Compare commits

..

No commits in common. "main" and "release-26.4-alpha" have entirely different histories.

141 changed files with 807 additions and 14214 deletions

View file

@ -12,7 +12,6 @@ on:
- '**/*.md'
- 'docs/**'
- 'website/**'
- 'ops/**'
- 'CHANGELOG.md'
- 'RELEASING.md'
workflow_dispatch:

View file

@ -1,69 +1,28 @@
name: Release
# Tag-triggered: when `git push origin <version>` lands, this builds the
# release tarball + the live-installer ISO, and publishes them both to
# the Forgejo releases page. Boxes POST /api/furtka/update to pull the
# tarball; fresh-install users download the ISO from the release page.
# release tarball and publishes it + the sha256 + release.json to the
# Forgejo releases page for that tag. Boxes then POST /api/furtka/update
# to pull from here.
#
# Runs on the self-hosted runner because iso/build.sh needs privileged
# docker access (mkarchiso wants root + loop mounts), and because the
# ubuntu-latest Forgejo hosted runner doesn't carry the docker socket
# bind-mount the build needs. Self-hosted adds ~5-7 min to the release
# (ISO build) but keeps the release page self-contained.
#
# Version tags only (CalVer like 26.0-alpha, 26.1, 27.0-beta). Random
# tags are ignored by the [0-9]* prefix.
# Version tags only (pattern matches CalVer like 26.0-alpha, 26.1, 27.0-beta).
# Documentation / random tags are ignored by the [0-9]* prefix.
on:
push:
tags: ['[0-9]*']
jobs:
release:
runs-on: self-hosted
timeout-minutes: 45
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
with:
fetch-depth: 0 # changelog section extraction needs history
- name: Install prerequisites
# Alpine runner is near-empty: we need curl + python3 for the
# publish script, bash for the build scripts.
run: apk add --no-cache curl python3 bash
- name: Build release tarball
run: ./scripts/build-release-tarball.sh "${GITHUB_REF_NAME}"
- name: Build live-installer ISO
# Same script build-iso.yml uses on every main push. Re-running
# here is intentional: guarantees the ISO matches the exact
# tagged commit without coordinating across workflows. Step-level
# continue-on-error so an ISO build flake doesn't block the
# core tarball (which is what boxes need for self-update) from
# publishing.
continue-on-error: true
id: build_iso
run: ./iso/build.sh
- name: Move ISO into dist/
# publish-release.sh attaches dist/furtka-<ver>.iso if present.
# Skipped gracefully when the build step above failed.
if: steps.build_iso.outcome == 'success'
run: |
iso=$(ls iso/out/*.iso | head -1)
cp "$iso" "dist/furtka-${GITHUB_REF_NAME}.iso"
- name: Publish to Forgejo releases
env:
FORGEJO_TOKEN: ${{ secrets.FORGEJO_RELEASE_TOKEN }}
run: ./scripts/publish-release.sh "${GITHUB_REF_NAME}"
- name: Refresh furtka.org download page
# The download page reads the latest release (ISO, size, date,
# checksum) from the Forgejo API at build time, so rebuild the site
# now that the release exists. Same runner + bind mounts as
# deploy-site.yml. A failure here doesn't touch the release.
continue-on-error: true
run: |
apk add --no-cache hugo rsync
./website/deploy-ci.sh

1
.gitignore vendored
View file

@ -13,4 +13,3 @@ iso/out/
website/public/
website/resources/
website/.hugo_build.lock
website/hugo_stats.json

View file

@ -7,579 +7,6 @@ This project uses calendar versioning: `YY.N-stage` (e.g. `26.0-alpha` = 2026, r
## [Unreleased]
## [26.21-alpha] - 2026-10-23
### Added
- **Furtka Gateway: apps reachable from the internet** (behind NAT/CGNAT,
no port forwarding). Settings → *Furtka Gateway*: enter the gateway URL
and registration token, the box registers and keeps a WireGuard tunnel to
a [furtka-gateway](https://forgejo.sourcegate.online/robert/furtka-gateway)
server (Traefik + wg-easy + control plane). Apps → ⋯ → *Expose to
Internet* publishes an app under its own hostname with Let's Encrypt;
*Stop exposing* takes it down again. Only apps whose manifest declares
`internet.viable: true` can be exposed. Client in `furtka/gateway.py` +
`furtka/wireguard.py`, CLI `furtka gateway …`.
- **New logo: Mark M** (a lowercase *f* cut out of an arched gate), winner of
the furtka.org vote (29 of 42 votes). It is now in the website header,
favicon and share card, the installer header and tab icon, the on-box UI
(every page and the login), the console welcome screens of the live ISO
and the installed box, and the BIOS boot-menu splash. Brand files in
`docs/brand/`, generated by `website/tools/brand-assets.py`.
- **furtka.org/download/** (and `/de/download/`): ISO download with size,
date and checksum, requirements and step-by-step install guide. Releases
now publish `furtka-<ver>.iso.sha256` and refresh the site.
### Security
- **App names are validated everywhere.** `POST /api/apps/remove` with
`{"name": ".."}` or `"/"` sent `shutil.rmtree` at `/var/lib/furtka` or the
root filesystem (and wiping `users.json` reopened first-run admin setup to
the LAN). Names from URLs, JSON bodies and manifests must now match
`^[a-z][a-z0-9_-]*$`; `installer.remove`/`update_env` check it too.
- **Settings can't inject into `.env`.** Line breaks are refused; values
with `$` are single-quoted so compose doesn't expand them (`pa$word` used
to become `pa`). Validation runs before the write, so a rejected `/etc`
path is no longer saved anyway.
- **Damaged `users.json` fails closed.** An empty/corrupt file used to read
as "no admin yet" and turned `/login` into an open create-admin form.
`/login` now answers 503 with SSH recovery steps; `save_users` fsyncs.
- **API server hardening:** threaded (state changes still serialised), 1 MiB
body cap, 30 s socket timeout, cross-site POSTs refused via
`Sec-Fetch-Site`, JSON 500 instead of dropped connections.
### Fixed
- **Installer disk picker:** SATA SSDs were labelled "HDD" and lost the
recommendation to bigger spinning disks; type now comes from lsblk
`TRAN`/`ROTA`. zram, eMMC boot/RPMB areas, read-only and 0-byte disks are
hidden; disks under 16 GB are greyed out; USB disks rank last; >2 TB disks
on BIOS machines get an MBR warning; the boot stick is also found via the
archiso kernel args when `bootmnt` is gone. Step 2 only accepts a listed disk.
- **Installer runs one install at a time** (double-click / second browser
started a second archinstall on the same disk), and a crashed archinstall
(OOM kill, signal) now shows as an error instead of a progress bar stuck
forever. Disk-probe exceptions land in the log instead of a bare 500.
- **Failed installs can be retried** from the error page without re-entering
everything.
- **No 2-minute boot stall on boards with a second NIC:** the target gets a
`systemd-networkd-wait-online --any` drop-in.
- **Self-update:** symlink flip is atomic; any error after the flip rolls
back (and restores the previous Caddyfile); health check survives read
timeouts; every failure ends in an `error` stage the settings page shows.
- **Interrupted app installs no longer block all later installs** — a stale
state with no live runner is marked failed after 60 s.
- **Catalog sync / update checks** no longer strand on socket resets or disk
full.
- **Apps page after an API restart** redirects to `/login` instead of
showing an empty app list.
- **32-bit UEFI machines** (Atom Bay Trail / Cherry Trail) get Limine
instead of systemd-boot: Arch has no ia32 systemd-boot and archinstall's
GRUB path hardcodes `x86_64-efi`, so these boxes installed but didn't boot.
- **Force-HTTPS redirect** only covers `<hostname>.local` / `<hostname>` and
is a 302. The old 301 for every host sent devices that use the IP to a
TLS handshake that can't succeed, and stayed cached after HTTPS was
turned off. Self-update migrates the old snippet.
- **Self-update starts newly added timers immediately and links units
through `current`.** `_link_new_units()` enabled a timer a release
introduced but never started it, so after the 26.19 → 26.20 update the
bench sat with `furtka-welcome.timer` `inactive (dead)` — and the console
banner without its IP line — until the next reboot. It also linked the
unit from `/opt/furtka/versions/<ver>/…` instead of `/opt/furtka/current/…`,
so the link would have stopped following later updates and rollbacks
(installer-created links use `current`). Now: link via `current`, enable,
start.
### Changed
- Wizard wording: "Server name" with a live `http://<name>.local` preview,
"Pick a disk" with an explicit erase warning, readable confirm table,
password hint explains it's also the SSH/console password, names are
lowercased instead of rejected, system account names are refused.
- Live console banner says a network cable with internet is required.
- ISO builds pin archinstall (4.4-1, sha256-checked from the Arch archive)
instead of taking whatever the repos ship that day; the build fails
clearly when Arch's Python moves on. Bump steps in `iso/README.md`.
- On-box UI polish: sticky top nav, bordered cards and status tiles, app
tile hover lift, brand-red section markers; animations respect
`prefers-reduced-motion`.
## [26.20-alpha] - 2026-08-25
First release shaped by a real-hardware install (old laptop mainboard, BIOS boot,
SSD with a previous Proxmox VE on it). All four fixes are things an empty
Proxmox VM never shows.
### Fixed
- **First catalog sync no longer dies silently at boot.** On dual-stack
LANs `network-online.target` is satisfied by the IPv6 SLAAC address a
second before DHCPv4 lands, so `furtka-catalog-sync.service` ran into
`Network is unreachable`, exited, and the next attempt was the daily timer
— up to 6 h of jitter later. Meanwhile `catalog-state.json` stayed on
`checking` and the apps page showed "checking…" with nothing running
(first hardware bench, 2026-08-25). The service now retries on failure
(every 20 s, up to 8 times in 15 min), a failed sync is recorded as stage
`error` with the message, and the apps page shows "last sync failed: …"
instead of a fake in-progress state.
- **Installer frees the target disk before partitioning.** The live ISO
auto-activates whatever an attached drive carries (LVM volume groups, md
arrays, swap) — the first hardware bench's SSD still had a Proxmox VE
`pve` VG on it — and archinstall then failed at 12 % with `unable to
inform the kernel of the change, probably because it/they are in use`.
`install_run` now runs `diskprep.release_disk()` first: swapoff/umount
leaves-first, `dmsetup remove` / `mdadm --stop` for every dm/md node
stacked on the disk, `wipefs -a` on partitions + disk, then
`blockdev --rereadpt`. Every step is written to the top of the install
log. The progress page also names this failure explicitly instead of a
bare "hit a snag" if it does still happen.
- **Console banner always shows the IP fallback.** On both the live ISO and
the installed system the `/etc/issue` welcome was written once, after
`network-online.target`, and simply omitted the `http://<ip>` line when no
address existed yet — so on a slow-DHCP NIC (first hardware bench,
2026-08-24) the screen only ever offered `proksi.local`. The banner script
now runs from a timer (`furtka-issue.timer` on the ISO,
`furtka-welcome.timer` on the installed box, every 5 s) and always prints
a fallback line: every global IPv4 address once there is one, or a
"no IP address yet — check the cable / DHCP" hint until then. It only
rewrites `/etc/issue` and nudges agetty when the text changed, so the
login prompt doesn't flicker. The live ISO no longer auto-logs `root` in
on tty1 (releng default) — that handed the console to a shell before the
banner could update; tty1 now stays at the login prompt with a live
banner, and the banner tells you a shell is `root` + Enter away. Boxes
that self-update get the new timer linked + enabled automatically; the
refresh loop takes effect after their next reboot.
## [26.19-alpha] - 2026-08-23
### Fixed
- **App installs now copy subdirectories, so dependency hooks actually
ship.** `install_from()` only copied an app's top-level files, so the
`scripts/` folder — where a provider's `on_install`/`on_start` dependency
hooks live — never reached `/var/lib/furtka/apps/<app>/`, and every hooked
dependency failed at reconcile with `hook ... missing in provider`. The
whole app folder is copied now (`rmtree` + `copytree`, so a reinstall also
drops files that were removed upstream). Found during the first real
end-to-end run of the mosquitto + zigbee2mqtt pair on a test VM.
- **`furtka app install <name>` now runs `on_install` hooks.** Named CLI
installs copied the app folders and then ran a bare reconcile, which fires
`on_start` but never `on_install` — so a CLI install of a consumer brought
its provider up without ever provisioning it (no account created, empty
`MQTT_*` values in the consumer's `.env`). Named installs now go through
`install_runner.run_install` (writing the plan file first), the same docker
phase the API dispatches, so providers come up before consumers and
`on_install` hooks run. Path-based installs keep the copy + reconcile
dev/test path.
## [26.18-alpha] - 2026-06-04
### Fixed
- **`on_start` dependency hooks now receive the consumer's stored
credentials.** Previously the reconciler handed an `on_start` hook only
`FURTKA_CONSUMER_APP`/`FURTKA_CONSUMER_VERSION`, so it had no way to learn
the consumer's existing secrets — which made the feature's own headline use
case (re-create a provider account, e.g. an MQTT user, after a wipe, with
the *same* password the consumer already holds) impossible without the
provider stashing a copy itself. The hook now also gets the consumer's `.env`
values, namespaced under `FURTKA_CONSUMER_ENV_<KEY>` (only UPPER_SNAKE_CASE
keys, so a hand-edited `.env` can't produce a malformed `--env` argument).
`on_start` stays read-only with respect to the consumer: unlike `on_install`,
its stdout is intentionally not merged back into the consumer's `.env` — it
reads consumer state to reconcile provider state, it doesn't mutate it.
Surfaced by building the first real provider/consumer catalog pair
(mosquitto + zigbee2mqtt) in daniel/furtka-apps.
## [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 <name>` 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-<app>` 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 <name>` (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-<version>.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 `<a>` rendered-as-button lines up with its `<button>` siblings in `.buttons`. Needed because "Open" is a real link (middle-click, copy URL, screen readers) and HTML doesn't let `<button>` carry `href`.
### Notes
- `26.7-alpha` was tagged but never published — the tag push didn't trigger `release.yml` (Forgejo race with the concurrent main push). `26.8-alpha` supersedes it and carries the same content plus power actions.
## [26.6-alpha] - 2026-04-20
### Added
- **Apps catalog synced independently of core.** A new `daniel/furtka-apps` Forgejo repo carries the bundled app catalog; running boxes pull the latest release via `furtka-catalog-sync.timer` (10 min post-boot + daily, ±6 h jitter) and extract atomically into `/var/lib/furtka/catalog/`. The resolver now prefers catalog apps over the seed `/opt/furtka/current/apps/` tree that ships inside the core release tarball, so apps can update without cutting a Furtka core release. Manual trigger: "Sync apps catalog" button on `/apps`, or `sudo furtka catalog sync` at the console. Fresh boxes with no network fall back to the seed, so offline first-boot still shows installable apps. Installed apps are never auto-swapped — users click Reinstall in `/apps` to move an existing install onto a newer catalog version (settings merge-preserved via the existing `installer.install_from` path).
- **Catalog CLI**: `furtka catalog sync [--check] [--json]` + `furtka catalog status [--json]`. Same shape as the core `furtka update` commands.
- **Catalog API endpoints**: `POST /api/catalog/sync/check`, `POST /api/catalog/sync/apply` (detached via `systemd-run` for symmetry with `/api/furtka/update/apply`), `GET /api/catalog/status`. The existing `/api/bundled` endpoint keeps working as a backwards-compat alias for `/api/apps/available`, which now returns the union of catalog + seed apps with a new `"source"` field on each entry (`"catalog"` | `"bundled"`).
### Changed
- **`furtka._release_common`** extracted from `furtka.updater`. Both `updater` and the new `catalog` module now share one implementation of the Forgejo-releases-API call, SHA256 verification, path-traversal-guarded tarball extraction, and CalVer comparison. Public updater surface unchanged.
- **`_link_new_units` now auto-enables newly-linked `.timer` units.** On self-update, a fresh timer file (e.g. `furtka-catalog-sync.timer` added in this release) needs `systemctl enable` to actually start firing — linking alone isn't enough. Fresh installs get their enable via the webinstaller's `_FURTKA_UNITS` list as before.
### Fixed
- **SHA-256 CA fingerprint no longer overflows the `/settings` Local HTTPS card** on narrow viewports. `.kv dd` grid items now set `min-width: 0` + `overflow-wrap: anywhere` so the colon-separated hex string breaks within the card's right edge instead of pushing past it.
## [26.5-alpha] - 2026-04-20
### Fixed
- **HTTPS handshake regression on the installed box (#10).** Phase 1 shipped two linked bugs: the `:443 { tls internal }` site block had no hostname, so Caddy never issued a leaf cert and every SNI handshake died with `SSL_ERROR_INTERNAL_ERROR_ALERT`; and both `furtka.https` and the Caddyfile's `/rootCA.crt` handler referenced `/var/lib/caddy/.local/share/caddy/pki/…`, a path that doesn't exist because our systemd unit sets `XDG_DATA_HOME=/var/lib`. Force-HTTPS toggle made the brokenness user-visible by redirecting working HTTP to dead HTTPS. Fixed: the Caddyfile now ships a `__FURTKA_HOSTNAME__.local, __FURTKA_HOSTNAME__ { tls internal }` block with the placeholder substituted at install time (`webinstaller/app.py`) and on every self-update (`furtka.updater._refresh_caddyfile` reads `/etc/hostname`). `auto_https disable_redirects` keeps Caddy's built-in redirect out of the way of the `/settings` toggle. PKI paths corrected in both `furtka/https.py` and `assets/Caddyfile`. Verified end-to-end on the 192.168.178.110 test VM: TLS 1.3 handshake completes, leaf cert issued, `/rootCA.crt` returns 200.
### Changed
- **Wizard footer version is now dynamic.** `webinstaller/app.py` resolves the Furtka version at startup via a Flask context processor — reads `/opt/furtka/VERSION` on the live ISO (written by `iso/build.sh` from `pyproject.toml` at build time), falls back to `pyproject.toml` in dev runs, then to literal `"dev"`. The 26.4 footer was hand-pinned and drifted within hours of release; that follow-up item is now closed.
- **Docs realigned with 26.4-alpha reality.** `apps/README.md` added (manifest schema, volume namespacing, `.env.example` guardrails, SVG sanitiser limits, install/test flow). Root `README.md` roadmap updated with Phase 1 HTTPS + smoke-VM pipeline as shipped items and 26.4-alpha in the release list. `iso/README.md` corrected: mDNS is wired (not "later milestone"), post-install default URL is `http://furtka.local` (not `proksi.local`), HTTPS is available via `tls internal` since 26.4. `website/README.md` now documents the auto-deploy on push-to-main as the default path, manual `deploy.sh` as the SSH-hop fallback.
## [26.4-alpha] - 2026-04-18
### Added
@ -659,23 +86,7 @@ First tagged snapshot. Pre-alpha — the installer does not yet boot, but the de
- **Containers:** Docker + Compose
- **License:** AGPL-3.0
[Unreleased]: https://forgejo.sourcegate.online/daniel/furtka/compare/26.21-alpha...HEAD
[26.21-alpha]: https://forgejo.sourcegate.online/daniel/furtka/releases/tag/26.21-alpha
[26.20-alpha]: https://forgejo.sourcegate.online/daniel/furtka/releases/tag/26.20-alpha
[26.19-alpha]: https://forgejo.sourcegate.online/daniel/furtka/releases/tag/26.19-alpha
[26.18-alpha]: https://forgejo.sourcegate.online/daniel/furtka/releases/tag/26.18-alpha
[26.17-alpha]: https://forgejo.sourcegate.online/daniel/furtka/releases/tag/26.17-alpha
[26.16-alpha]: https://forgejo.sourcegate.online/daniel/furtka/releases/tag/26.16-alpha
[26.15-alpha]: https://forgejo.sourcegate.online/daniel/furtka/releases/tag/26.15-alpha
[26.14-alpha]: https://forgejo.sourcegate.online/daniel/furtka/releases/tag/26.14-alpha
[26.13-alpha]: https://forgejo.sourcegate.online/daniel/furtka/releases/tag/26.13-alpha
[26.12-alpha]: https://forgejo.sourcegate.online/daniel/furtka/releases/tag/26.12-alpha
[26.11-alpha]: https://forgejo.sourcegate.online/daniel/furtka/releases/tag/26.11-alpha
[26.10-alpha]: https://forgejo.sourcegate.online/daniel/furtka/releases/tag/26.10-alpha
[26.9-alpha]: https://forgejo.sourcegate.online/daniel/furtka/releases/tag/26.9-alpha
[26.8-alpha]: https://forgejo.sourcegate.online/daniel/furtka/releases/tag/26.8-alpha
[26.6-alpha]: https://forgejo.sourcegate.online/daniel/furtka/releases/tag/26.6-alpha
[26.5-alpha]: https://forgejo.sourcegate.online/daniel/furtka/releases/tag/26.5-alpha
[Unreleased]: https://forgejo.sourcegate.online/daniel/furtka/compare/26.4-alpha...HEAD
[26.4-alpha]: https://forgejo.sourcegate.online/daniel/furtka/releases/tag/26.4-alpha
[26.3-alpha]: https://forgejo.sourcegate.online/daniel/furtka/releases/tag/26.3-alpha
[26.2-alpha]: https://forgejo.sourcegate.online/daniel/furtka/releases/tag/26.2-alpha

View file

@ -1,5 +1,3 @@
<p><img src="docs/brand/furtka-lockup.svg" alt="Furtka" height="56"></p>
# Furtka
**Open-source home server OS — simple enough for everyone.** · [furtka.org](https://furtka.org)
@ -56,7 +54,7 @@ Furtka aims to be:
| Identity provider | Authentik | Bundled SSO from day one — every app template auto-wires to it at install |
| Managed gateway DNS | NS delegation to `ns1.furtka.org` | User delegates once at registrar; we handle wildcard cert + subdomain creation |
| Local HTTPS | Local CA | One-click CA install → green padlock on every service, no browser warnings |
| Gateway | Traefik + wg-easy ([furtka-gateway](https://forgejo.sourcegate.online/robert/furtka-gateway)) | Decided 2026-10-23. Self-hosted on any VPS with a public IPv4 via `deploy.sh`, or managed through our infrastructure later. Boxes dial out over WireGuard, so no port forwarding at home. |
| Gateway | Flexible | Own reverse proxy OR managed through our infrastructure |
| UI approach | UI-first | Design the simplest possible UI, then build everything to match |
## Landscape (Existing Projects)
@ -104,14 +102,13 @@ None of these nail the "your dad can set this up" experience. The installer wiza
- [x] Installer webapp prototype — device reader + form → JSON (Robert)
- [x] Arch running on Proxmox, custom image builds in progress (Robert)
- [x] Competitor analysis — see [docs/competitors.md](docs/competitors.md)
- [x] First bare-metal install (old laptop board, BIOS, SSD with a previous Proxmox VE) — 4 bugs found and shipped in 26.20-alpha, report in [docs/hardware-test-2026-08.md](docs/hardware-test-2026-08.md)
- [x] Wizard flow spec — see [docs/wizard-flow.md](docs/wizard-flow.md)
- [x] Release process + CI — CalVer tags, conventional commits, Forgejo Actions (ruff, pytest, JSON, link checks), `26.0-alpha` tagged
- [x] Forgejo runner live on Proxmox VM (`forge-runner-01`, Ubuntu 24.04) — docker-outside-of-docker with host-mode jobs for ISO builds, setup captured in [docs/runner-setup.md](docs/runner-setup.md) + [ops/forgejo-runner/](ops/forgejo-runner/)
- [x] **ISO-build in CI** — `.forgejo/workflows/build-iso.yml` runs `iso/build.sh` on every push to `main` and publishes the resulting `.iso` as the `furtka-iso` artifact (14 d retention). Push → green run → download → test.
- [x] **Forgejo Releases + tag-driven release pipeline** — `.forgejo/workflows/release.yml` fires on `[0-9]*` tags, `scripts/build-release-tarball.sh` packages `furtka/` + `apps/` + `assets/` + a root VERSION, `scripts/publish-release.sh` uploads tarball + sha256 + release.json to the Forgejo releases page. Releases `26.1-alpha`, `26.3-alpha`, and `26.4-alpha` live at [releases](https://forgejo.sourcegate.online/daniel/furtka/releases) (26.2 stalled on a `jq` apt hang, fixed in 26.3). Needs one repo secret (`FORGEJO_RELEASE_TOKEN`).
- [x] **Forgejo Releases + tag-driven release pipeline** — `.forgejo/workflows/release.yml` fires on `[0-9]*` tags, `scripts/build-release-tarball.sh` packages `furtka/` + `apps/` + `assets/` + a root VERSION, `scripts/publish-release.sh` uploads tarball + sha256 + release.json to the Forgejo releases page. `26.1-alpha` and `26.3-alpha` live at [releases](https://forgejo.sourcegate.online/daniel/furtka/releases). Needs one repo secret (`FORGEJO_RELEASE_TOKEN`).
- [x] **Walking-skeleton live ISO — end to end** — `iso/build.sh` produces a hybrid BIOS/UEFI Arch-based ISO. It boots in a Proxmox VM, DHCPs onto the LAN, shows a console welcome with `http://proksi.local:5000` (+ IP fallback), serves the Flask webinstaller, runs `archinstall --silent`, reboots the VM via a Reboot-now button, and the installed system logs in and runs `docker ps` without sudo. Build infra in [`iso/`](iso/).
- [x] **Drop loop/rom devices from drive list** — `webinstaller/drives.py` filters by `lsblk` `TYPE=disk`, so the live squashfs and CD-ROM no longer appear as install targets. The boot USB itself is also filtered: on the live ISO, `findmnt /run/archiso/bootmnt` resolves the boot partition and its parent disk is dropped from the picker.
- [x] **Drop loop/rom devices from drive list** — `webinstaller/drives.py` filters by `lsblk` `TYPE=disk`, so the live squashfs and CD-ROM no longer appear as install targets. Boot-USB filtering on bare metal is still TODO; see [iso/README.md](iso/README.md).
- [x] **Rebrand GRUB menu** — `iso/build.sh` rewrites "Arch Linux install medium" → "Furtka Live Installer" across GRUB, syslinux, and systemd-boot configs; default entry marked `(Recommended)`.
- [x] **Wizard: account form → drive picker → overview → archinstall** — S1 collects hostname/user/password/language with validation, S2 picks boot drive, overview confirms, `/install/run` writes `user_configuration.json` + `user_credentials.json` (0600) and execs `archinstall --silent` against its 4.x schema (`default_layout` disk_config + `!root-password` / `!password` sentinel keys + `custom_commands` for post-install group joins). Install log page polls a JSON endpoint and renders a phase-based progress bar with a collapsible raw log. `FURTKA_DRY_RUN=1` skips the real exec for testing.
- [x] **mDNS `proksi.local`** — hostname baked into the live ISO, avahi + nss-mdns in the package list, advertised as soon as network-online fires. The HTTPS + local-CA half of this milestone is still open below.
@ -120,15 +117,10 @@ None of these nail the "your dad can set this up" experience. The installer wiza
- [x] **On-box web UI uplevel** — shared `/style.css` served by Caddy, persistent top nav, landing page with an "Your apps" tile grid + live status, `/apps` with real per-app icons (inlined SVG from each manifest), new `/settings` page (hostname, IP, version, kernel, RAM, Docker, uptime + Furtka-updates card). `prefers-color-scheme` light/dark.
- [x] **Versioned on-box layout + Phase 1 per-app updates** — `/opt/furtka/versions/<ver>/` + `current` symlink; `/var/lib/furtka/` for runtime state. `POST /api/apps/<name>/update` runs `docker compose pull` + compares digests + conditional `up -d`.
- [x] **Phase 2 Furtka self-update** — `/settings` → Check → Update now. Downloads signed tarball (SHA256), stages, atomic symlink flip, reloads Caddy, daemon-reload, restarts services, health-checks the new api with auto-rollback on failure. CLI: `furtka update [--check]` + `furtka rollback`. Validated end-to-end on VM 2026-04-16 (`26.0-alpha` → `26.3-alpha` → rollback → reboot).
- [x] **Local HTTPS Phase 1** — Caddy `tls internal` on `:443` is fully opt-in via the `/settings` toggle (26.15-alpha); fresh installs stay HTTP-only so a half-trusted cert chain can't lock the user out. Per-box root CA generated on first enable, `rootCA.crt` downloadable from `/settings`, per-OS install guide at `/https-install/`. The "force HTTPS" sub-toggle still only appears once the current browser already trusts the cert.
- [x] **Post-build smoke VM on Proxmox** — `.forgejo/workflows/build-iso.yml` hands the freshly built ISO to `scripts/smoke-vm.sh`, which boots it in a throwaway VM on `pollux` (192.168.178.165) and curls the webinstaller on `:5000`. VMID range 9000–9099, last 5 kept. Green end-to-end since 26.4-alpha.
- [x] **Furtka Gateway (Access Gate)** — boxes behind NAT/CGNAT register with a [furtka-gateway](https://forgejo.sourcegate.online/robert/furtka-gateway) server (Traefik + wg-easy + control plane) over a WireGuard tunnel; Settings → *Furtka Gateway* connects, Apps → ⋯ → *Expose to Internet* publishes an app under its own hostname with Let's Encrypt. Only apps with `internet.viable: true` in the manifest can be exposed. Shipped in `26.21-alpha`; first gateway running at `gw.furtka.org` (Hetzner, 2026-10-23).
- [x] **`26.21-alpha`** — security fixes (app-name validation, `.env` injection, fail-closed `users.json`, API hardening), installer fixes, Mark M logo, furtka.org/download, on-box UI polish. See [CHANGELOG](CHANGELOG.md).
- [ ] Gateway dogfooding on real hardware — two old laptops as boxes, one per brother, both behind CGNAT
- [ ] Installer wizard screens S3–S7 — per-device purpose, network, domain, SSL, diagnostic. S5/S6 blocked on managed-gateway DNS infra not yet built.
- [ ] Local HTTPS Phase 2 — dedicated local CA (not Caddy's `tls internal`), streamlined one-click install across Win/Mac/Linux/Android, and HTTPS on the live-installer wizard (`https://proksi.local:5000`).
- [ ] `https://proksi.local` with a local CA (today: plain HTTP at `http://proksi.local:5000`)
- [ ] Caddy + Authentik wired into first-boot bootstrap
- [ ] Managed gateway DNS — `ns1/ns2.furtka.org` + DNS-01 wildcard automation (the gateway itself works today with your own domain and per-app or wildcard A records)
- [ ] Managed gateway infrastructure — `ns1/ns2.furtka.org` + DNS-01 wildcard automation
- [ ] First containerized service (Nextcloud?) with auto-SSO + auto-subdomain
- [ ] Competitor hands-on testing on Proxmox — validate findings from docs/competitors.md
- [ ] UI mockups / drafts (Robert)

View file

@ -45,7 +45,7 @@ Tag per meaningful milestone, not on a calendar. A milestone is: ISO boots, a wi
git push origin 26.1-alpha
```
5. **The release workflow does the rest.** `.forgejo/workflows/release.yml` fires on the tag push and runs on the self-hosted runner: `scripts/build-release-tarball.sh` builds the self-update payload (tarball + sha256 + release.json under `dist/`), `iso/build.sh` builds the live-installer ISO, `scripts/publish-release.sh` uploads tarball + sha256 + release.json + ISO to the Forgejo release page. Pre-release is flagged automatically based on the suffix (`-alpha`/`-beta`/`-rc`). The ISO gets a `furtka-<ver>.iso.sha256` sidecar, and a last step rebuilds furtka.org so the download page (`/download/`) picks up the new ISO, size, date and checksum from the Forgejo API. ISO build is `continue-on-error`: a flaky ISO step doesn't block the core tarball (the thing boxes need for self-update).
5. **The release workflow does the rest.** `.forgejo/workflows/release.yml` fires on the tag push: `scripts/build-release-tarball.sh` builds the self-update payload (tarball + sha256 + release.json under `dist/`), `scripts/publish-release.sh` uploads all three assets to the Forgejo release page. Pre-release is flagged automatically based on the suffix (`-alpha`/`-beta`/`-rc`).
The release workflow needs one secret set at repo **Settings → Secrets → Actions**:
- `FORGEJO_RELEASE_TOKEN` — a PAT with `write:repository` scope.

View file

@ -1,145 +0,0 @@
# Building a Furtka app from a Docker image
A Furtka app is a folder with four files. The reconciler walks `/var/lib/furtka/apps/*` at boot, validates each manifest, ensures the declared volumes exist, and runs `docker compose up -d` per app. Filesystem is the only source of truth — no database.
Use `apps/fileshare/` as the reference implementation.
## Folder layout
```
apps/<name>/
manifest.json # required — app metadata and user-facing settings
docker-compose.yaml # required — filename is .yaml, not .yml
.env.example # required — keys consumed by docker-compose, with safe defaults
icon.svg # required — referenced by manifest.icon
```
The folder name must equal `manifest.name`. The scanner rejects mismatches.
## `manifest.json`
All top-level fields except `description_long` and `settings` are required.
```json
{
"name": "myapp",
"display_name": "My App",
"version": "0.1.0",
"description": "One-line summary shown in the app list.",
"description_long": "Longer German prose shown on the app page. Optional.",
"volumes": ["data"],
"ports": [8080],
"icon": "icon.svg",
"settings": [
{
"name": "ADMIN_PASSWORD",
"label": "Passwort",
"description": "Wird beim ersten Start gesetzt.",
"type": "password",
"required": true
}
]
}
```
Rules enforced by `furtka/manifest.py`:
- `volumes` — short names, strings. Namespaced to `furtka_<app>_<short>` at runtime.
- `ports` — integers. Informational only; compose owns the actual port binding.
- `settings[].name` — must match `^[A-Z_][A-Z0-9_]*$`. This name becomes both the env-var key and the form-field ID.
- `settings[].type` — one of `text`, `password`, `number`, `path`.
- `settings[].required` — if true, the install refuses when the value is empty.
- `settings[].default` — optional string. Used to pre-fill the form and the bootstrapped `.env`.
### Path-type settings (host bind mounts)
Use `"type": "path"` when the app should point at an existing folder on the host — media libraries, document archives, photo backups. The value is written to `.env` like any other setting, and compose consumes it via `${VAR}` substitution as a bind mount.
```json
{
"name": "MEDIA_PATH",
"label": "Medienordner",
"description": "Absoluter Pfad zu deinem Medien-Ordner, z.B. /mnt/media.",
"type": "path",
"required": true
}
```
```yaml
services:
app:
volumes:
- ${MEDIA_PATH}:/media:ro
```
The installer (`install_from` and `update_env`) refuses values that:
- aren't absolute (must start with `/`),
- don't exist on the host,
- aren't directories,
- 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`.
Traversal like `/mnt/../etc` is caught too — the deny-list check runs on the resolved path.
Path settings sit alongside manifest-declared volumes. Use `manifest.volumes` for internal state the app owns (databases, caches, config), and path settings for user data the container should mount and — usually — read without owning. Mounting read-only (`:ro`) is a good default for data the app only consumes.
## `docker-compose.yaml`
- File extension is `.yaml`. The compose runner hardcodes this — `.yml` will not be found.
- Reference manifest volumes as `furtka_<app>_<short>` with `external: true`. The reconciler creates the volume *before* `compose up`, so compose must not try to manage its lifecycle.
- Values from `.env` are substituted by compose in the usual `${VAR}` form.
- If the upstream image ships a HEALTHCHECK that misbehaves on Furtka's setup, disable it — a permanently-unhealthy container scares users reading `docker ps`.
- Pin images to a digest or stable tag when you can. `:latest` is acceptable for an MVP but noisy.
Minimal example:
```yaml
services:
app:
image: ghcr.io/example/myapp:1.2.3
restart: unless-stopped
environment:
- ADMIN_PASSWORD=${ADMIN_PASSWORD}
ports:
- "8080:8080"
volumes:
- furtka_myapp_data:/var/lib/myapp
volumes:
furtka_myapp_data:
external: true
```
## `.env.example`
One `KEY=VALUE` per line. Every key declared in `manifest.settings` should have a line here so the compose file resolves cleanly on first install even before the user opens the form.
Do not use `changeme` (or any value listed in `furtka.installer.PLACEHOLDER_SECRETS`) as the default for a required secret. The install step scans the final `.env` and refuses to finish if a placeholder survives — this is the guardrail that stops us shipping an app with a known password.
For non-secret values (usernames, paths), sensible defaults are fine and go straight into `.env` on first install.
## `icon.svg`
- 64×64 viewBox, no width/height attributes so the UI can scale it.
- Use `fill="currentColor"` (and `stroke="currentColor"`) so the icon picks up the current theme instead of baking in a color.
- Keep it single-path-ish. These render small in the app grid.
- The icon is inlined into the `/apps` page by the defensive SVG sanitiser, which strips `<script>`, `on*` attributes, and `javascript:` refs and enforces a 16 KB cap. Anything fancier than static paths and shapes will be rejected.
## Install and test
From the repo root on a dev box with Furtka installed:
```
sudo furtka app install ./apps/myapp
```
`furtka app install` runs a reconcile as its last step, so the container is up once the command returns. Open the Web UI (`http://furtka.local/`), fill in the settings form, and confirm the app starts. `docker ps` should show one container per compose service; `docker volume ls` should show `furtka_myapp_*`.
To bundle the app into the ISO, drop the folder into `apps/` before `iso/build.sh` runs — the build tarballs the whole `apps/` tree into the image.
## Out of scope (for now)
- Sharing volumes between apps. v1 keeps them isolated.
- Auth on the Web UI. The UI itself has a banner about this.
- Automatic updates. User-triggered per-app update is `POST /api/apps/<name>/update`.
- A network catalog. `furtka app install <name>` only resolves bundled apps in `/opt/furtka/apps/`.

View file

@ -1,13 +1,12 @@
{
"name": "fileshare",
"display_name": "Network Files",
"version": "0.1.2",
"version": "0.1.1",
"description": "SMB share for Mac, Windows, Linux and Android devices on the LAN.",
"description_long": "Alle Geräte im WLAN sehen einen gemeinsamen Ordner. Funktioniert mit Windows, Mac, Linux und Android. Verbinden zu smb://furtka.local — Anmeldung mit dem hier gesetzten Benutzernamen und Passwort.",
"volumes": ["files"],
"ports": [445, 139],
"icon": "icon.svg",
"open_url": "smb://{host}/files",
"settings": [
{
"name": "SMB_USER",

View file

@ -23,8 +23,7 @@
"git",
"htop",
"curl",
"openssh",
"wireguard-tools"
"openssh"
],
"profile": {

View file

@ -1,35 +1,18 @@
# Serves the Furtka landing page + live JSON on :80 (plain HTTP). HTTPS
# is **opt-in** — Caddy doesn't serve :443 until the user clicks the
# "Enable HTTPS" toggle on /settings, which drops an import snippet into
# /etc/caddy/furtka-https.d/. Default install has NO tls site block →
# Caddy never generates a self-signed CA / leaf cert → no
# SEC_ERROR_BAD_SIGNATURE when a user visits https://furtka.local before
# they've trusted anything. That was the 26.14-era regression this file
# exists to cure: the old Caddyfile always served :443 with a freshly-
# generated cert, and a browser that had ever trusted an older Furtka
# box's CA would reject the new one with an unbypassable bad-sig error.
# Serves the Furtka landing page + live JSON on :80 (plain HTTP) and :443
# (HTTPS via Caddy's built-in `tls internal` — locally-issued certs signed
# by a root CA that Caddy generates on first start and stores under
# /var/lib/caddy/.local/share/caddy/pki/authorities/local/). Static pages
# are read from /opt/furtka/current/ — updates flip the symlink and
# everything picks up the new content without a Caddy restart (a
# `systemctl reload caddy` is still triggered post-swap to flush the
# file-server's handle cache). /apps and /api are reverse-proxied to the
# resource-manager API (furtka serve, bound to 127.0.0.1:7000).
#
# /apps, /api, /login, /logout, / (home), /settings are reverse-proxied
# to the resource-manager API (furtka serve, bound to 127.0.0.1:7000).
# Static pages are read from /opt/furtka/current/ — updates flip the
# symlink and everything picks up the new content without a Caddy
# restart (a `systemctl reload caddy` is still triggered post-swap to
# flush the file-server's handle cache).
#
# Two snippet dirs, both silently no-op when empty:
# - /etc/caddy/furtka.d/*.caddyfile → imported inside the :80 block.
# The HTTPS toggle's "force HTTP→HTTPS redirect" snippet lands here.
# - /etc/caddy/furtka-https.d/*.caddyfile → imported at TOP LEVEL, so
# the HTTPS hostname+tls-internal site block can drop in here when
# the toggle is on. Hostname is substituted at toggle-time.
{
# Named-hostname :443 blocks would otherwise make Caddy add its own
# HTTP→HTTPS redirect — but we already serve our own `:80` block and
# the opt-in /settings toggle owns the redirect. Disable the built-in
# to keep a single source of truth.
auto_https disable_redirects
}
# Force-HTTPS: /etc/caddy/furtka.d/*.caddyfile gets imported into the :80
# block. The /api/furtka/https/force endpoint creates or removes
# redirect.caddyfile there to toggle the HTTP→HTTPS redirect, then reloads
# Caddy. Glob imports silently no-op on an empty/missing directory, so the
# toggle-off state is "no file present" rather than "empty file".
(furtka_routes) {
handle /api/* {
reverse_proxy localhost:7000
@ -37,26 +20,6 @@
handle /apps* {
reverse_proxy localhost:7000
}
handle /login* {
reverse_proxy localhost:7000
}
handle /logout* {
reverse_proxy localhost:7000
}
# /settings and / — these previously served as static HTML straight
# from the catch-all file_server, which meant the auth-guard was
# bypassed: a LAN visitor could see the box's version, IP, and
# reach the Update-now / Reboot buttons (the API calls behind them
# are auth-gated, but the page itself rendered without a redirect
# to /login). Route them through the Python handler which checks
# the session cookie and either serves the static HTML from
# assets/www/ or redirects to /login.
handle /settings* {
reverse_proxy localhost:7000
}
handle / {
reverse_proxy localhost:7000
}
# Runtime JSON lives under /var/lib/furtka/ so it survives self-updates
# (which only swap /opt/furtka/current).
handle /status.json {
@ -72,10 +35,10 @@
file_server
}
# Download the local root CA cert Caddy generated for `tls internal`.
# Public because users need to grab it before they've trusted it.
# The private key next to it stays 0600 / caddy-owned.
# Available on both :80 and :443 so users can grab it before they've
# trusted it. The private key next to it stays 0600 / caddy-owned.
handle /rootCA.crt {
root * /var/lib/caddy/pki/authorities/local
root * /var/lib/caddy/.local/share/caddy/pki/authorities/local
rewrite * /root.crt
file_server
header Content-Type "application/x-x509-ca-cert"
@ -91,12 +54,12 @@
}
}
# HTTPS opt-in: when /settings toggles HTTPS on, a snippet gets written
# into /etc/caddy/furtka-https.d/ that adds the hostname+tls-internal
# site block. Empty directory = HTTP-only (default fresh install).
import /etc/caddy/furtka-https.d/*.caddyfile
:80 {
import /etc/caddy/furtka.d/*.caddyfile
import furtka_routes
}
:443 {
tls internal
import furtka_routes
}

View file

@ -1,47 +1,22 @@
#!/bin/bash
# Regenerates /etc/issue on the installed system so the console tells the
# user which URL to open. Mirrors the live-ISO furtka-update-issue pattern:
# runs at boot and every few seconds from furtka-welcome.timer, so the IP
# fallback line is always on screen — with the address once DHCP handed one
# out, or with a "no IP yet" hint before that / after the cable is pulled.
# Only rewrites + nudges agetty when the text changed (no prompt flicker).
set -u
# user which URL to open. Mirrors the live-ISO furtka-update-issue pattern.
set -e
issue=/etc/issue
hostname=$(cat /etc/hostname)
ip=$(ip -4 -o addr show scope global 2>/dev/null | awk '{print $4}' | cut -d/ -f1 | head -1)
hostname=$(cat /etc/hostname 2>/dev/null || hostname)
mapfile -t ips < <(ip -4 -o addr show scope global 2>/dev/null | awk '{print $4}' | cut -d/ -f1)
new=$(
# Furtka mark (docs/brand/console-banner.txt, from brand-assets.py)
echo ' ▄▄▄▄▄▄▄▄'
echo ' ▄██████████▄'
echo ' █████▀▀▀▀▀▀███'
echo ' ████▀ ████'
echo ' ████ █████████'
echo ' ████ ████'
echo ' ████ ▄▄▄▄▄████'
echo ' ████ █████████'
echo ' ████ █████████'
echo ' ▀▀▀▀ ▀▀▀▀▀▀▀▀▀'
{
echo
echo " Furtka is ready."
echo
echo " Open in a browser on another device on your network:"
echo
echo " http://${hostname}.local (easy — try this first)"
if ((${#ips[@]})); then
for ip in "${ips[@]}"; do
if [ -n "$ip" ]; then
echo " http://${ip} (fallback if the first doesn't work)"
done
else
echo " (fallback: no IP address yet — waiting for the network."
echo " Check the cable / DHCP. This line updates by itself.)"
fi
echo
)
} > /etc/issue
if [ "$new" != "$(cat "$issue" 2>/dev/null)" ]; then
printf '%s\n' "$new" > "$issue"
agetty --reload 2>/dev/null || true
fi

View file

@ -1,21 +0,0 @@
[Unit]
Description=Furtka apps catalog sync
Requires=network-online.target
After=network-online.target
# network-online.target is satisfied by *any* routable address. On dual-stack
# LANs that is usually the IPv6 SLAAC address, which lands a second or so
# before DHCPv4 — and the first sync then dies with "Network is unreachable"
# (first hardware bench, 2026-08-25). Retry a few times instead of waiting
# for the daily timer.
StartLimitIntervalSec=15min
StartLimitBurst=8
[Service]
Type=oneshot
ExecStart=/usr/local/bin/furtka catalog sync
TimeoutStartSec=5min
Restart=on-failure
RestartSec=20s
[Install]
WantedBy=multi-user.target

View file

@ -1,14 +0,0 @@
[Unit]
Description=Furtka apps catalog daily sync
[Timer]
# First sync 10 min after boot, then once per day with up to 6 h jitter so
# a fleet of boxes doesn't all hit Forgejo at the same second. Persistent
# = catch up if the box was off when the timer should have fired.
OnBootSec=10min
OnUnitActiveSec=24h
RandomizedDelaySec=6h
Persistent=true
[Install]
WantedBy=timers.target

View file

@ -1,12 +1,12 @@
[Unit]
Description=Furtka console welcome banner
# No network-online dependency on purpose: paint the banner (with a
# "no IP yet" fallback) as early as possible; furtka-welcome.timer re-runs
# it every few seconds so the address appears as soon as DHCP delivers.
After=network-online.target
Wants=network-online.target
[Service]
Type=oneshot
ExecStart=/opt/furtka/current/assets/bin/furtka-welcome
RemainAfterExit=yes
[Install]
WantedBy=multi-user.target

View file

@ -1,10 +0,0 @@
[Unit]
Description=Keep the Furtka console welcome (IP fallback) up to date
[Timer]
OnBootSec=3s
OnUnitActiveSec=5s
AccuracySec=1s
[Install]
WantedBy=timers.target

View file

@ -1 +0,0 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 64 64"><style>svg{color:#c03a28}@media (prefers-color-scheme:dark){svg{color:#ff6b56}}</style><path fill="currentColor" fill-rule="evenodd" d="M5 60V27a27 27 0 0 1 54 0v33Z M19.5 60V26A10 10 0 0 1 29.5 16H46v10H32a2.5 2.5 0 0 0-2.5 2.5V32H46v10H29.5v18Z"/></svg>

Before

Width:  |  Height:  |  Size: 316 B

View file

@ -2,7 +2,6 @@
<html lang="en">
<head>
<meta charset="utf-8">
<link rel="icon" type="image/svg+xml" href="/favicon.svg">
<title>Install local HTTPS · Furtka</title>
<meta name="viewport" content="width=device-width,initial-scale=1">
<link rel="stylesheet" href="/style.css">

View file

@ -2,7 +2,6 @@
<html lang="en">
<head>
<meta charset="utf-8">
<link rel="icon" type="image/svg+xml" href="/favicon.svg">
<title>Furtka</title>
<meta name="viewport" content="width=device-width,initial-scale=1">
<link rel="stylesheet" href="/style.css">
@ -15,7 +14,6 @@
<a href="/" aria-current="page">Home</a>
<a href="/apps">Apps</a>
<a href="/settings/">Settings</a>
<a href="#" id="logout-link" onclick="return doLogout(event)">Logout</a>
</div>
</nav>
<header>
@ -69,26 +67,6 @@
</main>
<script>
// Sessions live in API memory, so every restart or self-update logs
// everyone out. Send the browser to the login page instead of rendering
// the 401 body as if the box had no apps.
const _fetch = window.fetch.bind(window);
window.fetch = async (...args) => {
const r = await _fetch(...args);
if (r.status === 401) { location.href = '/login'; }
return r;
};
// Revoke the cookie server-side and bounce to /login. Shared
// shape with the _HTML in furtka/api.py so the two logout
// links behave identically.
async function doLogout(ev) {
ev.preventDefault();
try { await fetch('/logout', { method: 'POST', credentials: 'same-origin' }); }
catch (e) { /* server may already be down */ }
window.location.href = '/login';
return false;
}
// Hostname + install metadata — written once at install time to
// /var/lib/furtka/furtka.json (see _furtka_json_cmd in the installer).
// Separate from status.json because these facts don't change between
@ -114,17 +92,13 @@
}
function primaryAction(app) {
// open_url is a manifest-declared template with a `{host}`
// placeholder — substituted against the current browser's
// hostname so smb://host/files and http://host:3001/ both
// follow however the user reached Furtka (furtka.local, raw
// IP, a future reverse-proxy hostname). Apps without a
// frontend fall back to /apps for management.
if (app.open_url) {
const host = HOSTNAME || location.hostname;
return { href: app.open_url.replace('{host}', host), label: 'Open', external: true };
// Only fileshare has a direct "open" link today. Future apps with
// HTTP endpoints would surface a URL here; everything else falls
// back to the /apps manage page.
if (app.name === 'fileshare' && HOSTNAME) {
return { href: `smb://${HOSTNAME}.local/files`, label: 'Open files' };
}
return { href: '/apps', label: 'Manage →', external: false };
return { href: '/apps', label: 'Manage →' };
}
async function renderApps() {
@ -141,9 +115,8 @@
}
target.innerHTML = apps.map(a => {
const icon = a.icon_svg || FALLBACK_ICON;
const { href, label, external } = primaryAction(a);
const tgt = external ? ' target="_blank" rel="noopener"' : '';
return `<a class="app-tile" href="${esc(href)}"${tgt}>
const { href, label } = primaryAction(a);
return `<a class="app-tile" href="${esc(href)}">
<div class="icon">${icon}</div>
<span class="name">${esc(a.display_name || a.name)}</span>
<span class="cta">${esc(label)}</span>

View file

@ -2,7 +2,6 @@
<html lang="en">
<head>
<meta charset="utf-8">
<link rel="icon" type="image/svg+xml" href="/favicon.svg">
<title>Settings · Furtka</title>
<meta name="viewport" content="width=device-width,initial-scale=1">
<link rel="stylesheet" href="/style.css">
@ -15,7 +14,6 @@
<a href="/">Home</a>
<a href="/apps">Apps</a>
<a href="/settings/" aria-current="page">Settings</a>
<a href="#" id="logout-link" onclick="return doLogout(event)">Logout</a>
</div>
</nav>
@ -81,45 +79,6 @@
</div>
</section>
<section>
<h2>Furtka Gateway</h2>
<div class="card">
<p class="lede">
Connect this box to a furtka-gateway to expose individual apps to the
internet over a WireGuard tunnel. Apps stay private until you explicitly
publish one from the <a href="/apps">Apps</a> page — connecting here only
opens the tunnel, nothing is exposed automatically.
</p>
<div id="gateway-disconnected" hidden>
<div class="field">
<label for="gw-url">Gateway URL</label>
<input type="text" id="gw-url" placeholder="https://gw.example.com">
</div>
<div class="field">
<label for="gw-token">Registration token</label>
<input type="password" id="gw-token" placeholder="from the gateway operator">
</div>
<div class="field">
<label for="gw-name">Box name</label>
<input type="text" id="gw-name" placeholder="my-box">
</div>
<div class="update-actions">
<button id="gateway-connect-btn">Connect</button>
</div>
</div>
<div id="gateway-connected" hidden>
<dl class="kv">
<dt>Gateway</dt><dd id="gw-connected-url">—</dd>
<dt>Tunnel</dt><dd id="gw-tunnel-status">—</dd>
</dl>
<div class="update-actions">
<button class="danger" id="gateway-disconnect-btn">Disconnect</button>
</div>
</div>
<p id="gateway-status" class="hint"></p>
</div>
</section>
<section>
<h2>Appearance</h2>
<div class="card">
@ -130,28 +89,16 @@
</div>
</section>
<section>
<h2>Power</h2>
<div class="card">
<p class="lede">
Reboot or shut down the whole Furtka box. Takes a few seconds to
finish; the UI will reconnect itself after a reboot.
</p>
<div class="power-actions">
<button type="button" id="power-reboot" class="secondary">Reboot</button>
<button type="button" id="power-poweroff" class="danger">Shut down</button>
</div>
<p id="power-status" class="hint"></p>
</div>
</section>
<section>
<h2>Coming next</h2>
<div class="coming">
<p class="hint">Controls we're building — follow progress on <a href="https://furtka.org">furtka.org</a>.</p>
<a href="https://furtka.org/#planned">Reboot</a>
<a href="https://furtka.org/#planned">Shut down</a>
<a href="https://furtka.org/#planned">Change hostname</a>
<a href="https://furtka.org/#planned">Backup</a>
<a href="https://furtka.org/#planned">User accounts</a>
<a href="https://furtka.org/#planned">Remote access</a>
</div>
</section>
@ -161,24 +108,6 @@
</main>
<script>
// Sessions live in API memory, so every restart or self-update logs
// everyone out. Send the browser to the login page instead of rendering
// the 401 body as if the box had no apps.
const _fetch = window.fetch.bind(window);
window.fetch = async (...args) => {
const r = await _fetch(...args);
if (r.status === 401) { location.href = '/login'; }
return r;
};
// Logout button in the nav — same shape as /apps and / pages.
async function doLogout(ev) {
ev.preventDefault();
try { await fetch('/logout', { method: 'POST', credentials: 'same-origin' }); }
catch (e) { /* server may already be down */ }
window.location.href = '/login';
return false;
}
async function refresh() {
try {
const r = await fetch('/status.json', { cache: 'no-store' });
@ -210,7 +139,6 @@
done: 'Update complete — reloading…',
rolled_back: 'Update failed, rolled back to the previous version',
rolled_back_manual: 'Rolled back manually',
error: 'Update failed — nothing was changed',
};
let pollHandle = null;
@ -388,140 +316,18 @@
await refreshHttpsStatus();
})();
// --- Furtka Gateway -------------------------------------------------
const CONNECT_STAGE_LABELS = {
registering: 'Registering with the gateway…',
starting_tunnel: 'Starting the WireGuard tunnel…',
done: 'Connected.',
};
const gatewayDisconnectedEl = document.getElementById('gateway-disconnected');
const gatewayConnectedEl = document.getElementById('gateway-connected');
const gwConnectedUrlEl = document.getElementById('gw-connected-url');
const gwTunnelStatusEl = document.getElementById('gw-tunnel-status');
const gatewayStatusEl = document.getElementById('gateway-status');
const gatewayConnectBtn = document.getElementById('gateway-connect-btn');
const gatewayDisconnectBtn = document.getElementById('gateway-disconnect-btn');
let gatewayPollHandle = null;
function setGatewayStatus(msg, isError = false) {
gatewayStatusEl.textContent = msg;
gatewayStatusEl.style.color = isError ? 'var(--danger)' : 'var(--muted)';
}
function describeTunnel(tunnel) {
if (!tunnel || !tunnel.configured) return 'not configured';
return tunnel.latest_handshake ? `up (last handshake ${tunnel.latest_handshake})` : 'configured, no handshake yet';
}
async function refreshGatewayStatus() {
try {
const r = await fetch('/api/gateway/status', { cache: 'no-store' });
// Sessions are in-memory, so a furtka-api restart logs us
// out. Both panels start hidden — never claim "not
// connected" when we simply couldn't ask.
if (r.status === 401) {
window.location.href = '/login';
return;
}
if (!r.ok) return;
const s = await r.json();
gatewayDisconnectedEl.hidden = !!s.connected;
gatewayConnectedEl.hidden = !s.connected;
if (s.connected) {
gwConnectedUrlEl.textContent = s.gateway_url;
gwTunnelStatusEl.textContent = describeTunnel(s.tunnel);
}
} catch (e) {
/* next refresh will retry */
}
}
refreshGatewayStatus();
gatewayConnectBtn.addEventListener('click', async () => {
const gateway_url = document.getElementById('gw-url').value.trim();
const registration_token = document.getElementById('gw-token').value;
const box_name = document.getElementById('gw-name').value.trim();
if (!gateway_url || !registration_token || !box_name) {
setGatewayStatus('Gateway URL, registration token, and box name are all required.', true);
return;
}
gatewayConnectBtn.disabled = true;
setGatewayStatus('Connecting…');
try {
const r = await fetch('/api/gateway/connect', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ gateway_url, registration_token, box_name }),
});
const data = await r.json();
if (!r.ok) {
setGatewayStatus(data.error || `HTTP ${r.status}`, true);
gatewayConnectBtn.disabled = false;
return;
}
gatewayPollHandle = setInterval(pollGatewayConnectState, 2000);
} catch (e) {
setGatewayStatus(`Network error: ${e.message}`, true);
gatewayConnectBtn.disabled = false;
}
});
async function pollGatewayConnectState() {
try {
const r = await fetch('/api/gateway/connect/status', { cache: 'no-store' });
if (!r.ok) return;
const s = await r.json();
const label = CONNECT_STAGE_LABELS[s.stage] || `Stage: ${s.stage}`;
setGatewayStatus(label, s.stage === 'error');
if (s.stage === 'done') {
clearInterval(gatewayPollHandle);
gatewayConnectBtn.disabled = false;
await refreshGatewayStatus();
} else if (s.stage === 'error') {
clearInterval(gatewayPollHandle);
gatewayConnectBtn.disabled = false;
if (s.error) setGatewayStatus(`${label} — ${s.error}`, true);
}
} catch (e) {
/* keep polling; transient blip expected */
}
}
gatewayDisconnectBtn.addEventListener('click', async () => {
if (!confirm('Disconnect from the gateway? Any apps currently exposed to the internet will stop being reachable.')) return;
gatewayDisconnectBtn.disabled = true;
setGatewayStatus('Disconnecting…');
try {
const r = await fetch('/api/gateway/disconnect', { method: 'POST' });
const data = await r.json();
if (!r.ok) {
setGatewayStatus(data.error || `HTTP ${r.status}`, true);
return;
}
setGatewayStatus('Disconnected.');
await refreshGatewayStatus();
} catch (e) {
setGatewayStatus(`Network error: ${e.message}`, true);
} finally {
gatewayDisconnectBtn.disabled = false;
}
});
async function pollUpdateState() {
try {
const r = await fetch('/update-state.json', { cache: 'no-store' });
if (!r.ok) return;
const s = await r.json();
const label = STAGE_LABELS[s.stage] || `Stage: ${s.stage}`;
const failed = s.stage === 'rolled_back' || s.stage === 'error';
setStatus(label, failed);
setStatus(label, s.stage === 'rolled_back');
if (s.stage === 'done') {
clearInterval(pollHandle);
clearTimeout(fallbackReloadHandle);
setTimeout(() => location.reload(), 5000);
} else if (failed) {
} else if (s.stage === 'rolled_back') {
clearInterval(pollHandle);
clearTimeout(fallbackReloadHandle);
if (s.reason) {
@ -534,85 +340,6 @@
/* keep polling; restart blip expected */
}
}
// Power buttons: confirm, POST, then swap the whole card into a
// "going down" state so the user doesn't keep clicking. After a
// reboot we try to reconnect after ~45s; for shutdown we just
// tell the user the box is off — no auto-reconnect attempt.
const powerStatusEl = document.getElementById('power-status');
const rebootBtn = document.getElementById('power-reboot');
const poweroffBtn = document.getElementById('power-poweroff');
function setPowerStatus(msg, tone = 'muted') {
powerStatusEl.textContent = msg;
powerStatusEl.style.color =
tone === 'error' ? 'var(--danger)' : 'var(--muted)';
}
async function triggerPower(action, confirmMsg, inflightLabel) {
if (!confirm(confirmMsg)) return;
rebootBtn.disabled = true;
poweroffBtn.disabled = true;
setPowerStatus(inflightLabel);
try {
const r = await fetch('/api/furtka/power', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ action }),
});
if (!r.ok) {
const data = await r.json().catch(() => ({}));
setPowerStatus(data.error || `HTTP ${r.status}`, 'error');
rebootBtn.disabled = false;
poweroffBtn.disabled = false;
return;
}
if (action === 'reboot') {
setPowerStatus('Rebooting… this page will reload when the box is back.');
// Try reconnecting after a generous delay. archinstall
// + boot + services typically takes 30–45 s; give it 30
// before the first poke so we don't just spin against
// a down kernel.
setTimeout(pollForReconnect, 30000);
} else {
setPowerStatus(
'Shutdown scheduled. Press the physical power button to turn it back on.'
);
}
} catch (e) {
setPowerStatus(`Network error: ${e.message}`, 'error');
rebootBtn.disabled = false;
poweroffBtn.disabled = false;
}
}
async function pollForReconnect() {
// Fetch a tiny static file; when it comes back 200 the box is up.
try {
const r = await fetch('/furtka.json', { cache: 'no-store' });
if (r.ok) {
setPowerStatus('Back up — reloading…');
setTimeout(() => location.reload(), 1500);
return;
}
} catch (e) { /* still down */ }
setTimeout(pollForReconnect, 3000);
}
rebootBtn.addEventListener('click', () =>
triggerPower(
'reboot',
"Wirklich neu starten? Die Box ist für ~30 Sekunden nicht erreichbar.",
'Rebooting…'
)
);
poweroffBtn.addEventListener('click', () =>
triggerPower(
'poweroff',
"Wirklich ausschalten? Du kannst die Box erst wieder starten, wenn du den physischen Power-Knopf drückst.",
'Shutting down…'
)
);
</script>
</body>
</html>

View file

@ -21,13 +21,7 @@
--r-pill: 999px;
--shadow-card: 0 1px 2px rgba(0, 0, 0, 0.3);
--shadow-lift: 0 8px 24px rgba(0, 0, 0, 0.35);
--ring: 0 0 0 2px var(--accent);
/* Brand red from the logo, used only for small highlights. */
--brand: #ff6b56;
--glow-a: rgba(255, 107, 86, 0.10);
--glow-b: rgba(110, 231, 183, 0.08);
--nav-bg: rgba(15, 17, 21, 0.72);
}
@media (prefers-color-scheme: light) {
@ -44,11 +38,6 @@
--warn-fg: #5a2a10;
--danger: #c03a28;
--shadow-card: 0 1px 3px rgba(0, 0, 0, 0.08);
--shadow-lift: 0 8px 24px rgba(0, 0, 0, 0.10);
--brand: #c03a28;
--glow-a: rgba(192, 58, 40, 0.07);
--glow-b: rgba(15, 138, 95, 0.06);
--nav-bg: rgba(247, 246, 243, 0.8);
}
}
@ -56,16 +45,9 @@
body {
margin: 0;
font-family: system-ui, -apple-system, "Segoe UI", Roboto, sans-serif;
/* Two soft glows at the top (logo red, accent green) give the page some
depth without competing with the content. */
background:
radial-gradient(60rem 28rem at 15% -10%, var(--glow-a), transparent 70%),
radial-gradient(50rem 24rem at 95% -5%, var(--glow-b), transparent 70%),
var(--bg);
background-attachment: fixed;
background: var(--bg);
color: var(--fg);
line-height: 1.5;
-webkit-font-smoothing: antialiased;
}
/* Shared page container — both landing and /apps wrap content in
@ -77,16 +59,9 @@ body {
display: flex;
align-items: center;
justify-content: space-between;
padding: 0.85rem 0;
padding-bottom: 1.25rem;
border-bottom: 1px solid var(--border);
margin-bottom: 2rem;
/* Stays in reach while scrolling long pages like /settings. */
position: sticky;
top: 0;
z-index: 20;
background: var(--nav-bg);
-webkit-backdrop-filter: blur(10px);
backdrop-filter: blur(10px);
}
.brand {
font-weight: 700;
@ -98,17 +73,13 @@ body {
align-items: center;
gap: 0.55rem;
}
/* Furtka mark (M), drawn as a mask so it takes the accent colour in both
themes. Generated geometry: website/tools/brand-assets.py. */
.brand::before {
content: "";
width: 1.35rem;
height: 1.35rem;
/* Brand red, not the UI's green accent — the logo keeps its colour.
This stylesheet is dark-first; the light theme gets the deeper red. */
background: #ff6b56;
-webkit-mask: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 64 64'%3E%3Cpath fill-rule='evenodd' d='M5 60V27a27 27 0 0 1 54 0v33Z M19.5 60V26A10 10 0 0 1 29.5 16H46v10H32a2.5 2.5 0 0 0-2.5 2.5V32H46v10H29.5v18Z'/%3E%3C/svg%3E") center / contain no-repeat;
mask: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 64 64'%3E%3Cpath fill-rule='evenodd' d='M5 60V27a27 27 0 0 1 54 0v33Z M19.5 60V26A10 10 0 0 1 29.5 16H46v10H32a2.5 2.5 0 0 0-2.5 2.5V32H46v10H29.5v18Z'/%3E%3C/svg%3E") center / contain no-repeat;
width: 0.7rem;
height: 0.7rem;
background: var(--accent);
border-radius: 2px;
transform: rotate(45deg);
}
.nav-links { display: flex; gap: 0.25rem; }
.nav-links a {
@ -116,52 +87,30 @@ body {
text-decoration: none;
font-size: 0.9rem;
padding: 0.35rem 0.75rem;
border-radius: var(--r-pill);
transition: color 120ms, background 120ms;
border-radius: var(--r-sm);
}
.nav-links a:hover { color: var(--fg); background: var(--card); }
.nav-links a:hover { color: var(--fg); }
.nav-links a[aria-current="page"] {
color: var(--fg);
background: var(--accent-soft);
}
/* -- Landing page ---------------------------------------------- */
header h1 {
margin: 0 0 0.5rem;
font-size: clamp(2rem, 6vw, 2.75rem);
letter-spacing: -0.02em;
line-height: 1.1;
background: linear-gradient(100deg, var(--fg) 40%, var(--accent));
-webkit-background-clip: text;
background-clip: text;
color: transparent;
}
header h1 { margin: 0 0 0.5rem; font-size: 2.5rem; }
.lead { font-size: 1.25rem; color: var(--muted); margin: 0 0 0.25rem; }
.host { color: var(--muted); margin: 0 0 3rem; }
.host code {
background: var(--accent-soft);
padding: 0.2rem 0.65rem;
border-radius: var(--r-pill);
background: var(--card);
padding: 0.15rem 0.5rem;
border-radius: var(--r-sm);
color: var(--accent);
font-size: 0.9em;
}
section h2 {
font-size: 0.85rem;
font-size: 1.1rem;
text-transform: uppercase;
letter-spacing: 0.12em;
letter-spacing: 0.1em;
color: var(--muted);
margin: 2.5rem 0 1rem;
display: flex;
align-items: center;
gap: 0.6rem;
}
/* Small brand-red marker in front of each landing-page section title. */
section h2::before {
content: "";
width: 0.45rem;
height: 0.45rem;
border-radius: var(--r-pill);
background: var(--brand);
margin: 2rem 0 1rem;
}
.tiles {
display: grid;
@ -171,21 +120,9 @@ section h2::before {
.tile {
background: var(--card);
padding: 1.25rem;
border: 1px solid var(--border);
border-radius: var(--r-lg);
border-radius: var(--r-md);
display: flex;
flex-direction: column;
position: relative;
overflow: hidden;
}
/* Thin accent line along the top edge of each status tile. */
.tile::before {
content: "";
position: absolute;
inset: 0 0 auto 0;
height: 3px;
background: linear-gradient(90deg, var(--accent), transparent);
opacity: 0.7;
}
.tile .label {
font-size: 0.8rem;
@ -193,12 +130,7 @@ section h2::before {
text-transform: uppercase;
letter-spacing: 0.08em;
}
.tile .value {
font-size: 1.35rem;
font-weight: 600;
margin-top: 0.5rem;
font-variant-numeric: tabular-nums;
}
.tile .value { font-size: 1.25rem; margin-top: 0.5rem; }
.updated { font-size: 0.85rem; color: var(--muted); margin-top: 1rem; }
.soon {
background: var(--card);
@ -236,9 +168,7 @@ h2 {
.app {
background: var(--card);
padding: 1rem;
border: 1px solid var(--border);
border-radius: var(--r-lg);
transition: border-color 120ms;
border-radius: var(--r-md);
margin: 0.5rem 0;
display: flex;
justify-content: space-between;
@ -268,7 +198,7 @@ h2 {
flex-wrap: wrap;
justify-content: flex-end;
}
button, .btn {
button {
background: var(--accent);
border: none;
color: var(--bg);
@ -279,95 +209,16 @@ button, .btn {
white-space: nowrap;
font-size: 0.9rem;
font-family: inherit;
/* Anchor rendered-as-button: strip underline + keep the button's
rectangular hit area. `display: inline-flex` so an <a class="btn">
lines up vertically with its <button> siblings in .buttons. */
text-decoration: none;
display: inline-flex;
align-items: center;
transition: filter 120ms, transform 80ms;
}
button:hover:not(:disabled), .btn:hover { filter: brightness(1.08); }
button:active:not(:disabled), .btn:active { transform: translateY(1px); }
button.secondary, .btn.secondary {
button.secondary {
background: var(--card);
color: var(--fg);
border: 1px solid var(--border);
}
button.danger { background: var(--danger); color: #fff; }
/* Public-URL line under an app's description, shown while the app is
exposed through furtka-gateway. */
.public-link {
display: inline-flex;
align-items: center;
gap: 0.4rem;
margin-top: 0.25rem;
font-size: 0.85rem;
color: var(--accent);
text-decoration: none;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.public-link:hover { text-decoration: underline; }
.public-link .dot {
width: 0.5rem;
height: 0.5rem;
border-radius: var(--r-pill);
background: var(--accent);
flex-shrink: 0;
}
/* "⋯" overflow menu on an installed app row. */
details.menu { position: relative; }
details.menu > summary { list-style: none; padding: 0.5rem 0.75rem; }
details.menu > summary::-webkit-details-marker { display: none; }
.menu-list {
position: absolute;
right: 0;
top: calc(100% + 0.25rem);
z-index: 10;
min-width: 11rem;
padding: 0.25rem;
background: var(--card);
border: 1px solid var(--border);
border-radius: var(--r-md);
box-shadow: 0 4px 16px rgba(0, 0, 0, 0.25);
display: flex;
flex-direction: column;
}
.menu-list button {
background: none;
color: var(--fg);
font-weight: 500;
width: 100%;
justify-content: flex-start;
}
.menu-list button:hover { background: var(--card-hover); }
.menu-list button.danger { background: none; color: var(--danger); }
.menu-list button.danger:hover { background: var(--card-hover); }
.menu-list hr { border: none; border-top: 1px solid var(--border); margin: 0.25rem 0; }
button:disabled { opacity: 0.5; cursor: wait; }
button:focus-visible, .btn:focus-visible { outline: none; box-shadow: var(--ring); }
button:focus-visible { outline: none; box-shadow: var(--ring); }
.empty { color: var(--muted); font-style: italic; padding: 0.5rem 0; }
.catalog-row {
display: flex;
justify-content: space-between;
align-items: center;
flex-wrap: wrap;
gap: 0.75rem;
padding: 0.5rem 0 0.75rem;
}
.catalog-state {
margin: 0;
color: var(--muted);
font-size: 0.9rem;
}
.catalog-stage.pending {
color: var(--fg);
font-style: italic;
}
pre {
background: var(--card);
padding: 1rem;
@ -436,8 +287,7 @@ details.log-details[open] > summary { color: var(--fg); }
}
.field input:focus { outline: 2px solid var(--accent); outline-offset: -1px; }
.field .req { color: var(--danger); margin-left: 0.25rem; }
.modal .error,
.login-wrap .error {
.modal .error {
background: var(--warn);
color: var(--warn-fg);
padding: 0.5rem 0.75rem;
@ -446,25 +296,7 @@ details.log-details[open] > summary { color: var(--fg); }
font-size: 0.9rem;
display: none;
}
.modal .error.show,
.login-wrap .error.show { display: block; }
.modal .dep-list {
margin: 0 0 1rem;
padding: 0.75rem 1rem 0.75rem 1.75rem;
background: var(--bg);
border: 1px solid var(--border);
border-radius: var(--r-sm);
font-size: 0.9rem;
line-height: 1.4;
}
.modal .dep-list li { margin: 0.15rem 0; }
/* Login + first-run setup page. Shares .wrap's max-width so the form
sits in the same column the rest of the app uses, just without the
Home/Apps/Settings nav. A bit of top padding so the H1 isn't glued
to the viewport edge. */
.login-wrap { padding-top: 3rem; }
.login-wrap .actions { margin-top: 0.5rem; }
.modal .error.show { display: block; }
.modal-actions {
display: flex;
justify-content: flex-end;
@ -474,8 +306,7 @@ details.log-details[open] > summary { color: var(--fg); }
/* Row of buttons beneath a card — used by the Furtka updates card on
/settings. Left-aligned, wraps on narrow screens. */
.update-actions,
.power-actions {
.update-actions {
display: flex;
gap: 0.5rem;
flex-wrap: wrap;
@ -520,8 +351,7 @@ details.log-details[open] > summary { color: var(--fg); }
.card {
background: var(--card);
padding: 1.25rem;
border: 1px solid var(--border);
border-radius: var(--r-lg);
border-radius: var(--r-md);
box-shadow: var(--shadow-card);
}
.card + .card { margin-top: 1rem; }
@ -535,18 +365,7 @@ details.log-details[open] > summary { color: var(--fg); }
font-size: 0.95rem;
}
.kv dt { color: var(--muted); }
.kv dd {
margin: 0;
color: var(--fg);
font-family: ui-monospace, SFMono-Regular, Menlo, monospace;
/* Grid items default to min-width: auto (= content width), so a long
unbreakable value like a SHA-256 fingerprint would push past the
card. min-width: 0 lets the 1fr track enforce the column width, and
overflow-wrap: anywhere gives the colon-separated hex string valid
break opportunities. */
min-width: 0;
overflow-wrap: anywhere;
}
.kv dd { margin: 0; color: var(--fg); font-family: ui-monospace, SFMono-Regular, Menlo, monospace; }
.coming {
display: flex;
@ -576,9 +395,9 @@ details.log-details[open] > summary { color: var(--fg); }
gap: 0.75rem;
}
.app-tile {
background: var(--card);
background: var(--bg);
border: 1px solid var(--border);
border-radius: var(--r-lg);
border-radius: var(--r-md);
padding: 1rem;
display: flex;
flex-direction: column;
@ -586,20 +405,12 @@ details.log-details[open] > summary { color: var(--fg); }
align-items: flex-start;
text-decoration: none;
color: var(--fg);
transition: border-color 120ms, background 120ms, transform 120ms, box-shadow 120ms;
}
.app-tile:hover {
border-color: var(--accent);
background: var(--card-hover);
transform: translateY(-2px);
box-shadow: var(--shadow-lift);
transition: border-color 120ms, background 120ms;
}
.app-tile:hover { border-color: var(--accent); background: var(--card-hover); }
.app-tile .icon {
width: 48px;
height: 48px;
padding: 6px;
border-radius: var(--r-md);
background: var(--accent-soft);
width: 40px;
height: 40px;
color: var(--accent);
display: flex;
align-items: center;
@ -625,15 +436,3 @@ details.log-details[open] > summary { color: var(--fg); }
color: var(--accent);
}
.app-icon svg { width: 36px; height: 36px; }
.login-brand { margin: 0 0 1.5rem; font-size: 1.2rem; }
.app:hover { border-color: color-mix(in srgb, var(--accent) 40%, var(--border)); }
@media (prefers-reduced-motion: reduce) {
*, *::before, *::after { transition: none !important; }
.app-tile:hover { transform: none; }
}
@media (prefers-color-scheme: light) {
.brand::before { background: #c03a28; }
}

View file

@ -1,19 +0,0 @@
# Furtka brand files
The Furtka logo is **Mark M** — a lowercase *f* cut out of an arched gate. It won the
public logo vote on furtka.org (closed 2026-09-28: M 29, R 13 of 42 votes).
| File | Use |
|---|---|
| `furtka-mark.svg` | The mark, 32 px and up |
| `furtka-mark-small.svg` | Simplified drawing for 16–24 px (favicons, tab icons) |
| `furtka-lockup.svg` | Mark + wordmark, accent colour on light grounds |
| `furtka-lockup-white.svg` | Mark + wordmark in white, for dark or accent grounds |
| `console-banner.txt` | Text-mode art for the console welcome screens |
Colours: accent `#c03a28` (dark UI: `#ff6b56`), ink `#0d0d0f`.
Everything here and the copies in `website/static/`, `webinstaller/static/`,
`assets/www/` and `iso/overlay/splash.png` are generated by
`website/tools/brand-assets.py` from the geometry in `website/tools/logo-assets.py`.
Change the geometry there and re-run the script — don't edit the outputs by hand.

View file

@ -1,10 +0,0 @@
▄▄▄▄▄▄▄▄
▄██████████▄
█████▀▀▀▀▀▀███
████▀ ████
████ █████████
████ ████
████ ▄▄▄▄▄████
████ █████████
████ █████████
▀▀▀▀ ▀▀▀▀▀▀▀▀▀

View file

@ -1 +0,0 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 -4 232.44 64.00" role="img" aria-label="Furtka"><g transform="scale(1.0000) translate(-6 -2)"><path fill="#ffffff" fill-rule="evenodd" d="M6 60V28a26 26 0 0 1 52 0v32Z M20.0 60V26.5A10.5 10.5 0 0 1 30.5 16H45.0v8.6H32.5a3.5 3.5 0 0 0-3.5 3.5V32h16v8.6H29.0V60Z"/></g><g fill="#ffffff"><path transform="translate(68.24 58.00) scale(0.05354 -0.05354)" d="M115 0V494H30V600H115V637Q115 687 132.5 724.0Q150 761 192.0 781.5Q234 802 305 802Q333 802 358 799V695Q344 698 324 698Q287 698 273.5 682.0Q260 666 260 632V600H358V494H260V0Z"/><path transform="translate(87.21 58.00) scale(0.05354 -0.05354)" d="M271 -15Q169 -15 119.0 48.5Q69 112 69 218V600H218V244Q218 179 241.5 143.0Q265 107 316 107Q379 107 413.0 156.0Q447 205 447 276V600H596V0H462V90H438Q423 44 382.5 14.5Q342 -15 271 -15Z"/><path transform="translate(121.45 58.00) scale(0.05354 -0.05354)" d="M76 0V600H211V492H235Q241 521 257.0 547.0Q273 573 307.0 589.5Q341 606 399 606H428V471H387Q301 471 263.0 427.0Q225 383 225 294V0Z"/><path transform="translate(144.17 58.00) scale(0.05354 -0.05354)" d="M162 0Q139 27 125.5 65.0Q112 103 112 179V494H30V600H114V750H259V600H358V494H260V196Q260 116 278.0 74.5Q296 33 321 6V0Z"/><path transform="translate(163.14 58.00) scale(0.05354 -0.05354)" d="M78 0V800H226V366H274L449 600H608V594L397 320L632 6V0H457L274 254H226V0Z"/><path transform="translate(195.88 58.00) scale(0.05354 -0.05354)" d="M280 -13Q198 -13 144.0 27.5Q90 68 63.5 138.5Q37 209 37 301Q37 395 64.0 465.0Q91 535 145.0 574.0Q199 613 280 613Q344 613 384.5 587.5Q425 562 445 522H469V600H606V0H469V78H445Q425 41 385.0 14.0Q345 -13 280 -13ZM317 95Q381 95 419.5 151.0Q458 207 458 300Q458 394 419.5 449.5Q381 505 317 505Q257 505 223.5 453.0Q190 401 190 300Q190 199 223.5 147.0Q257 95 317 95Z"/></g></svg>

Before

Width:  |  Height:  |  Size: 1.8 KiB

View file

@ -1 +0,0 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 -4 232.44 64.00" role="img" aria-label="Furtka"><g transform="scale(1.0000) translate(-6 -2)"><path fill="#c03a28" fill-rule="evenodd" d="M6 60V28a26 26 0 0 1 52 0v32Z M20.0 60V26.5A10.5 10.5 0 0 1 30.5 16H45.0v8.6H32.5a3.5 3.5 0 0 0-3.5 3.5V32h16v8.6H29.0V60Z"/></g><g fill="#c03a28"><path transform="translate(68.24 58.00) scale(0.05354 -0.05354)" d="M115 0V494H30V600H115V637Q115 687 132.5 724.0Q150 761 192.0 781.5Q234 802 305 802Q333 802 358 799V695Q344 698 324 698Q287 698 273.5 682.0Q260 666 260 632V600H358V494H260V0Z"/><path transform="translate(87.21 58.00) scale(0.05354 -0.05354)" d="M271 -15Q169 -15 119.0 48.5Q69 112 69 218V600H218V244Q218 179 241.5 143.0Q265 107 316 107Q379 107 413.0 156.0Q447 205 447 276V600H596V0H462V90H438Q423 44 382.5 14.5Q342 -15 271 -15Z"/><path transform="translate(121.45 58.00) scale(0.05354 -0.05354)" d="M76 0V600H211V492H235Q241 521 257.0 547.0Q273 573 307.0 589.5Q341 606 399 606H428V471H387Q301 471 263.0 427.0Q225 383 225 294V0Z"/><path transform="translate(144.17 58.00) scale(0.05354 -0.05354)" d="M162 0Q139 27 125.5 65.0Q112 103 112 179V494H30V600H114V750H259V600H358V494H260V196Q260 116 278.0 74.5Q296 33 321 6V0Z"/><path transform="translate(163.14 58.00) scale(0.05354 -0.05354)" d="M78 0V800H226V366H274L449 600H608V594L397 320L632 6V0H457L274 254H226V0Z"/><path transform="translate(195.88 58.00) scale(0.05354 -0.05354)" d="M280 -13Q198 -13 144.0 27.5Q90 68 63.5 138.5Q37 209 37 301Q37 395 64.0 465.0Q91 535 145.0 574.0Q199 613 280 613Q344 613 384.5 587.5Q425 562 445 522H469V600H606V0H469V78H445Q425 41 385.0 14.0Q345 -13 280 -13ZM317 95Q381 95 419.5 151.0Q458 207 458 300Q458 394 419.5 449.5Q381 505 317 505Q257 505 223.5 453.0Q190 401 190 300Q190 199 223.5 147.0Q257 95 317 95Z"/></g></svg>

Before

Width:  |  Height:  |  Size: 1.8 KiB

View file

@ -1 +0,0 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 64 64"><path fill="#c03a28" fill-rule="evenodd" d="M5 60V27a27 27 0 0 1 54 0v33Z M19.5 60V26A10 10 0 0 1 29.5 16H46v10H32a2.5 2.5 0 0 0-2.5 2.5V32H46v10H29.5v18Z"/></svg>

Before

Width:  |  Height:  |  Size: 224 B

View file

@ -1 +0,0 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 64 64"><path fill="#c03a28" fill-rule="evenodd" d="M6 60V28a26 26 0 0 1 52 0v32Z M20.0 60V26.5A10.5 10.5 0 0 1 30.5 16H45.0v8.6H32.5a3.5 3.5 0 0 0-3.5 3.5V32h16v8.6H29.0V60Z"/></svg>

Before

Width:  |  Height:  |  Size: 236 B

View file

@ -1,75 +0,0 @@
# Hardware test report — "junk bench", 2026-08-24/26
First end-to-end run of Furtka on real hardware that was not a Proxmox VM
and not the Medion box: a bare old-laptop mainboard lying on the desk, a
1 TB Crucial BX SATA SSD that had previously lived in a Proxmox VE host, a
USB hub for keyboard + boot stick, LAN cable, HDMI monitor. Tester: Daniel.
Release under test: **26.19-alpha**, then self-updated to **26.20-alpha**.
Why it mattered: every bug below is invisible on the smoke VM. A VM has a
fresh empty disk, a fast virtual NIC, UEFI firmware and no USB stick to pull.
## Timeline
| When | What happened | Outcome |
|---|---|---|
| 08-24 | ISO flashed with Fedora Media Writer, bench booted, browser opened `proski.local:5000` | "Server Not Found" — **typo** (`proski` vs `proksi`). Bench powered off. |
| 08-25 | `proksi.local:5000` — wizard came up (BIOS/syslinux boot path, stock Arch splash) | OK |
| 08-25 13:07 | Install run 1 | **Failed at 12 %**: `Partition(s) 2, 3 on /dev/sda have been written, but we have been unable to inform the kernel of the change … in use` |
| 08-25 | Root shell over SSH (`passwd` + `ssh-copy-id` on the live ISO): SSD still carried a Proxmox VE `pve` volume group (swap, root, thin pool); udev had activated it, six device-mapper nodes sat on `sda3` | Cleared by hand: `dmsetup remove` ×6, `wipefs -a /dev/sda`, `blockdev --rereadpt` |
| 08-25 13:16 | Install run 2 | `Installation completed without any errors`; BIOS detected → `Grub`, MBR table, `sda1` 1 G vfat `/boot`, `sda2` ext4 `/` |
| 08-25 | "Reboot now" clicked | Box **booted the USB stick again** (BIOS order). Stick pulled, rebooted → `furtka.local` = 192.168.178.142, Caddy answering |
| 08-25 16:22 | First boot of the installed system | `furtka-catalog-sync.service` failed: `Network is unreachable`. `network-online.target` had fired on the IPv6 SLAAC address ~1 s before DHCPv4 landed. State file stuck on `checking`, apps page empty; next timer run would have been 18:26 |
| 08-25 ~17:00 | Catalog synced via the "Sync apps catalog" button | Catalog 26.13-alpha, 7 apps listed |
| 08-25 17:47 | 26.20-alpha released with the fixes below | Box reports `Update available: 26.19-alpha → 26.20-alpha` |
| 08-26 09:34 | Self-update to 26.20-alpha from `/settings` | `current → 26.20-alpha`, no failed units. **New finding:** `furtka-welcome.timer` linked + enabled by the updater but `inactive (dead)` — `enable` alone only arms it for the next boot |
## Findings and fixes
| # | Finding | Root cause | Fix | Shipped in | Validated on hardware |
|---|---|---|---|---|---|
| 1 | Console banner showed only `proksi.local` / `furtka.local`, never the IP fallback | Banner script was a oneshot after `network-online.target` and dropped the IP line when no address existed yet. On the live ISO releng's root autologin on tty1 also made `agetty --reload` a no-op | `furtka-issue.timer` (ISO) / `furtka-welcome.timer` (installed) re-run the script every 5 s; fallback line is always present ("no IP address yet — check the cable / DHCP" until one arrives); ISO no longer auto-logs root in on tty1 | 26.20-alpha (`0e05bef`) | Installed side: pending reboot or next update. ISO side: pending reinstall from the 26.20 ISO |
| 2 | Install failed at partitioning on a disk with a previous OS | Live ISO ships lvm2/mdadm/dmraid/cryptsetup; udev auto-activates whatever the disk carries; parted cannot re-read the partition table while dm nodes hold the partitions | `webinstaller/diskprep.py`: `release_disk()` runs before archinstall — swapoff/umount leaves-first, `dmsetup remove` / `mdadm --stop`, `wipefs -a`, `blockdev --rereadpt`; steps logged at the top of the install log; progress page names this failure explicitly | 26.20-alpha (`5ddda53`) | Pending reinstall from the 26.20 ISO (the disk now carries Furtka itself, which exercises the same path minus LVM) |
| 3 | "Remove the USB stick" was easy to miss — the box booted the installer again | Hint lived on the *rebooting* page and in a `confirm()` popup, i.e. after the click, with the machine restarting 3 s later | Done state shows a two-step list (pull the stick → restart) above the button; button reads "USB stick is out — restart now" | 26.20-alpha (`60404ae`) | Pending reinstall |
| 4 | No apps after first boot | Boot-time catalog sync raced DHCPv4 (IPv6 made `network-online` fire early), no retry, timer jitter pushed the next attempt 2 h out, state file stuck on `checking` | `furtka-catalog-sync.service` retries on failure (20 s, up to 8× in 15 min); failures recorded as stage `error`; apps page shows "last sync failed: …" | 26.20-alpha (`32ba522`) | Retry config confirmed live on the box after the update; a cold boot with the new unit is still pending |
| 5 | New timer dead after self-update | `_link_new_units()` linked + enabled but never started the timer; also linked from the versioned dir instead of `current` | Link via `current`, enable, start | Unreleased (this branch) | — |
Not a bug, but worth knowing: on this board the BIOS boots the USB stick
before the SSD, so every reinstall needs the stick pulled at the reboot
prompt. The stock Arch splash on the BIOS/syslinux boot path is unbranded
(only menu labels are) — cosmetic.
## What worked first time
- Fedora Media Writer → hybrid ISO boots on BIOS firmware.
- mDNS: `proksi.local` (live ISO) and `furtka.local` (installed) resolved
from a Fedora client once spelled correctly.
- BIOS detection → `Grub` + MBR layout; the installed system booted from
the SSD without intervention.
- Login, `/settings`, catalog sync via the UI button, `furtka update --check`,
self-update 26.19 → 26.20 with symlink flip and clean unit state.
- Time sync, Docker 29.7.2, Caddy on :80, sshd with key auth for the wizard
user (`wheel` + `docker` groups as configured).
## Still open from this run
- Reinstall from the 26.20-alpha ISO on the bench to validate findings 1–3
on the ISO side (the installed side of #1 validates on the next reboot).
- Cold boot with the retrying catalog-sync unit (#4) on a dual-stack LAN.
- Rebrand the syslinux boot splash.
- `smoke-latest.yml` is `workflow_dispatch` only and has not run since
April; the smoke VM would not have caught any of the above anyway, but it
is currently not running at all.
## Reproducing the bench conditions in a VM
Most of this can be provoked on Proxmox if you want a regression check
without the hardware:
- Finding 2: attach a disk that already carries an LVM install (e.g. a
spare Proxmox VE or Ubuntu LVM disk image) before booting the ISO.
- Finding 3: leave the ISO attached as CD-ROM with boot order CD-first.
- Finding 4: give the VM's bridge IPv6 RA before DHCPv4 — harder; the
retry logic is unit-tested instead.
- Finding 1: use SeaBIOS instead of OVMF (the README notes SeaBIOS fails on
`ldlinux.c32` from this ISO, so this one really needs hardware).

View file

@ -1,119 +0,0 @@
"""Shared primitives for release-tarball flows.
Both ``furtka.updater`` (core self-update) and ``furtka.catalog`` (apps
catalog sync) pull a tarball from a Forgejo Releases page, verify its
SHA256 against the ``.sha256`` sidecar, and extract it with a path-
traversal guard. The helpers here are the single implementation of
that dance.
Each error-raising helper accepts an ``error_cls`` kwarg so callers can
keep their domain-specific exception type (``UpdateError``,
``CatalogError``) at call sites — the helper itself defaults to a
neutral ``ReleaseError`` for use in tests or standalone scripts.
"""
from __future__ import annotations
import hashlib
import http.client
import json
import shutil
import tarfile
import urllib.error
import urllib.request
from pathlib import Path
class ReleaseError(RuntimeError):
"""Neutral failure for release-tarball operations."""
def forgejo_api(host: str, repo: str, path: str, *, error_cls: type = ReleaseError) -> dict | list:
url = f"https://{host}/api/v1/repos/{repo}{path}"
req = urllib.request.Request(url, headers={"Accept": "application/json"})
try:
with urllib.request.urlopen(req, timeout=15) as resp:
return json.loads(resp.read())
except (OSError, http.client.HTTPException, json.JSONDecodeError) as e:
# OSError covers URLError plus socket timeouts and resets while
# reading the body, which urllib doesn't wrap.
raise error_cls(f"forgejo api {url}: {e}") from e
def download(url: str, dest: Path, *, error_cls: type = ReleaseError) -> None:
dest.parent.mkdir(parents=True, exist_ok=True)
req = urllib.request.Request(url)
try:
with urllib.request.urlopen(req, timeout=60) as resp, dest.open("wb") as f:
shutil.copyfileobj(resp, f)
except (OSError, http.client.HTTPException) as e:
# Includes disk full while writing `dest` and mid-body disconnects.
raise error_cls(f"download {url}: {e}") from e
def sha256_of(path: Path) -> str:
h = hashlib.sha256()
with path.open("rb") as f:
for chunk in iter(lambda: f.read(1024 * 1024), b""):
h.update(chunk)
return h.hexdigest()
def verify_tarball(tarball: Path, expected_sha: str, *, error_cls: type = ReleaseError) -> None:
actual = sha256_of(tarball)
if actual != expected_sha:
raise error_cls(f"sha256 mismatch: expected {expected_sha}, got {actual}")
def parse_sha256_sidecar(text: str, *, error_cls: type = ReleaseError) -> str:
"""Extract the hash from a standard `sha256sum` sidecar line."""
line = text.strip().split("\n", 1)[0].strip()
if not line:
raise error_cls("empty sha256 sidecar")
return line.split()[0]
def extract_tarball(tarball: Path, dest: Path, *, error_cls: type = ReleaseError) -> str:
"""Extract the tarball and return the VERSION read from its root.
Refuses entries that could escape ``dest`` via absolute paths or ``..``
segments. On Python 3.12+ the stricter ``data`` filter is additionally
enabled to catch symlink-escape / device-node / setuid tricks that the
regex check can't see.
"""
dest.mkdir(parents=True, exist_ok=True)
with tarfile.open(tarball, "r:gz") as tf:
for member in tf.getmembers():
if member.name.startswith(("/", "..")) or ".." in Path(member.name).parts:
raise error_cls(f"refusing tarball entry {member.name!r}")
try:
tf.extractall(dest, filter="data")
except TypeError:
tf.extractall(dest)
version_file = dest / "VERSION"
if not version_file.is_file():
raise error_cls("tarball has no VERSION file at root")
return version_file.read_text().strip()
def version_tuple(v: str) -> tuple:
"""CalVer comparator: 26.1-alpha < 26.1-beta < 26.1-rc < 26.1 < 26.2-alpha.
Pre-release stages sort before the corresponding stable (no-suffix)
release. Unknown suffixes sort below everything except the malformed
fallback. Returns a tuple of (year, release, stage_rank, suffix).
"""
stage_rank = {"alpha": 0, "beta": 1, "rc": 2}
head, _, suffix = v.partition("-")
try:
year_str, release_str = head.split(".", 1)
year = int(year_str)
release = int(release_str)
except (ValueError, IndexError):
return (-1, -1, -1, v)
if not suffix:
return (year, release, 3, "")
for name, rank in stage_rank.items():
if suffix.startswith(name):
return (year, release, rank, suffix)
return (year, release, -1, suffix)

File diff suppressed because it is too large Load diff

View file

@ -1,272 +0,0 @@
"""Login-guard primitives for the Furtka UI.
One admin, one password. Passwords are PBKDF2-SHA256 hashed via
``furtka.passwd`` (stdlib-only — hashlib.pbkdf2_hmac / hashlib.scrypt),
stored in /var/lib/furtka/users.json with mode 0600. Sessions live in
memory — a systemctl restart logs everyone out again, which is fine
for an alpha single-user box. The ``LoginAttempts`` store in this
module rate-limits failed logins per (username, IP) and is also
in-memory; a restart clears a stuck lockout.
On upgrade from pre-auth Furtka the users.json file does not exist
yet; the api's GET /login detects this via ``setup_needed()`` and
renders a first-run form that POSTs to /login as if it were a setup
submit. Fresh installs get the file pre-populated by the webinstaller
so the setup step is skipped.
Hash format is compatible with werkzeug.security — 26.11 / 26.12 boxes
that happened to have werkzeug installed can carry their users.json
forward without re-setup; see ``furtka.passwd`` for the scrypt reader.
"""
from __future__ import annotations
import json
import math
import os
import secrets
import threading
from dataclasses import dataclass
from datetime import UTC, datetime, timedelta
from furtka.passwd import hash_password as _hash_password
from furtka.passwd import verify_password as _verify_password
from furtka.paths import users_file
COOKIE_NAME = "furtka_session"
COOKIE_TTL_SECONDS = 7 * 24 * 3600 # one week
def hash_password(plain: str) -> str:
"""PBKDF2-SHA256 via stdlib. 600k iterations (OWASP 2023)."""
return _hash_password(plain)
def verify_password(plain: str, hashed: str) -> bool:
"""Constant-time compare. Accepts stdlib + legacy werkzeug formats."""
return _verify_password(plain, hashed)
class UsersFileError(RuntimeError):
"""users.json exists but can't be read or parsed."""
def load_users() -> dict:
"""Return the users dict, or {} if the file is missing.
Missing-file is the expected state on first boot and on upgrades from
pre-auth versions — callers treat empty-dict as "setup required".
A file that exists but is unreadable, empty or corrupt raises
UsersFileError instead: treating it as "no admin yet" would turn /login
into an open "create admin" form for anyone on the network.
"""
path = users_file()
try:
raw = path.read_text()
except FileNotFoundError:
return {}
except (OSError, UnicodeDecodeError) as e:
raise UsersFileError(f"{path} is unreadable: {e}") from e
try:
data = json.loads(raw)
except json.JSONDecodeError as e:
raise UsersFileError(f"{path} is corrupt: {e}") from e
if not isinstance(data, dict):
raise UsersFileError(f"{path} is corrupt: not a JSON object")
return data
def save_users(users: dict) -> None:
"""Atomically write users.json with mode 0600.
Same pattern as installer.write_env — write to .tmp, chmod, rename —
so a crash between open() and close() can't leave a world-readable
partial file.
"""
path = users_file()
path.parent.mkdir(parents=True, exist_ok=True)
tmp = path.with_suffix(path.suffix + ".tmp")
with open(tmp, "w") as fh:
fh.write(json.dumps(users, indent=2) + "\n")
# A power cut right after rename() can otherwise leave a zero-length
# users.json, which load_users() refuses — locking the owner out.
fh.flush()
os.fsync(fh.fileno())
tmp.chmod(0o600)
tmp.replace(path)
def setup_needed() -> bool:
"""True when no admin is registered yet — initial setup is required."""
users = load_users()
return not users or "admin" not in users
def create_admin(username: str, password: str) -> None:
"""Overwrite users.json with a single admin account.
The webinstaller calls this post-install (with the step-1 password) so
the installed system is login-guarded from first boot. The /login
route calls it on first setup for upgrade-path boxes that don't
already have a users.json.
"""
users = {
"admin": {
"username": username,
"hash": hash_password(password),
"created_at": datetime.now(UTC).isoformat(timespec="seconds"),
}
}
save_users(users)
def authenticate(username: str, password: str) -> bool:
"""Return True iff the supplied credentials match the admin record."""
users = load_users()
admin = users.get("admin")
if not admin:
return False
if admin.get("username") != username:
return False
hashed = admin.get("hash")
if not isinstance(hashed, str) or not hashed:
return False
return verify_password(password, hashed)
@dataclass(frozen=True)
class Session:
token: str
username: str
expires_at: datetime
class SessionStore:
"""In-memory session table. Thread-safe (api.py uses the stdlib
HTTPServer which handles one request per thread — though the default
variant is single-threaded, we keep the lock so swapping to
ThreadingHTTPServer later doesn't require revisiting this).
"""
def __init__(self, ttl_seconds: int = COOKIE_TTL_SECONDS) -> None:
self._ttl = timedelta(seconds=ttl_seconds)
self._by_token: dict[str, Session] = {}
self._lock = threading.Lock()
def create(self, username: str) -> Session:
token = secrets.token_urlsafe(32)
session = Session(
token=token,
username=username,
expires_at=datetime.now(UTC) + self._ttl,
)
with self._lock:
self._by_token[token] = session
return session
def lookup(self, token: str | None) -> Session | None:
if not token:
return None
with self._lock:
session = self._by_token.get(token)
if session is None:
return None
if datetime.now(UTC) >= session.expires_at:
# Expired — drop it on the floor so repeat lookups stay fast.
self._by_token.pop(token, None)
return None
return session
def revoke(self, token: str | None) -> None:
if not token:
return
with self._lock:
self._by_token.pop(token, None)
def clear(self) -> None:
"""Test helper — wipe all sessions."""
with self._lock:
self._by_token.clear()
class LoginAttempts:
"""In-memory rate-limiter for failed logins, keyed by (username, ip).
Parallels SessionStore: thread-safe, uses ``datetime.now(UTC)`` so the
same ``_FakeDatetime`` test shim works, lives only in memory so a
``systemctl restart furtka`` wipes a stuck lockout. Tuple keying means
a flood from one source IP can't lock the admin out from elsewhere
(different IP → different key) — the trade-off is that an attacker
can keep probing forever by rotating IPs, but they still eat the
PBKDF2 cost per attempt.
Stored data is a dict[key → list[datetime]] of recent failure
timestamps. Every call prunes entries older than ``WINDOW_SECONDS``,
so memory per active key is bounded by ``MAX_FAILURES``.
"""
MAX_FAILURES = 10
WINDOW_SECONDS = 15 * 60
LOCKOUT_SECONDS = 15 * 60
def __init__(
self,
max_failures: int = MAX_FAILURES,
window_seconds: int = WINDOW_SECONDS,
lockout_seconds: int = LOCKOUT_SECONDS,
) -> None:
self._max = max_failures
self._window = timedelta(seconds=window_seconds)
self._lockout = timedelta(seconds=lockout_seconds)
self._fails: dict[tuple[str, str], list[datetime]] = {}
self._lock = threading.Lock()
def _prune_locked(self, key: tuple[str, str], now: datetime) -> list[datetime]:
"""Drop timestamps older than the window; caller holds self._lock."""
cutoff = now - self._window
kept = [ts for ts in self._fails.get(key, ()) if ts >= cutoff]
if kept:
self._fails[key] = kept
else:
self._fails.pop(key, None)
return kept
def register_failure(self, key: tuple[str, str]) -> None:
now = datetime.now(UTC)
with self._lock:
self._prune_locked(key, now)
self._fails.setdefault(key, []).append(now)
def is_locked(self, key: tuple[str, str]) -> bool:
return self.retry_after_seconds(key) > 0
def retry_after_seconds(self, key: tuple[str, str]) -> int:
"""Seconds remaining on an active lockout, or 0 if not locked."""
now = datetime.now(UTC)
with self._lock:
kept = self._prune_locked(key, now)
if len(kept) < self._max:
return 0
# Lockout runs from the oldest retained failure; once it
# falls off the window the key is effectively released.
unlock_at = kept[0] + self._lockout
remaining = (unlock_at - now).total_seconds()
if remaining <= 0:
return 0
return max(1, math.ceil(remaining))
def clear(self, key: tuple[str, str]) -> None:
with self._lock:
self._fails.pop(key, None)
def clear_all(self) -> None:
"""Test helper — wipe all failure state."""
with self._lock:
self._fails.clear()
# Module-level singleton used by the HTTP handler.
SESSIONS = SessionStore()
LOCKOUT = LoginAttempts()

View file

@ -1,286 +0,0 @@
"""Furtka apps catalog sync.
Mirrors the shape of ``furtka.updater`` but targets a separate Forgejo
repo (``daniel/furtka-apps`` by default) whose releases carry a single
``furtka-apps-<ver>.tar.gz`` with ``VERSION`` at the root and an
``apps/<name>/`` tree underneath. Pulling the catalog keeps the on-box
app ecosystem fresh without requiring a Furtka core release — core
ships a seed ``apps/`` under ``/opt/furtka/current/apps/`` that the
resolver falls back to when the catalog is empty or stale.
Flow of ``sync_catalog()``:
1. flock on ``/run/furtka/catalog.lock`` so two triggers (timer + manual
UI click) can't race.
2. ``check_catalog()`` asks Forgejo for the latest release and picks out
the tarball + sidecar URLs.
3. Download tarball + sidecar to ``/var/lib/furtka/catalog/_downloads/``.
4. Verify the sha256 sidecar against the tarball.
5. Extract into ``/var/lib/furtka/catalog/_staging/``.
6. Validate every ``apps/<name>/manifest.json`` via ``furtka.manifest.
load_manifest``. A broken catalog release is refused here, not half-
applied.
7. Atomic rename: existing live catalog → ``catalog.prev/``, staging →
``catalog/``, then rmtree the prev. Any failure before this step
leaves the live catalog untouched.
8. Write ``/var/lib/furtka/catalog-state.json`` for the UI.
Paths can be overridden via env vars so tests can redirect everything to
a tmp dir.
"""
from __future__ import annotations
import fcntl
import json
import os
import re
import shutil
import time
from dataclasses import dataclass
from pathlib import Path
from furtka import _release_common as _rc
from furtka.manifest import ManifestError, load_manifest
from furtka.paths import catalog_dir
FORGEJO_HOST = os.environ.get("FURTKA_FORGEJO_HOST", "forgejo.sourcegate.online")
CATALOG_REPO = os.environ.get("FURTKA_CATALOG_REPO", "daniel/furtka-apps")
_CATALOG_STATE = Path(os.environ.get("FURTKA_CATALOG_STATE", "/var/lib/furtka/catalog-state.json"))
_LOCK_PATH = Path(os.environ.get("FURTKA_CATALOG_LOCK", "/run/furtka/catalog.lock"))
_STAGING_NAME = "_staging"
_DOWNLOADS_NAME = "_downloads"
_PREV_SUFFIX = ".prev"
_VERSION_FILE = "VERSION"
class CatalogError(RuntimeError):
"""Any failure in the catalog sync flow that should surface to the caller."""
@dataclass(frozen=True)
class CatalogCheck:
current: str | None
latest: str
update_available: bool
tarball_url: str | None
sha256_url: str | None
def state_path() -> Path:
return _CATALOG_STATE
def lock_path() -> Path:
return _LOCK_PATH
def read_current_catalog_version() -> str | None:
"""Return the string in <catalog_dir>/VERSION, or None if absent / unreadable."""
try:
value = (catalog_dir() / _VERSION_FILE).read_text().strip()
except (FileNotFoundError, NotADirectoryError, OSError):
return None
return value or None
def check_catalog() -> CatalogCheck:
"""Query Forgejo for the latest catalog release.
Uses ``/releases?limit=1`` (not ``/releases/latest``) for the same
reason the core updater does — Forgejo's ``latest`` endpoint skips
pre-releases and 404s when every tag carries a suffix.
"""
current = read_current_catalog_version()
releases = _rc.forgejo_api(
FORGEJO_HOST, CATALOG_REPO, "/releases?limit=1", error_cls=CatalogError
)
if not isinstance(releases, list) or not releases:
raise CatalogError("no catalog releases published yet")
release = releases[0]
latest = str(release.get("tag_name") or "").strip()
if not latest:
raise CatalogError("latest catalog release has empty tag_name")
tarball_url = None
sha256_url = None
for asset in release.get("assets") or []:
name = asset.get("name") or ""
url = asset.get("browser_download_url") or ""
if name.endswith(".tar.gz") and "furtka-apps-" in name:
tarball_url = url
elif name.endswith(".tar.gz.sha256"):
sha256_url = url
available = latest != current and (
current is None or _rc.version_tuple(latest) > _rc.version_tuple(current)
)
return CatalogCheck(
current=current,
latest=latest,
update_available=available,
tarball_url=tarball_url,
sha256_url=sha256_url,
)
def write_state(stage: str, **extra) -> None:
"""Atomic JSON state write — same shape as updater's update-state.json."""
state_path().parent.mkdir(parents=True, exist_ok=True)
tmp = state_path().with_suffix(".tmp")
payload = {"stage": stage, "updated_at": time.strftime("%Y-%m-%dT%H:%M:%S%z"), **extra}
tmp.write_text(json.dumps(payload, indent=2))
tmp.replace(state_path())
def read_state() -> dict:
try:
return json.loads(state_path().read_text())
except (FileNotFoundError, json.JSONDecodeError):
return {}
def acquire_lock():
path = lock_path()
path.parent.mkdir(parents=True, exist_ok=True)
fh = path.open("w")
try:
fcntl.flock(fh, fcntl.LOCK_EX | fcntl.LOCK_NB)
except BlockingIOError as e:
fh.close()
raise CatalogError("another catalog sync is already in progress") from e
return fh
_PRIVILEGED_RE = re.compile(r"^\s*privileged:\s*true\s*$", re.MULTILINE)
def _validate_staging(staging: Path, expected_version: str) -> None:
"""Fail hard if the staging tree isn't a well-formed catalog release."""
version_file = staging / _VERSION_FILE
if not version_file.is_file():
raise CatalogError("catalog tarball has no VERSION file at root")
actual = version_file.read_text().strip()
if actual != expected_version:
raise CatalogError(
f"catalog tarball VERSION ({actual!r}) doesn't match expected ({expected_version!r})"
)
apps_root = staging / "apps"
if not apps_root.is_dir():
raise CatalogError("catalog tarball has no apps/ directory")
for entry in sorted(apps_root.iterdir()):
if not entry.is_dir():
continue
manifest_path = entry / "manifest.json"
if not manifest_path.exists():
raise CatalogError(f"catalog app {entry.name!r} has no manifest.json")
try:
manifest = load_manifest(manifest_path, expected_name=entry.name)
except ManifestError as e:
raise CatalogError(f"catalog app {entry.name!r}: invalid manifest: {e}") from e
if manifest.internet.viable:
compose_path = entry / "docker-compose.yaml"
if compose_path.exists() and _PRIVILEGED_RE.search(compose_path.read_text()):
# Not a network-mode check — Traefik reaches a host-networked
# container's port exactly like a bridge-published one. The
# real risk of `internet.viable: true` here is a compromised
# internet-facing container also having host device/kernel
# access, which `privileged: true` grants outright.
raise CatalogError(
f"catalog app {entry.name!r}: internet.viable apps must not run "
"privileged (docker-compose.yaml sets privileged: true)"
)
def _atomic_swap(staging: Path) -> None:
"""Move staging → live catalog, keeping the previous tree as .prev until
the rename succeeds so we never leave a half-written catalog on disk."""
live = catalog_dir()
live.parent.mkdir(parents=True, exist_ok=True)
prev = live.with_name(live.name + _PREV_SUFFIX)
if prev.exists():
shutil.rmtree(prev)
if live.exists():
live.rename(prev)
try:
staging.rename(live)
except OSError as e:
if prev.exists():
# try to restore the previous tree; if that also fails the box
# has no catalog at all until the next sync — still better than
# a partially-extracted tree.
try:
prev.rename(live)
except OSError:
pass
raise CatalogError(f"atomic catalog swap failed: {e}") from e
if prev.exists():
shutil.rmtree(prev, ignore_errors=True)
def sync_catalog() -> CatalogCheck:
"""End-to-end sync. Acquires the lock, writes state at each stage, and
leaves the live catalog untouched on any failure before the rename step.
A failure lands as stage ``"error"`` (with the message) instead of leaving
the last in-flight stage behind: the first hardware bench sat on
``"checking"`` for hours after a boot-time network failure, and the UI
showed "checking…" with nothing actually happening.
"""
with acquire_lock():
try:
return _sync_locked()
except CatalogError as e:
write_state("error", error=str(e))
raise
except Exception as e:
write_state("error", error=str(e) or type(e).__name__)
raise CatalogError(str(e) or type(e).__name__) from e
def _sync_locked() -> CatalogCheck:
write_state("checking")
check = check_catalog()
if not check.update_available:
write_state("done", version=check.current or check.latest, note="already up to date")
return check
if not check.tarball_url or not check.sha256_url:
raise CatalogError("catalog release is missing tarball or sha256 asset")
# Downloads land in a sibling of the live catalog so half-finished
# artefacts never pollute the live tree, and stay under /var/lib/
# furtka/ so a sync interrupted by reboot can resume instead of
# starting over from /tmp (which clears).
dl_dir = catalog_dir().with_name(catalog_dir().name + _DOWNLOADS_NAME)
dl_dir.mkdir(parents=True, exist_ok=True)
tarball = dl_dir / f"furtka-apps-{check.latest}.tar.gz"
sha_file = dl_dir / f"furtka-apps-{check.latest}.tar.gz.sha256"
write_state("downloading", latest=check.latest)
_rc.download(check.tarball_url, tarball, error_cls=CatalogError)
_rc.download(check.sha256_url, sha_file, error_cls=CatalogError)
write_state("verifying", latest=check.latest)
expected = _rc.parse_sha256_sidecar(sha_file.read_text(), error_cls=CatalogError)
_rc.verify_tarball(tarball, expected, error_cls=CatalogError)
write_state("extracting", latest=check.latest)
staging = catalog_dir().with_name(catalog_dir().name + _STAGING_NAME)
if staging.exists():
shutil.rmtree(staging)
try:
_rc.extract_tarball(tarball, staging, error_cls=CatalogError)
_validate_staging(staging, check.latest)
except CatalogError:
shutil.rmtree(staging, ignore_errors=True)
raise
write_state("swapping", latest=check.latest)
try:
_atomic_swap(staging)
except CatalogError:
shutil.rmtree(staging, ignore_errors=True)
raise
write_state("done", version=check.latest, previous=check.current)
return check

View file

@ -1,9 +1,8 @@
import argparse
import json
import sys
from pathlib import Path
from furtka import deps, dockerops, installer, reconciler
from furtka import dockerops, installer, reconciler
from furtka.paths import apps_dir
from furtka.scanner import scan
@ -22,34 +21,9 @@ def _cmd_app_list(args: argparse.Namespace) -> int:
"display_name": r.manifest.display_name,
"version": r.manifest.version,
"description": r.manifest.description,
"description_long": r.manifest.description_long,
"volumes": list(r.manifest.volumes),
"ports": list(r.manifest.ports),
"icon": r.manifest.icon,
"open_url": r.manifest.open_url,
"settings": [
{
"name": s.name,
"label": s.label,
"description": s.description,
"type": s.type,
"required": s.required,
"default": s.default,
}
for s in r.manifest.settings
],
"requires": [
{
"app": req.app,
"on_install": req.on_install,
"on_start": req.on_start,
}
for req in r.manifest.requires
],
"internet": {
"viable": r.manifest.internet.viable,
"port": r.manifest.internet.port,
},
}
if r.manifest
else None,
@ -71,12 +45,6 @@ def _cmd_app_list(args: argparse.Namespace) -> int:
def _cmd_app_install(args: argparse.Namespace) -> int:
# If the user passed a path (or a path-ish thing), bypass dep resolution —
# local paths are dev/test workflows where the caller knows what they want.
# These just copy + reconcile; no `requires`/hook handling.
src_path = Path(args.source)
is_path = src_path.is_dir() or "/" in args.source or args.source.startswith(".")
if is_path:
try:
src = installer.resolve_source(args.source)
target = installer.install_from(src)
@ -89,78 +57,12 @@ def _cmd_app_install(args: argparse.Namespace) -> int:
print(f" {a.describe()}")
return 1 if reconciler.has_errors(actions) else 0
# Catalog/bundled name install: resolve transitive `requires`, copy every
# app folder (the synchronous phase the API runs inline), then drive the
# exact same docker phase the API dispatches via systemd-run — so providers
# come up before consumers and `on_install` hooks fire to provision them.
# Going through run_install (not a bare reconcile) is what makes a CLI
# install of a consumer actually provision against its provider.
from furtka import install_runner
try:
plan = deps.plan_install(args.source)
except deps.DependencyError as e:
print(f"error: {e}", file=sys.stderr)
return 2
try:
if plan.to_install:
installer.install_plan(plan)
to_install = list(plan.to_install)
else:
# Target already installed — reinstall to refresh files, then still
# run the docker phase so hooks re-fire (matches API reinstall).
installer.install_from(installer.resolve_source(args.source))
to_install = [args.source]
except installer.InstallError as e:
print(f"error: {e}", file=sys.stderr)
return 2
# Stage the plan file run_install consumes (it removes it after reading).
install_runner.plan_path().parent.mkdir(parents=True, exist_ok=True)
install_runner.plan_path().write_text(
json.dumps({"target": args.source, "to_install": to_install})
)
try:
install_runner.run_install(args.source)
except Exception as e:
# run_install already wrote state="error"; surface it to the caller.
print(f"error: {e}", file=sys.stderr)
return 1
for name in to_install:
print(f"installed {name}")
return 0
def _cmd_app_install_bg(args: argparse.Namespace) -> int:
"""Docker-facing phases of an install — called by the API via systemd-run.
Internal subcommand; normal CLI users want `app install` (synchronous).
This exists to separate the slow docker pull/up from the synchronous
validation the API does inline, so the UI can poll a state file.
"""
from furtka import install_runner
try:
install_runner.run_install(args.name)
except Exception as e:
# run_install already wrote state="error"; echo for journald.
print(f"install-bg failed: {e}", file=sys.stderr)
return 1
return 0
def _cmd_app_remove(args: argparse.Namespace) -> int:
target = apps_dir() / args.name
if not target.exists():
print(f"error: {args.name!r} is not installed", file=sys.stderr)
return 1
dependents = deps.dependents_of(args.name)
if dependents:
print(
f"error: {args.name!r} is required by: {', '.join(dependents)}. Remove those first.",
file=sys.stderr,
)
return 2
try:
dockerops.compose_down(target, args.name)
except dockerops.DockerError as e:
@ -175,25 +77,6 @@ def _cmd_app_remove(args: argparse.Namespace) -> int:
return 0
def _cmd_gateway_connect_bg(args: argparse.Namespace) -> int:
"""Register + bring up the tunnel — called by the API via systemd-run.
Internal subcommand; the UI drives this through
`POST /api/gateway/connect`, which does fast input validation
synchronously and dispatches this the same way `app install` dispatches
`app install-bg`.
"""
from furtka import gateway
try:
gateway.run_connect(args.gateway_url, args.registration_token, args.box_name)
except Exception as e:
# run_connect already wrote state="error"; echo for journald.
print(f"gateway connect-bg failed: {e}", file=sys.stderr)
return 1
return 0
def _cmd_serve(args: argparse.Namespace) -> int:
# Imported lazily so `furtka` startup stays cheap when the user only runs
# `app list` or `reconcile` (the common case during tests + scripts).
@ -266,60 +149,6 @@ def _cmd_rollback(args: argparse.Namespace) -> int:
return 0
def _cmd_catalog_sync(args: argparse.Namespace) -> int:
from furtka import catalog
if args.check:
try:
check = catalog.check_catalog()
except catalog.CatalogError as e:
print(f"error: {e}", file=sys.stderr)
return 2
if args.json:
print(
json.dumps(
{
"current": check.current,
"latest": check.latest,
"update_available": check.update_available,
},
indent=2,
)
)
elif check.update_available:
print(f"Catalog update available: {check.current or '(none)'} → {check.latest}")
else:
print(f"Catalog already up to date ({check.current or check.latest})")
return 0
try:
check = catalog.sync_catalog()
except catalog.CatalogError as e:
print(f"error: {e}", file=sys.stderr)
return 2
if not check.update_available:
print(f"Catalog already up to date ({check.current or check.latest})")
else:
print(f"Synced catalog {check.current or '(none)'} → {check.latest}")
return 0
def _cmd_catalog_status(args: argparse.Namespace) -> int:
from furtka import catalog
current = catalog.read_current_catalog_version()
state = catalog.read_state()
if args.json:
print(json.dumps({"current": current, "state": state}, indent=2))
return 0
print(f"Catalog version: {current or '(none — run `furtka catalog sync`)'}")
if state:
print(f"Last sync stage: {state.get('stage', '?')} at {state.get('updated_at', '?')}")
else:
print("Last sync stage: (never)")
return 0
def build_parser() -> argparse.ArgumentParser:
p = argparse.ArgumentParser(prog="furtka", description="Furtka resource manager")
sub = p.add_subparsers(dest="command", required=True)
@ -341,15 +170,6 @@ def build_parser() -> argparse.ArgumentParser:
)
app_install.set_defaults(func=_cmd_app_install)
# Internal — called by the HTTP API via systemd-run. Deliberately omitted
# from the help listing; regular CLI users want `app install` above.
app_install_bg = app_sub.add_parser(
"install-bg",
help=argparse.SUPPRESS,
)
app_install_bg.add_argument("name", help="Installed app folder name")
app_install_bg.set_defaults(func=_cmd_app_install_bg)
app_remove = app_sub.add_parser("remove", help="Stop and uninstall an app (keeps volumes)")
app_remove.add_argument("name", help="App name (folder name under /var/lib/furtka/apps/)")
app_remove.set_defaults(func=_cmd_app_remove)
@ -392,50 +212,6 @@ def build_parser() -> argparse.ArgumentParser:
)
rollback.set_defaults(func=_cmd_rollback)
catalog = sub.add_parser("catalog", help="Manage the apps catalog (daniel/furtka-apps)")
catalog_sub = catalog.add_subparsers(dest="subcommand", required=True)
catalog_sync = catalog_sub.add_parser(
"sync",
help="Download and install the latest apps catalog from Forgejo",
)
catalog_sync.add_argument(
"--check",
action="store_true",
help="Only check whether a catalog update is available; don't apply",
)
catalog_sync.add_argument(
"--json",
action="store_true",
help="Emit machine-readable JSON (only honoured with --check)",
)
catalog_sync.set_defaults(func=_cmd_catalog_sync)
catalog_status = catalog_sub.add_parser(
"status",
help="Print the currently-installed catalog version and last-sync stage",
)
catalog_status.add_argument(
"--json",
action="store_true",
help="Emit machine-readable JSON",
)
catalog_status.set_defaults(func=_cmd_catalog_status)
gateway = sub.add_parser("gateway", help="Manage this box's furtka-gateway connection")
gateway_sub = gateway.add_subparsers(dest="subcommand", required=True)
# Internal — called by the HTTP API via systemd-run. Deliberately
# omitted from the help listing, same as `app install-bg`.
gateway_connect_bg = gateway_sub.add_parser(
"connect-bg",
help=argparse.SUPPRESS,
)
gateway_connect_bg.add_argument("gateway_url")
gateway_connect_bg.add_argument("registration_token")
gateway_connect_bg.add_argument("box_name")
gateway_connect_bg.set_defaults(func=_cmd_gateway_connect_bg)
return p

View file

@ -1,237 +0,0 @@
"""App-to-app dependency planning.
A manifest may declare ``requires: [{"app": "<name>", "on_install": ...,
"on_start": ...}]``. This module turns that graph into:
- ``plan_install(name)`` — topo-sorted install order so providers come up
before consumers, with cycle detection. Read-only over the catalog +
installed tree; the installer is the one that mutates.
- ``dependents_of(name)`` — installed apps that name ``<name>`` in their
``requires``. Used by the remove guard to block "rip out mosquitto"
while zigbee2mqtt is still installed.
- ``installed_topo_order(scan_results)`` — re-order a list of installed
apps so reconcile's per-boot sweep visits providers before consumers
(so a consumer's ``on_start`` hook runs against an already-up provider).
- ``provider_exec_service(provider_dir, project)`` — pick the compose
service to ``docker compose exec`` into when firing a hook. v1: first
service in the provider's compose config.
"""
from __future__ import annotations
from dataclasses import dataclass
from pathlib import Path
from furtka import dockerops, sources
from furtka.manifest import Manifest, ManifestError, load_manifest
from furtka.paths import apps_dir
from furtka.scanner import ScanResult, scan
class DependencyError(RuntimeError):
pass
@dataclass(frozen=True)
class DepPlan:
target: str
install_order: tuple[str, ...] # topo: providers first, target last
already_installed: frozenset[str]
to_install: tuple[str, ...] # install_order minus already_installed
def _load_any(name: str) -> Manifest | None:
"""Load `<name>`'s manifest — prefer installed, fall back to catalog/bundled.
Returns None if the app exists nowhere we can see. Caller decides how
loud to be about that — `plan_install` raises, `dependents_of` just
skips entries it can't parse so reconcile keeps working.
"""
installed = apps_dir() / name / "manifest.json"
if installed.is_file():
try:
return load_manifest(installed, expected_name=name)
except ManifestError:
return None
src = sources.resolve_app_name(name)
if src is None:
return None
try:
return load_manifest(src.path / "manifest.json")
except ManifestError:
return None
def _installed_names() -> frozenset[str]:
return frozenset(r.manifest.name for r in scan(apps_dir()) if r.ok)
def plan_install(name: str) -> DepPlan:
"""Build a topo-sorted install plan for `name`.
Walks the dependency graph via the catalog/bundled+installed manifests,
detects cycles, and returns the order plus which entries are already
installed (those get skipped at install time but stay in `install_order`
for sequencing the `on_install` hooks correctly).
"""
WHITE, GRAY, BLACK = 0, 1, 2
color: dict[str, int] = {}
order: list[str] = []
stack_chain: list[str] = []
# Iterative post-order DFS with a per-frame iterator over children.
# Cycle detection uses GRAY-on-GRAY (Tarjan-style) so a chain through
# several apps still surfaces the full path in the error message.
def visit(start: str) -> None:
if color.get(start, WHITE) == BLACK:
return
# Each frame: (name, manifest, iterator over sorted requires)
m = _load_any(start)
if m is None:
raise DependencyError(
f"required app {start!r} not found in installed apps, catalog, or bundled apps"
)
# Sort requires alphabetically for deterministic install order.
children = iter(sorted(r.app for r in m.requires))
stack: list[tuple[str, Manifest, "object"]] = [(start, m, children)] # noqa: UP037
color[start] = GRAY
stack_chain.append(start)
while stack:
cur_name, cur_m, it = stack[-1]
child = next(it, None)
if child is None:
# All children processed — emit and pop.
color[cur_name] = BLACK
order.append(cur_name)
stack.pop()
stack_chain.pop()
continue
c = color.get(child, WHITE)
if c == BLACK:
continue
if c == GRAY:
# Cycle — find the back-edge target in the chain and report.
idx = stack_chain.index(child)
cycle = " -> ".join(stack_chain[idx:] + [child])
raise DependencyError(f"circular dependency: {cycle}")
# WHITE — descend.
child_m = _load_any(child)
if child_m is None:
raise DependencyError(
f"required app {child!r} (needed by {cur_name!r}) "
"not found in installed apps, catalog, or bundled apps"
)
color[child] = GRAY
stack_chain.append(child)
stack.append((child, child_m, iter(sorted(r.app for r in child_m.requires))))
visit(name)
installed = _installed_names()
to_install = tuple(n for n in order if n not in installed)
return DepPlan(
target=name,
install_order=tuple(order),
already_installed=frozenset(n for n in order if n in installed),
to_install=to_install,
)
def dependents_of(name: str) -> tuple[str, ...]:
"""Names of installed apps that declare `<name>` in their `requires`.
Used by the remove guard. Result is sorted alphabetically so error
messages read in a stable order.
"""
out: list[str] = []
for r in scan(apps_dir()):
if not r.ok:
continue
if any(req.app == name for req in r.manifest.requires):
out.append(r.manifest.name)
out.sort()
return tuple(out)
def installed_topo_order(results: list[ScanResult]) -> list[ScanResult]:
"""Re-order installed apps so providers come before consumers.
Apps whose `requires` point at uninstalled providers (or that contain
cycles) are emitted at the tail in their original order — reconcile
already isolates per-app failure so we don't want to abort the whole
sweep on a misconfigured manifest. Ties within a tier stay alphabetical
(the scanner already returns alphabetical), matching the deterministic
boot order users rely on.
"""
ok = [r for r in results if r.ok]
bad = [r for r in results if not r.ok]
by_name = {r.manifest.name: r for r in ok}
# Kahn's algorithm against the installed subgraph only. Edges from
# consumer -> provider; we want providers first, so build the indegree
# over consumers ("how many of MY providers are still pending").
pending_providers: dict[str, set[str]] = {}
consumers_of: dict[str, list[str]] = {n: [] for n in by_name}
for r in ok:
deps = {req.app for req in r.manifest.requires if req.app in by_name}
pending_providers[r.manifest.name] = deps
for dep in deps:
consumers_of[dep].append(r.manifest.name)
# Seed with anything that has no installed providers, alphabetical.
ready = sorted(n for n, deps in pending_providers.items() if not deps)
ordered: list[str] = []
while ready:
# Pop the alphabetically-smallest so ties stay deterministic.
n = ready.pop(0)
ordered.append(n)
for consumer in consumers_of[n]:
pending_providers[consumer].discard(n)
if not pending_providers[consumer]:
# Insert in sorted position.
_insort(ready, consumer)
# Anything left has unresolved providers (missing or cyclic) — append
# in scanner order so reconcile still tries them and gets a clean
# per-app error.
leftover = [n for n in by_name if n not in set(ordered)]
leftover_set = set(leftover)
leftover_in_scan_order = [r.manifest.name for r in ok if r.manifest.name in leftover_set]
out = [by_name[n] for n in ordered]
out.extend(by_name[n] for n in leftover_in_scan_order)
out.extend(bad) # broken manifests already had their place in `results`; append last
return out
def _insort(seq: list[str], value: str) -> None:
"""Insert `value` into the sorted list `seq` (keeping it sorted)."""
lo, hi = 0, len(seq)
while lo < hi:
mid = (lo + hi) // 2
if seq[mid] < value:
lo = mid + 1
else:
hi = mid
seq.insert(lo, value)
def provider_exec_service(provider_dir: Path, project: str) -> str:
"""Pick the compose service name to `docker compose exec` into for a hook.
v1: first service in the provider's compose file. Works for the apps we
actually have (Mosquitto, Postgres, Redis — all single-service). When a
multi-service provider (Authentik etc.) lands, the deferred follow-up is
to add an explicit `service` field on the Requirement entry.
Falls back to the project name if compose config can't be read — that's
a desperate guess but better than crashing, and the resulting exec error
will be surfaced cleanly as a DockerError to the caller.
"""
try:
cfg = dockerops.compose_image_tags(provider_dir, project)
except dockerops.DockerError:
return project
if not cfg:
return project
return next(iter(cfg.keys()))

View file

@ -60,88 +60,6 @@ def compose_pull(app_dir: Path, project: str) -> None:
_run([*_compose_args(app_dir, project), "pull"], cwd=app_dir)
def compose_exec(
app_dir: Path,
project: str,
service: str,
argv: list[str],
*,
env: dict[str, str] | None = None,
timeout: float | None = None,
) -> str:
"""`docker compose exec -T <service> <argv...>`. Returns captured stdout.
`-T` disables TTY allocation — required when called from a non-interactive
parent (the install background job, the reconcile service). Without it,
docker exits with "the input device is not a TTY".
"""
cmd = [*_compose_args(app_dir, project), "exec", "-T"]
for k, v in (env or {}).items():
cmd.extend(["--env", f"{k}={v}"])
cmd.append(service)
cmd.extend(argv)
try:
proc = subprocess.run(
cmd,
cwd=app_dir,
check=False,
capture_output=True,
text=True,
timeout=timeout,
)
except subprocess.TimeoutExpired as e:
raise DockerError(f"compose exec {service}: timed out after {timeout}s") from e
if proc.returncode != 0:
msg = proc.stderr.strip() or proc.stdout.strip()
raise DockerError(f"compose exec {service} exited {proc.returncode}: {msg}")
return proc.stdout
def compose_exec_script(
app_dir: Path,
project: str,
service: str,
script_path: Path,
*,
env: dict[str, str] | None = None,
timeout: float | None = None,
) -> str:
"""Run a host-side script inside the compose container via `sh -s`.
The script's bytes are streamed on stdin, so it doesn't need to be
copied into the image. Used by the app-dependency feature to run a
provider's hook scripts (e.g. "create an MQTT user for the consumer")
when a consumer is being installed or every time it starts.
Returns the script's stdout as text (UTF-8, replace-on-error). Raises
DockerError on non-zero exit or timeout, mirroring `compose_exec`.
"""
body = Path(script_path).read_bytes()
cmd = [*_compose_args(app_dir, project), "exec", "-T"]
for k, v in (env or {}).items():
cmd.extend(["--env", f"{k}={v}"])
cmd.extend([service, "sh", "-s"])
try:
proc = subprocess.run(
cmd,
cwd=app_dir,
check=False,
input=body,
capture_output=True,
timeout=timeout,
)
except subprocess.TimeoutExpired as e:
raise DockerError(
f"compose exec {service}: hook {script_path.name} timed out after {timeout}s"
) from e
if proc.returncode != 0:
err = (proc.stderr or proc.stdout or b"").decode("utf-8", "replace").strip()
raise DockerError(
f"compose exec {service} hook {script_path.name} exited {proc.returncode}: {err}"
)
return proc.stdout.decode("utf-8", "replace")
def compose_image_tags(app_dir: Path, project: str) -> dict[str, str]:
"""Return {service_name: image_tag} as declared in the compose file.

View file

@ -1,246 +0,0 @@
"""Client for a Furtka box registering with (and talking to) a
furtka-gateway.
Talks to the gateway's `/v1/boxes/*` API (see the furtka-gateway repo's
`control_plane/api.py` for the server side) using the same stdlib-only
`urllib.request` convention as `_release_common.py`/`updater.py` — no new
dependency for this.
`register()` persists the result to `paths.gateway_config_file()` (0600,
atomic write — the same tmp-file-then-replace pattern `auth.save_users`
uses) so later calls don't need the box to re-register, and so
`/api/gateway/status` has something to read without holding a live
connection open.
`run_connect()` plus the state/lock helpers below follow the exact shape
of furtka.catalog's own state/lock functions — this module is the
"runner" `furtka gateway connect-bg` calls after `api.py`'s
`POST /api/gateway/connect` dispatches it via systemd-run, the same
async-with-state-file-polling idiom used for install/update/catalog-sync.
"""
from __future__ import annotations
import fcntl
import json
import os
import time
import urllib.error
import urllib.request
from pathlib import Path
from furtka import wireguard
from furtka.paths import gateway_config_file
_CONNECT_STATE = Path(
os.environ.get("FURTKA_GATEWAY_CONNECT_STATE", "/var/lib/furtka/gateway-connect-state.json")
)
_CONNECT_LOCK = Path(
os.environ.get("FURTKA_GATEWAY_CONNECT_LOCK", "/run/furtka/gateway-connect.lock")
)
class GatewayError(Exception):
"""Recoverable failure talking to a furtka-gateway — the caller should 5xx."""
def _request(method: str, url: str, body: dict | None = None, *, token: str | None = None) -> dict:
data = json.dumps(body).encode() if body is not None else None
headers = {}
if data is not None:
headers["Content-Type"] = "application/json"
if token:
headers["Authorization"] = f"Bearer {token}"
req = urllib.request.Request(url, data=data, headers=headers, method=method)
try:
with urllib.request.urlopen(req, timeout=15) as resp:
return json.loads(resp.read())
except urllib.error.HTTPError as e:
detail = e.read().decode(errors="replace")
raise GatewayError(f"{method} {url} -> HTTP {e.code}: {detail}") from e
except urllib.error.URLError as e:
raise GatewayError(f"{method} {url}: {e}") from e
def _post(url: str, body: dict, *, token: str | None = None) -> dict:
return _request("POST", url, body, token=token)
def _delete(url: str, *, token: str | None = None) -> dict:
return _request("DELETE", url, token=token)
def _save_config(config: dict) -> None:
path = gateway_config_file()
path.parent.mkdir(parents=True, exist_ok=True)
tmp = path.with_suffix(path.suffix + ".tmp")
tmp.write_text(json.dumps(config, indent=2) + "\n")
tmp.chmod(0o600)
tmp.replace(path)
def load_config() -> dict | None:
"""Return the persisted {gateway_url, box_id, box_token, wg} config, or
None if this box has never registered with a gateway."""
path = gateway_config_file()
if not path.is_file():
return None
return json.loads(path.read_text())
def register(gateway_url: str, registration_token: str, box_name: str) -> dict:
"""Register this box with a furtka-gateway.
No local keypair is generated here — the gateway's response carries the
full WireGuard interface config, private key included (see
furtka-gateway's `wgeasy.py` module docstring for why stock wg-easy
makes that unavoidable). Persists and returns
{gateway_url, box_id, box_token, wg}.
"""
gateway_url = gateway_url.rstrip("/")
result = _post(
f"{gateway_url}/v1/boxes/register",
{"registration_token": registration_token, "box_name": box_name},
)
config = {
"gateway_url": gateway_url,
"box_id": result["box_id"],
"box_token": result["box_token"],
"wg": result["wg"],
}
_save_config(config)
return config
def rotate_token() -> str:
"""Ask the gateway for a new box token, replacing the stored one."""
config = load_config()
if config is None:
raise GatewayError("not connected to a gateway")
result = _post(
f"{config['gateway_url']}/v1/boxes/{config['box_id']}/rotate-token",
{},
token=config["box_token"],
)
config["box_token"] = result["box_token"]
_save_config(config)
return config["box_token"]
def deregister() -> None:
"""Tell the gateway to forget this box, then drop the local config.
Raises on a failed gateway call rather than silently clearing local
state — a box that can't reach the gateway should stay "connected"
locally so the operation can be retried, instead of losing its
box_token while the gateway's own row (and the wg-easy peer / any
published routes) linger orphaned.
"""
config = load_config()
if config is None:
return
_post(
f"{config['gateway_url']}/v1/boxes/{config['box_id']}/deregister",
{},
token=config["box_token"],
)
gateway_config_file().unlink(missing_ok=True)
def publish_route(app_name: str, subdomain: str, port: int) -> dict:
"""Publish `app_name` at `subdomain` through the connected gateway.
Returns {route_id, public_url}. The gateway independently re-validates
subdomain/port server-side regardless of what's sent here — see
furtka-gateway's routes.py — this call just carries the box owner's
request, it isn't itself the security boundary.
"""
config = load_config()
if config is None:
raise GatewayError("not connected to a gateway")
return _post(
f"{config['gateway_url']}/v1/routes",
{"app_name": app_name, "subdomain": subdomain, "port": port},
token=config["box_token"],
)
def unpublish_route(route_id: str) -> None:
config = load_config()
if config is None:
raise GatewayError("not connected to a gateway")
_delete(f"{config['gateway_url']}/v1/routes/{route_id}", token=config["box_token"])
def state_path() -> Path:
return _CONNECT_STATE
def lock_path() -> Path:
return _CONNECT_LOCK
def write_state(stage: str, **extra) -> None:
"""Atomic JSON state write — same shape as catalog/update-state."""
state_path().parent.mkdir(parents=True, exist_ok=True)
tmp = state_path().with_suffix(".tmp")
payload = {"stage": stage, "updated_at": time.strftime("%Y-%m-%dT%H:%M:%S%z"), **extra}
tmp.write_text(json.dumps(payload, indent=2))
tmp.replace(state_path())
def read_state() -> dict:
try:
return json.loads(state_path().read_text())
except (FileNotFoundError, json.JSONDecodeError):
return {}
def acquire_lock():
path = lock_path()
path.parent.mkdir(parents=True, exist_ok=True)
fh = path.open("w")
try:
fcntl.flock(fh, fcntl.LOCK_EX | fcntl.LOCK_NB)
except BlockingIOError as e:
fh.close()
raise GatewayError("another gateway connect is already in progress") from e
return fh
def run_connect(gateway_url: str, registration_token: str, box_name: str) -> None:
"""Register with the gateway, then bring the tunnel up — the slow part
`furtka gateway connect-bg` runs in the background (dispatched via
systemd-run from `POST /api/gateway/connect`), writing state
transitions as it goes so the UI can poll instead of the request
thread blocking on a slow/unreachable gateway.
A registration saved by an earlier attempt whose tunnel failed to come
up is reused rather than registering again — re-registering would leave
the first box (and its wg-easy peer) orphaned on the gateway.
"""
existing = load_config()
if existing is not None:
if existing["gateway_url"] != gateway_url.rstrip("/"):
write_state(
"error",
error=f"already registered with {existing['gateway_url']} — disconnect first",
)
return
config = existing
else:
write_state("registering")
try:
config = register(gateway_url, registration_token, box_name)
except GatewayError as e:
write_state("error", error=str(e))
return
write_state("starting_tunnel")
try:
wireguard.set_tunnel_enabled(True, wg=config["wg"])
except wireguard.WireguardError as e:
write_state("error", error=str(e))
return
write_state("done")

View file

@ -1,30 +1,15 @@
"""Local-CA HTTPS helpers for the `tls internal` setup.
Caddy generates the local root CA lazily on first start and keeps it under
$XDG_DATA_HOME/caddy/pki/authorities/local/ — our packaged caddy.service
sets `XDG_DATA_HOME=/var/lib`, so on the target that resolves to
/var/lib/caddy/pki/authorities/local/. The private key stays 0600 /
$XDG_DATA_HOME/caddy/pki/authorities/local/ — on the target that's
/var/lib/caddy/.local/share/caddy/pki/authorities/local/ (the caddy system
user's XDG_DATA_HOME resolves there). The private key stays 0600 /
caddy-owned; we only ever read the public root.crt next to it.
HTTPS is **opt-in** since 26.15-alpha. Default Caddyfile has no `:443`
site block, so `tls internal` never triggers cert issuance. The
/settings toggle drops a snippet file into /etc/caddy/furtka-https.d/
that adds the hostname+tls-internal block (plus the redirect snippet
inside /etc/caddy/furtka.d/ for HTTP→HTTPS). Disabling the toggle
removes both snippets and reloads — Caddy falls back to HTTP-only.
Why opt-in: fresh-install boxes used to always serve a self-signed
cert on :443. Any browser that had ever trusted a previous Furtka
box's local CA rejected the new cert with an unbypassable
SEC_ERROR_BAD_SIGNATURE — Firefox in particular has no "Advanced →
Accept" for that case. Making HTTPS explicit means fresh installs
never hit that trap; users who want HTTPS download the rootCA.crt
first and then click the toggle.
This module exposes:
- status(): CA fingerprint + current toggle state
- set_force_https(enabled): write/remove BOTH snippets atomically,
reload Caddy, roll back on failure.
This module exposes two operations:
- status(): current CA fingerprint + whether force-HTTPS is on
- set_force_https(enabled): write/remove the Caddy import snippet that
redirects HTTP to HTTPS, reload Caddy, roll back on failure.
"""
import base64
@ -33,17 +18,10 @@ import re
import subprocess
from pathlib import Path
CA_CERT_PATH = Path("/var/lib/caddy/pki/authorities/local/root.crt")
CA_CERT_PATH = Path("/var/lib/caddy/.local/share/caddy/pki/authorities/local/root.crt")
SNIPPET_DIR = Path("/etc/caddy/furtka.d")
REDIRECT_SNIPPET = SNIPPET_DIR / "redirect.caddyfile"
# Pre-26.21 redirect: permanent (browsers cache a 301 indefinitely, so
# turning HTTPS off left them bouncing to a dead :443) and for every Host —
# including a bare IP, which has no certificate, so devices that reach the
# box by IP (no mDNS on Android / older Windows) couldn't load the UI at all.
LEGACY_REDIRECT_CONTENT = "redir https://{host}{uri} permanent\n"
HTTPS_SNIPPET_DIR = Path("/etc/caddy/furtka-https.d")
HTTPS_SNIPPET = HTTPS_SNIPPET_DIR / "https.caddyfile"
HOSTNAME_FILE = Path("/etc/hostname")
REDIRECT_CONTENT = "redir https://{host}{uri} permanent\n"
_PEM_RE = re.compile(
r"-----BEGIN CERTIFICATE-----\s*(.+?)\s*-----END CERTIFICATE-----",
@ -55,43 +33,6 @@ class HttpsError(Exception):
"""Recoverable failure from set_force_https — the caller should 5xx."""
def _read_hostname(hostname_file: Path = HOSTNAME_FILE) -> str:
"""Return the box's hostname, stripped. Falls back to 'furtka' so a
missing /etc/hostname doesn't produce an empty site block that Caddy
would reject at parse time."""
try:
value = hostname_file.read_text().strip()
except (FileNotFoundError, PermissionError, OSError):
return "furtka"
return value or "furtka"
def _redirect_snippet_content(hostname: str) -> str:
"""HTTP→HTTPS redirect for the names the HTTPS block has a cert for.
Only those hosts are redirected: an IP or any other name keeps plain
HTTP instead of landing on a TLS handshake that can't succeed. 302 so
switching HTTPS off takes effect in browsers immediately.
"""
return (
f"@furtka_https_host host {hostname}.local {hostname}\n"
"redir @furtka_https_host https://{host}{uri} 302\n"
)
def _https_snippet_content(hostname: str) -> str:
"""Caddy site block the HTTPS toggle installs at opt-in.
Serves <hostname>.local and <hostname> on :443 with Caddy's
`tls internal` (local CA auto-issuance), and imports the shared
furtka_routes snippet so the :443 listener exposes the same
routes as :80. Must be written at top-level (not inside another
site block) — that's why the Caddyfile imports furtka-https.d at
top-level rather than inside :80.
"""
return f"{hostname}.local, {hostname} {{\n\ttls internal\n\timport furtka_routes\n}}\n"
def _ca_fingerprint(ca_path: Path) -> str | None:
try:
pem = ca_path.read_text()
@ -113,20 +54,13 @@ def _format_fingerprint(hex_upper: str) -> str:
def status(
ca_path: Path = CA_CERT_PATH,
https_snippet: Path = HTTPS_SNIPPET,
snippet: Path = REDIRECT_SNIPPET,
) -> dict:
"""force_https is True iff the HTTPS listener snippet exists.
Before 26.15-alpha this checked the redirect snippet instead — but
the redirect alone without a :443 listener wouldn't actually serve
HTTPS, so the listener snippet is the authoritative "HTTPS is on"
signal.
"""
fp = _ca_fingerprint(ca_path)
return {
"ca_available": fp is not None,
"fingerprint_sha256": _format_fingerprint(fp) if fp else None,
"force_https": https_snippet.is_file(),
"force_https": snippet.is_file(),
"ca_download_url": "/rootCA.crt",
}
@ -144,49 +78,29 @@ def set_force_https(
enabled: bool,
snippet_dir: Path = SNIPPET_DIR,
snippet: Path = REDIRECT_SNIPPET,
https_snippet_dir: Path = HTTPS_SNIPPET_DIR,
https_snippet: Path = HTTPS_SNIPPET,
hostname_file: Path = HOSTNAME_FILE,
reload_caddy=_default_reload,
) -> bool:
"""Toggle HTTPS by writing or removing two snippets atomically:
1. The top-level HTTPS hostname+tls-internal block (enables :443
listener + Caddy's `tls internal` cert issuance)
2. The :80-scoped redirect snippet (forces HTTP → HTTPS)
Reload Caddy after the snippet swap. On reload failure both
snippets are reverted to their pre-call state so a bad config
can't leave Caddy wedged.
"""Toggle the HTTP→HTTPS redirect by writing or removing the snippet
Caddy imports. Always reloads Caddy. Rolls the snippet state back on
reload failure so a broken config can't leave Caddy wedged on the next
restart.
"""
snippet_dir.mkdir(mode=0o755, parents=True, exist_ok=True)
https_snippet_dir.mkdir(mode=0o755, parents=True, exist_ok=True)
had_redirect = snippet.is_file()
previous_redirect = snippet.read_text() if had_redirect else None
had_https = https_snippet.is_file()
previous_https = https_snippet.read_text() if had_https else None
had = snippet.is_file()
previous = snippet.read_text() if had else None
if enabled:
hostname = _read_hostname(hostname_file)
snippet.write_text(_redirect_snippet_content(hostname))
https_snippet.write_text(_https_snippet_content(hostname))
else:
if had_redirect:
snippet.write_text(REDIRECT_CONTENT)
elif had:
snippet.unlink()
if had_https:
https_snippet.unlink()
try:
reload_caddy()
except subprocess.CalledProcessError as e:
_revert(snippet, previous_redirect)
_revert(https_snippet, previous_https)
_revert(snippet, previous)
msg = (e.stderr or e.stdout or "").strip() or f"exit {e.returncode}"
raise HttpsError(f"caddy reload failed: {msg}") from e
except FileNotFoundError as e:
_revert(snippet, previous_redirect)
_revert(https_snippet, previous_https)
_revert(snippet, previous)
raise HttpsError(f"systemctl not available: {e}") from e
return enabled

View file

@ -1,343 +0,0 @@
"""Background job for app installs — progress-visible via state file.
The slow part of installing an app is `docker compose pull` on a large
image (Jellyfin ~500 MB); without progress feedback, the UI modal sits
dead on "Installing…" for 30+ seconds and the user wonders if it hung.
This module mirrors the exact same shape as ``furtka.catalog`` and
``furtka.updater`` so the UI can poll an install just like it polls a
catalog sync or a self-update. The split is:
- ``furtka.api._do_install`` runs synchronously: resolve source(s), copy
the app folder(s), write .env. Those are fast, and their failures
deserve an immediate 4xx so the install modal can surface them in-line.
- After that the API writes an initial state file (stage
"pulling_image") and dispatches ``systemd-run --unit=furtka-install-
<name>`` to run ``furtka app install-bg <name>`` in the background.
That CLI subcommand is what calls ``run_install()`` here — it does the
docker-facing phases and writes state transitions as it goes.
If the API also wrote a plan file at ``/var/lib/furtka/install-plan.json``
(because the target had transitive dependencies), the runner iterates
through every app in ``to_install`` — pulling, creating volumes, firing
``on_install`` hooks against already-up providers, then ``compose up`` —
so providers are ready before consumers' hooks try to talk to them. The
state file's ``target`` field carries the original user-chosen app name
so the UI can show "Installing mosquitto (required by zigbee2mqtt)".
State file schema (``/var/lib/furtka/install-state.json``):
{
"stage": "pulling_image" | "creating_volumes"
| "running_hooks" | "starting_container" | "done" | "error",
"updated_at": "2026-04-21T17:30:45+0200",
"app": "mosquitto", // app currently being processed
"target": "zigbee2mqtt", // original target (== app for single-app installs)
"version": "1.0.0", // added at "done"
"error": "details..." // added at "error"
}
Lock: ``/run/furtka/install.lock`` (tmpfs, reboot-safe). Global, not
per-app — two parallel installs are not a v1 use-case and the lock
keeps the state-file representation simple (one in-flight install at
a time).
"""
from __future__ import annotations
import fcntl
import json
import os
import re
import time
from pathlib import Path
from furtka import deps, dockerops, installer
from furtka.manifest import SETTING_NAME_RE, Manifest, load_manifest
from furtka.paths import apps_dir
_INSTALL_STATE = Path(os.environ.get("FURTKA_INSTALL_STATE", "/var/lib/furtka/install-state.json"))
_INSTALL_PLAN = Path(os.environ.get("FURTKA_INSTALL_PLAN", "/var/lib/furtka/install-plan.json"))
_LOCK_PATH = Path(os.environ.get("FURTKA_INSTALL_LOCK", "/run/furtka/install.lock"))
_ON_INSTALL_TIMEOUT_SECONDS = 60.0
_FURTKA_JSON_RE = re.compile(r"^FURTKA_JSON:\s*(.*)$")
class InstallRunnerError(RuntimeError):
"""Any failure in the background install flow that should surface to the caller."""
def state_path() -> Path:
return _INSTALL_STATE
def plan_path() -> Path:
return _INSTALL_PLAN
def lock_path() -> Path:
return _LOCK_PATH
def write_state(stage: str, **extra) -> None:
"""Atomic JSON state write — same shape as catalog/update-state."""
state_path().parent.mkdir(parents=True, exist_ok=True)
tmp = state_path().with_suffix(".tmp")
payload = {"stage": stage, "updated_at": time.strftime("%Y-%m-%dT%H:%M:%S%z"), **extra}
tmp.write_text(json.dumps(payload, indent=2))
tmp.replace(state_path())
def read_state() -> dict:
try:
data = json.loads(state_path().read_text())
except (OSError, UnicodeDecodeError, json.JSONDecodeError):
return {}
return data if isinstance(data, dict) else {}
TERMINAL_STAGES = frozenset({"done", "error"})
# How old an unfinished state must be before we believe the job is gone.
# Covers the gap between the API writing the first stage and the detached
# runner taking the lock.
_STALE_AFTER_SECONDS = 60
def clear_stale_state(now: float | None = None) -> bool:
"""Mark an unfinished install as failed if no runner is alive any more.
install-state.json lives in /var/lib and survives reboots, OOM kills and
a runner that never started — each of which used to block every later
install with "another install is in progress" until someone fixed it
over SSH. The runner holds the flock for its whole run, so a free lock
plus an old timestamp means nobody is coming back to finish. Returns
True when it reset the state.
"""
state = read_state()
stage = state.get("stage", "")
if not stage or stage in TERMINAL_STAGES:
return False
try:
updated = time.mktime(time.strptime(state.get("updated_at", ""), "%Y-%m-%dT%H:%M:%S%z"))
except (TypeError, ValueError):
updated = 0.0
if (now if now is not None else time.time()) - updated < _STALE_AFTER_SECONDS:
return False
try:
fh = acquire_lock()
except (InstallRunnerError, OSError):
return False # a runner is still working
try:
write_state(
"error",
app=state.get("app", ""),
error=f"the install was interrupted at '{stage}' (restart or crash) — try again",
)
finally:
fh.close()
return True
def _read_plan(target: str) -> dict:
"""Load the install plan if the API wrote one; otherwise the single-app fallback.
The plan file is consumed once — removed after read so a stale plan from
a previous install can't accidentally steer this run. If the file is
missing/unparseable we synthesize a one-element plan from the target arg
so the old single-app behaviour still works (CLI invocations, smoke tests).
"""
try:
raw = plan_path().read_text()
except (FileNotFoundError, OSError):
return {"target": target, "to_install": [target]}
try:
data = json.loads(raw)
except json.JSONDecodeError:
return {"target": target, "to_install": [target]}
finally:
try:
plan_path().unlink()
except OSError:
pass
if not isinstance(data, dict):
return {"target": target, "to_install": [target]}
return {
"target": data.get("target", target),
"to_install": data.get("to_install") or [target],
}
def acquire_lock():
path = lock_path()
path.parent.mkdir(parents=True, exist_ok=True)
fh = path.open("w")
try:
fcntl.flock(fh, fcntl.LOCK_EX | fcntl.LOCK_NB)
except BlockingIOError as e:
fh.close()
raise InstallRunnerError("another install is already in progress") from e
return fh
def _parse_hook_output(text: str) -> dict[str, str]:
"""Extract KEY=VALUE pairs from hook stdout plus any FURTKA_JSON: {...} line.
KEY=VALUE keys must match the manifest's SETTING_NAME regex (UPPER_SNAKE_CASE)
so a misbehaving hook can't inject e.g. `PATH=` and clobber the container's
runtime environment.
The FURTKA_JSON sentinel is opt-in for hooks that need to return structured
data later (e.g. a list of generated certificates). Only string values are
accepted; non-string values raise so a hook can't smuggle non-env content
into the .env file. JSON values overlay KEY=VALUE values.
"""
out: dict[str, str] = {}
# First pass: skip FURTKA_JSON lines for KEY=VALUE extraction.
kv_lines = [line for line in text.splitlines() if not _FURTKA_JSON_RE.match(line.strip())]
kv = installer.parse_env_text("\n".join(kv_lines))
for key, value in kv.items():
if not SETTING_NAME_RE.match(key):
raise InstallRunnerError(
f"hook returned invalid env-var name {key!r} "
"(must be UPPER_SNAKE_CASE, e.g. MQTT_USER)"
)
out[key] = value
# Second pass: pick up FURTKA_JSON sentinels.
for raw in text.splitlines():
m = _FURTKA_JSON_RE.match(raw.strip())
if not m:
continue
try:
payload = json.loads(m.group(1))
except json.JSONDecodeError as e:
raise InstallRunnerError(f"hook returned invalid FURTKA_JSON payload: {e}") from e
if not isinstance(payload, dict):
raise InstallRunnerError(
"hook FURTKA_JSON payload must be an object of KEY=VALUE strings"
)
for key, value in payload.items():
if not isinstance(key, str) or not SETTING_NAME_RE.match(key):
raise InstallRunnerError(f"hook FURTKA_JSON key {key!r} must be UPPER_SNAKE_CASE")
if not isinstance(value, str):
raise InstallRunnerError(f"hook FURTKA_JSON value for {key!r} must be a string")
out[key] = value
return out
def _merge_hook_output_into_env(env_path: Path, hook_stdout: str) -> None:
"""Overlay hook-returned keys onto an app's `.env`. Hook wins on conflict.
Re-runs the placeholder-secret check so a hook returning literal "changeme"
is refused the same way an unedited .env.example is. Re-chmods to 0600 so
even an interrupted run leaves the file root-only.
"""
overlay = _parse_hook_output(hook_stdout)
if not overlay:
return
existing = installer.read_env_values(env_path)
merged: dict[str, str] = {}
merged.update(existing)
merged.update(overlay) # hook wins
installer.write_env(env_path, merged)
env_path.chmod(0o600)
bad = installer._placeholder_keys(env_path)
if bad:
raise InstallRunnerError(
f"{env_path}: hook returned placeholder values for {', '.join(bad)}"
)
def _fire_install_hooks(consumer: Manifest, consumer_dir: Path) -> None:
"""Run each `on_install` hook against the corresponding provider's container.
The provider must already be running (its `compose up` ran earlier in the
same plan). Hook stdout is parsed via `_parse_hook_output` and merged into
the consumer's `.env` before its own `compose up` fires.
"""
for req in consumer.requires:
if not req.on_install:
continue
provider_dir = apps_dir() / req.app
provider_manifest_path = provider_dir / "manifest.json"
if not provider_manifest_path.is_file():
raise InstallRunnerError(f"{consumer.name}: required app {req.app!r} is not installed")
# Validate provider manifest loads (matches the contract the rest of
# the system relies on — never trust a provider folder with a busted
# manifest).
load_manifest(provider_manifest_path, expected_name=req.app)
hook_abs = provider_dir / req.on_install
if not hook_abs.is_file():
raise InstallRunnerError(
f"{consumer.name}: on_install hook {req.on_install!r} missing in provider {req.app}"
)
service = deps.provider_exec_service(provider_dir, req.app)
stdout = dockerops.compose_exec_script(
provider_dir,
req.app,
service,
hook_abs,
env={
"FURTKA_CONSUMER_APP": consumer.name,
"FURTKA_CONSUMER_VERSION": consumer.version,
},
timeout=_ON_INSTALL_TIMEOUT_SECONDS,
)
_merge_hook_output_into_env(consumer_dir / ".env", stdout)
def run_install(name: str) -> None:
"""Docker-facing phases of the install: pull → volumes → hooks → compose up.
Called by the ``furtka app install-bg <name>`` CLI subcommand from the
systemd-run spawned by the API. Assumes the API has already run
``installer.install_from()`` for every app in the plan, so each app folder,
`.env`, and manifest are on disk under ``apps_dir() / <name>``.
If ``/var/lib/furtka/install-plan.json`` exists, every app in its
``to_install`` is processed in order (providers before consumers). Each
provider is fully up before the consumer's ``on_install`` hooks fire,
so a hook can ``mosquitto_passwd``/`createuser` against a live broker/DB.
Every phase transition is written to the state file for the UI to poll.
On exception the state flips to ``"error"`` with the message, then the
exception is re-raised so the CLI exits non-zero and journald gets a
traceback. Per-app failure aborts the rest of the plan: a half-installed
consumer whose provider is fine is recoverable by retrying.
"""
with acquire_lock():
plan = _read_plan(name)
target = plan["target"]
to_install = list(plan["to_install"])
try:
last_manifest = None
for app_name in to_install:
target_dir = apps_dir() / app_name
m = load_manifest(target_dir / "manifest.json", expected_name=app_name)
last_manifest = m
write_state("pulling_image", app=app_name, target=target)
dockerops.compose_pull(target_dir, app_name)
write_state("creating_volumes", app=app_name, target=target)
for short in m.volumes:
dockerops.ensure_volume(m.volume_name(short))
if m.requires:
write_state("running_hooks", app=app_name, target=target)
_fire_install_hooks(m, target_dir)
write_state("starting_container", app=app_name, target=target)
dockerops.compose_up(target_dir, app_name)
# Terminal state carries the original target's name + version so
# the UI's poll loop ("is install of <target> done yet?") still
# works unchanged.
if last_manifest is not None and last_manifest.name == target:
write_state("done", app=target, target=target, version=last_manifest.version)
else:
# Fallback: target wasn't last in the plan (shouldn't happen for
# a well-formed plan, but don't crash on the terminal write).
write_state("done", app=target, target=target)
except Exception as e:
current = read_state().get("app", target)
write_state("error", app=current, target=target, error=str(e))
raise

View file

@ -1,9 +1,8 @@
import shutil
from pathlib import Path
from furtka import sources
from furtka.manifest import APP_NAME_RE, Manifest, ManifestError, load_manifest
from furtka.paths import apps_dir
from furtka.manifest import ManifestError, load_manifest
from furtka.paths import apps_dir, bundled_apps_dir
# Values that an app's .env.example may use as obvious "fill me in" markers.
# If any of these reach the live .env, install refuses — otherwise we'd ship
@ -11,125 +10,30 @@ from furtka.paths import apps_dir
# default that ends up screenshotted on Hacker News.
PLACEHOLDER_SECRETS: frozenset[str] = frozenset({"changeme"})
# System paths that must never be accepted as a user-supplied `path`-type
# setting. The user is root on their own box, so this is about preventing
# accidental footguns (typing `/etc` when they meant `/mnt/etc`), not
# defending against an attacker. Matches exact paths and their subtrees
# after `Path.resolve()` — so `/mnt/../etc` also lands here.
DENIED_PATH_PREFIXES: tuple[str, ...] = (
"/etc",
"/root",
"/boot",
"/proc",
"/sys",
"/dev",
"/bin",
"/sbin",
"/usr/bin",
"/usr/sbin",
"/var/lib/furtka",
)
class InstallError(RuntimeError):
pass
def app_dir(name: str) -> Path:
"""/var/lib/furtka/apps/<name>, refusing anything that isn't a plain app name.
Names reach us from API bodies and URL paths; without this check "..",
"." or "/" would point remove()'s rmtree at /var/lib/furtka, every app,
or the root filesystem.
"""
if not isinstance(name, str) or not APP_NAME_RE.fullmatch(name):
raise InstallError(f"{name!r} is not a valid app name")
return apps_dir() / name
def _placeholder_values(values: dict[str, str]) -> list[str]:
return [k for k, v in values.items() if v.strip() in PLACEHOLDER_SECRETS]
def _placeholder_keys(env_path: Path) -> list[str]:
if not env_path.exists():
return []
return _placeholder_values(_read_env(env_path))
def _is_denied_system_path(resolved: str) -> bool:
if resolved == "/":
return True
for bad in DENIED_PATH_PREFIXES:
if resolved == bad or resolved.startswith(bad + "/"):
return True
return False
def _path_setting_errors(m: Manifest, values: dict[str, str]) -> list[str]:
"""Validate the filesystem paths named by `path`-type settings.
Returns one human-readable message per offending setting. Empty values
on non-required settings are allowed — the required-field check in the
caller already refuses blanks on required fields before write.
"""
errors: list[str] = []
for s in m.settings:
if s.type != "path":
bad: list[str] = []
for raw in env_path.read_text().splitlines():
line = raw.strip()
if not line or line.startswith("#") or "=" not in line:
continue
value = values.get(s.name, "")
if not value:
continue
p = Path(value)
if not p.is_absolute():
errors.append(f"{s.name}={value!r} must be an absolute path (start with /)")
continue
try:
resolved = p.resolve(strict=False)
except (OSError, RuntimeError) as e:
errors.append(f"{s.name}={value!r} cannot be resolved: {e}")
continue
if _is_denied_system_path(str(resolved)):
errors.append(f"{s.name}={value!r} resolves into a system path and is not allowed")
continue
if not resolved.exists():
errors.append(f"{s.name}={value!r} does not exist on this box")
continue
if not resolved.is_dir():
errors.append(f"{s.name}={value!r} is not a directory")
continue
return errors
def _validate_settings(m: Manifest, values: dict[str, str]) -> None:
"""Refuse a merged settings view before it is written to .env.
Runs in memory so a rejected value (placeholder, system path, line
break) never reaches the live .env where the next reconcile would use it.
"""
for k, v in values.items():
if any(c in v for c in "\r\n\0"):
raise InstallError(f"{m.name}: setting {k!r} must not contain line breaks")
bad = _placeholder_values({s.name: values.get(s.name, "") for s in m.settings})
if bad:
raise InstallError(
f"{m.name}: please replace the example value for {', '.join(bad)} with your own"
)
path_errors = _path_setting_errors(m, values)
if path_errors:
raise InstallError(f"{m.name}: {'; '.join(path_errors)}")
key, _, value = line.partition("=")
value = value.strip().strip('"').strip("'")
if value in PLACEHOLDER_SECRETS:
bad.append(key.strip())
return bad
def _format_env_value(v: str) -> str:
# Quote values that contain whitespace, quotes, or shell metacharacters so
# docker-compose's env substitution reads them back intact. Simple values
# stay unquoted to keep the file readable when a user SSHes in.
if any(c in v for c in "\r\n\0"):
raise InstallError("setting values must not contain line breaks")
if "$" in v and "'" not in v:
# Compose expands ${VAR} inside double quotes, so "pa$word" would
# silently turn into "pa". Single-quoted values are taken literally.
return f"'{v}'"
if v == "" or any(c in v for c in " \t\"'$`\\#"):
escaped = v.replace("\\", "\\\\").replace('"', '\\"')
return f'"{escaped}"'
@ -154,18 +58,17 @@ def resolve_source(source: str) -> Path:
"""Resolve a `furtka app install <source>` arg to a real source folder.
If `source` looks like a path (or exists on disk), use it. Otherwise treat
it as an app name and look it up via `furtka.sources.resolve_app_name` —
which checks the synced catalog first and falls back to the bundled seed.
it as a bundled app name and look up under /opt/furtka/apps/<name>.
"""
p = Path(source)
if p.is_dir():
return p
if "/" in source or source.startswith("."):
raise InstallError(f"{source!r} is not a directory")
resolved = sources.resolve_app_name(source)
if resolved is None:
raise InstallError(f"{source!r} not found as a path, catalog app, or bundled app")
return resolved.path
bundled = bundled_apps_dir() / source
if bundled.is_dir():
return bundled
raise InstallError(f"{source!r} not found as a path or bundled app")
def install_from(src: Path, settings: dict[str, str] | None = None) -> Path:
@ -196,26 +99,17 @@ def install_from(src: Path, settings: dict[str, str] | None = None) -> Path:
if key not in declared:
raise InstallError(f"{m.name}: unknown setting {key!r}")
target = app_dir(m.name)
target = apps_dir() / m.name
target.mkdir(parents=True, exist_ok=True)
for item in src.iterdir():
dest = target / item.name
# Subdirectories (e.g. scripts/ holding a provider's on_install/on_start
# dependency hooks) must come along too — copy the whole tree, replacing
# any stale copy from a previous install so removed files don't linger.
if item.is_dir():
if dest.exists():
shutil.rmtree(dest)
shutil.copytree(item, dest)
continue
if not item.is_file():
continue
# Never overwrite an existing user .env — either settings-driven write
# or previous manual edit has authority.
if item.name == ".env" and (target / ".env").exists():
continue
shutil.copy2(item, dest)
shutil.copy2(item, target / item.name)
env = target / ".env"
env_example = target / ".env.example"
@ -245,7 +139,6 @@ def install_from(src: Path, settings: dict[str, str] | None = None) -> Path:
for s in m.settings:
if s.required and not merged.get(s.name):
raise InstallError(f"{m.name}: setting {s.name!r} is required")
_validate_settings(m, merged)
write_env(env, merged)
elif not env.exists() and env_example.exists():
# First install with no settings and no .env shipped: bootstrap from
@ -265,41 +158,26 @@ def install_from(src: Path, settings: dict[str, str] | None = None) -> Path:
f"file and re-run `furtka app install {m.name}`."
)
path_errors = _path_setting_errors(m, read_env_values(env))
if path_errors:
raise InstallError(f"{m.name}: {'; '.join(path_errors)}")
return target
def parse_env_text(text: str) -> dict[str, str]:
"""Parse KEY=VALUE lines from a string into a dict. Unquotes quoted values.
Reusable by anything that needs the same lenient .env parsing logic
without reading a file — e.g. hook script stdout merged into an app's
.env during install (see install_runner._fire_install_hooks).
"""
def _read_env(env_path: Path) -> dict[str, str]:
"""Parse a simple KEY=VALUE .env into a dict. Unquotes quoted values."""
out: dict[str, str] = {}
for raw in text.splitlines():
for raw in env_path.read_text().splitlines():
line = raw.strip()
if not line or line.startswith("#") or "=" not in line:
continue
key, _, value = line.partition("=")
value = value.strip()
if len(value) >= 2 and value.startswith('"') and value.endswith('"'):
if (value.startswith('"') and value.endswith('"')) or (
value.startswith("'") and value.endswith("'")
):
value = value[1:-1].replace('\\"', '"').replace("\\\\", "\\")
elif len(value) >= 2 and value.startswith("'") and value.endswith("'"):
# Single quotes are literal in compose's dotenv — no escapes.
value = value[1:-1]
out[key.strip()] = value
return out
def _read_env(env_path: Path) -> dict[str, str]:
"""Parse a simple KEY=VALUE .env into a dict. Unquotes quoted values."""
return parse_env_text(env_path.read_text())
def read_env_values(env_path: Path) -> dict[str, str]:
"""Public wrapper — returns {} if the file doesn't exist."""
if not env_path.exists():
@ -315,7 +193,7 @@ def update_env(name: str, settings: dict[str, str]) -> Path:
apps only. Returns the target folder; caller is expected to run
reconcile to restart the containers.
"""
target = app_dir(name)
target = apps_dir() / name
manifest_path = target / "manifest.json"
if not manifest_path.exists():
raise InstallError(f"{name!r} is not installed")
@ -347,39 +225,19 @@ def update_env(name: str, settings: dict[str, str]) -> Path:
for s in m.settings:
if s.required and not merged.get(s.name):
raise InstallError(f"{m.name}: setting {s.name!r} is required")
_validate_settings(m, merged)
write_env(env, merged)
bad = _placeholder_keys(env)
if bad:
raise InstallError(f"{m.name}: {env} still has placeholder values for {', '.join(bad)}.")
return target
def install_plan(plan, settings_target: dict[str, str] | None = None) -> list[Path]:
"""Run the synchronous install phase for every app in `plan.to_install`.
Each name is resolved via `resolve_source()` and copied via `install_from`
in plan order, so providers land before consumers. Only the target app
receives user-supplied settings — transitive providers install from their
catalog/bundled `.env.example` and rely on the placeholder-secret check
to refuse if anyone shipped a "changeme" default.
No rollback on partial failure. Re-running install is the recovery path;
stopping providers a user may already rely on for other apps is more
destructive than a partial state. Returns the list of target folders in
install order.
"""
targets: list[Path] = []
for name in plan.to_install:
src = resolve_source(name)
settings = settings_target if name == plan.target else None
targets.append(install_from(src, settings=settings))
return targets
def remove(name: str) -> Path:
"""Delete /var/lib/furtka/apps/<name>/. Volumes are NOT touched.
Caller is responsible for stopping the compose project first.
"""
target = app_dir(name)
target = apps_dir() / name
if not target.exists():
raise InstallError(f"{name!r} is not installed")
shutil.rmtree(target)

View file

@ -13,9 +13,8 @@ REQUIRED_FIELDS = (
"icon",
)
VALID_SETTING_TYPES = frozenset({"text", "password", "number", "path"})
VALID_SETTING_TYPES = frozenset({"text", "password", "number"})
SETTING_NAME_RE = re.compile(r"^[A-Z_][A-Z0-9_]*$")
APP_NAME_RE = re.compile(r"^[a-z][a-z0-9_-]*$")
class ManifestError(Exception):
@ -32,33 +31,6 @@ class Setting:
default: str | None
@dataclass(frozen=True)
class Internet:
# Whether this app is safe to expose to the internet through a
# furtka-gateway at all. Only ever a catalog author's self-declaration —
# the gateway independently re-validates the port it's given at publish
# time regardless of what this says (see furtka-gateway's routes.py).
viable: bool
# Which declared `ports` entry is the exposable web UI. Required when
# viable is True — an app declaring several ports (e.g. a DNS service
# exposing both its resolver port and an admin UI port) must say which
# one is actually meant to be reachable from the internet; there is no
# way to infer that from `ports` alone.
port: int | None
@dataclass(frozen=True)
class Requirement:
app: str # name of the required app — must resolve in installed/catalog/bundled
# Hook paths are relative to the PROVIDER's app folder (not the consumer's).
# Resolved at hook-fire time, not manifest-load time — the provider may not
# be installed yet when this manifest is parsed.
# on_install: script run via `docker compose exec` on the provider during install.
on_install: str | None
# on_start: script run on every boot before the consumer starts (must be idempotent).
on_start: str | None
@dataclass(frozen=True)
class Manifest:
name: str
@ -70,14 +42,6 @@ class Manifest:
icon: str
description_long: str = ""
settings: tuple[Setting, ...] = field(default_factory=tuple)
# Optional "Open" link for the landing page + installed-app row.
# `{host}` is substituted with the current browser hostname at render
# time so the URL follows whatever the user typed to reach Furtka —
# furtka.local, a raw IP, a future reverse-proxy hostname. Apps with
# no frontend (CLI-only, background workers) leave this empty.
open_url: str = ""
requires: tuple[Requirement, ...] = field(default_factory=tuple)
internet: Internet = field(default_factory=lambda: Internet(viable=False, port=None))
def volume_name(self, short: str) -> str:
# Namespace volume names so two apps can each declare e.g. "data"
@ -128,70 +92,6 @@ def _parse_settings(raw: object, manifest_path: Path) -> tuple[Setting, ...]:
return tuple(out)
def _validate_hook_path(value: object, manifest_path: Path, where: str) -> str | None:
if value is None:
return None
if not isinstance(value, str) or not value:
raise ManifestError(f"{manifest_path}: {where} must be a non-empty string if set")
if value.startswith("/"):
raise ManifestError(f"{manifest_path}: {where} must be relative (no leading /)")
parts = value.replace("\\", "/").split("/")
if any(p == ".." for p in parts):
raise ManifestError(f"{manifest_path}: {where} must not contain '..'")
return value
def _parse_requires(raw: object, manifest_path: Path, self_name: str) -> tuple[Requirement, ...]:
if raw is None:
return ()
if not isinstance(raw, list):
raise ManifestError(f"{manifest_path}: requires must be a list")
out: list[Requirement] = []
seen: set[str] = set()
for i, item in enumerate(raw):
if not isinstance(item, dict):
raise ManifestError(f"{manifest_path}: requires[{i}] must be an object")
app = item.get("app")
if not isinstance(app, str) or not app or not APP_NAME_RE.match(app):
raise ManifestError(
f"{manifest_path}: requires[{i}].app must be a non-empty lowercase app name"
)
if app == self_name:
raise ManifestError(f"{manifest_path}: requires[{i}].app {app!r} is a self-reference")
if app in seen:
raise ManifestError(f"{manifest_path}: requires has duplicate app {app!r}")
seen.add(app)
on_install = _validate_hook_path(
item.get("on_install"), manifest_path, f"requires[{app}].on_install"
)
on_start = _validate_hook_path(
item.get("on_start"), manifest_path, f"requires[{app}].on_start"
)
out.append(Requirement(app=app, on_install=on_install, on_start=on_start))
return tuple(out)
def _parse_internet(raw: object, manifest_path: Path, ports: list[int]) -> Internet:
if raw is None:
return Internet(viable=False, port=None)
if not isinstance(raw, dict):
raise ManifestError(f"{manifest_path}: internet must be an object")
viable = bool(raw.get("viable", False))
port = raw.get("port")
if not viable:
return Internet(viable=False, port=None)
if not isinstance(port, int) or isinstance(port, bool):
raise ManifestError(f"{manifest_path}: internet.port must be an integer when viable")
if port not in ports:
raise ManifestError(
f"{manifest_path}: internet.port {port} must be one of the declared ports {ports}"
)
return Internet(viable=True, port=port)
def load_manifest(path: Path, expected_name: str | None = None) -> Manifest:
"""Parse and validate a manifest.json.
@ -212,10 +112,8 @@ def load_manifest(path: Path, expected_name: str | None = None) -> Manifest:
raise ManifestError(f"{path}: missing required fields: {', '.join(missing)}")
name = raw["name"]
if not isinstance(name, str) or not APP_NAME_RE.fullmatch(name):
raise ManifestError(
f"{path}: name must be lowercase letters, digits, '-' or '_' (got {name!r})"
)
if not isinstance(name, str) or not name:
raise ManifestError(f"{path}: name must be a non-empty string")
if expected_name is not None and name != expected_name:
raise ManifestError(f"{path}: name {name!r} must equal {expected_name!r}")
@ -228,12 +126,6 @@ def load_manifest(path: Path, expected_name: str | None = None) -> Manifest:
raise ManifestError(f"{path}: ports must be a list of integers")
settings = _parse_settings(raw.get("settings"), path)
requires = _parse_requires(raw.get("requires"), path, name)
internet = _parse_internet(raw.get("internet"), path, ports)
open_url_raw = raw.get("open_url", "")
if not isinstance(open_url_raw, str):
raise ManifestError(f"{path}: open_url must be a string if set")
return Manifest(
name=name,
@ -245,7 +137,4 @@ def load_manifest(path: Path, expected_name: str | None = None) -> Manifest:
icon=str(raw["icon"]),
description_long=str(raw.get("description_long", "")),
settings=settings,
open_url=open_url_raw,
requires=requires,
internet=internet,
)

View file

@ -1,95 +0,0 @@
"""Stdlib-only password hashing, compatible with werkzeug's hash format.
Why this exists: 26.11-alpha introduced auth via ``werkzeug.security``,
but the target system doesn't have ``werkzeug`` installed (Core runs as
system Python with only the stdlib — pyproject.toml's ``flask>=3.0``
dep is never pip-installed on the box). Fresh installs from a 26.11 /
26.12 ISO crashed on import; upgrades from pre-auth versions were
double-broken by that plus a too-strict updater health check.
Fix: replace werkzeug with stdlib equivalents using the same hash
**format** so existing ``users.json`` files created by 26.11 / 26.12 on
the rare boxes that happened to have werkzeug installed (Medion, .196
after manual pacman) still verify.
Format: ``<method>$<salt>$<hex digest>``
- ``pbkdf2:<hash>:<iterations>`` — what we generate by default here
- ``scrypt:<N>:<r>:<p>`` — what werkzeug's default produces
Both are implemented via ``hashlib`` which has been stdlib since 3.6.
"""
from __future__ import annotations
import hashlib
import hmac
import secrets
_PBKDF2_HASH = "sha256"
_PBKDF2_ITERATIONS = 600_000
_SALT_LEN = 16
def hash_password(password: str) -> str:
"""Return a ``pbkdf2:sha256:<iter>$<salt>$<hex>`` hash of *password*.
PBKDF2-SHA256 over UTF-8. 600k iterations — same as werkzeug's
default in the 3.x series, roughly OWASP 2023's recommendation.
"""
if not isinstance(password, str):
raise TypeError("password must be str")
salt = secrets.token_urlsafe(_SALT_LEN)[:_SALT_LEN]
dk = hashlib.pbkdf2_hmac(
_PBKDF2_HASH, password.encode("utf-8"), salt.encode("utf-8"), _PBKDF2_ITERATIONS
)
return f"pbkdf2:{_PBKDF2_HASH}:{_PBKDF2_ITERATIONS}${salt}${dk.hex()}"
def verify_password(password: str, hashed: str) -> bool:
"""Constant-time verify *password* against a stored *hashed* value.
Accepts both our own pbkdf2 hashes and legacy werkzeug scrypt
hashes in ``scrypt:N:r:p$salt$hex`` form — so users.json files
written by 26.11 / 26.12 keep working after upgrade.
"""
if not isinstance(password, str) or not isinstance(hashed, str):
return False
try:
method, salt, expected = hashed.split("$", 2)
except ValueError:
return False
parts = method.split(":")
if not parts:
return False
algo = parts[0]
pw_bytes = password.encode("utf-8")
salt_bytes = salt.encode("utf-8")
try:
if algo == "pbkdf2":
if len(parts) < 3:
return False
inner_hash = parts[1]
iterations = int(parts[2])
dk = hashlib.pbkdf2_hmac(inner_hash, pw_bytes, salt_bytes, iterations)
elif algo == "scrypt":
# werkzeug: scrypt:N:r:p, dklen=64, maxmem=132 MiB. Without
# the explicit maxmem we'd hit OpenSSL's default memory cap
# and throw ValueError on N >= 32768.
if len(parts) < 4:
return False
n = int(parts[1])
r = int(parts[2])
p = int(parts[3])
dk = hashlib.scrypt(
pw_bytes,
salt=salt_bytes,
n=n,
r=r,
p=p,
dklen=64,
maxmem=132 * 1024 * 1024,
)
else:
return False
except (ValueError, TypeError, OverflowError):
return False
return hmac.compare_digest(dk.hex(), expected)

View file

@ -7,31 +7,6 @@ DEFAULT_APPS_DIR = Path("/var/lib/furtka/apps")
# symlink. A flat /opt/furtka/apps path would break the Phase-2 self-update
# flow (symlink swap wouldn't move the bundled-app tree along with the code).
DEFAULT_BUNDLED_APPS_DIR = Path("/opt/furtka/current/apps")
# Catalog apps come from `furtka catalog sync` pulling the daniel/furtka-apps
# release tarball. Lives under /var/lib/furtka/ so it survives core self-
# updates — the resolver (furtka.sources) prefers it over the bundled seed.
DEFAULT_CATALOG_DIR = Path("/var/lib/furtka/catalog")
# Users / auth state. One JSON file keyed by role — today only "admin" exists.
# Lives under /var/lib/furtka/ so self-updates don't stomp it. Mode 0600 is
# enforced by furtka.auth.save_users (same atomic-write pattern as the app
# .env files).
DEFAULT_USERS_FILE = Path("/var/lib/furtka/users.json")
# Static-web asset dir served by the Python handler for / and
# /settings* so those pages pick up the auth-guard. Caddy also serves
# /style.css and other assets directly from here for the login page.
DEFAULT_STATIC_WWW = Path("/opt/furtka/current/assets/www")
# This box's furtka-gateway registration: {gateway_url, box_id, box_token,
# wg: {...}}. Written once by furtka.gateway.register(), 0600 (it carries
# the box's WireGuard private key — see furtka-gateway's wgeasy.py for why
# that's unavoidable with stock wg-easy). Lives under /var/lib/furtka/ so
# self-updates don't stomp it, same as users.json.
DEFAULT_GATEWAY_CONFIG = Path("/var/lib/furtka/gateway.json")
# Which installed apps are currently published through the connected
# gateway, keyed by app name: {app_name: {route_id, subdomain, port}}.
# Separate from gateway.json since this changes per-app-toggle, not per
# gateway-connection — no reason to rewrite the (more sensitive) box
# credentials file on every publish/unpublish.
DEFAULT_INTERNET_ROUTES_FILE = Path("/var/lib/furtka/internet-routes.json")
def apps_dir() -> Path:
@ -40,27 +15,3 @@ def apps_dir() -> Path:
def bundled_apps_dir() -> Path:
return Path(os.environ.get("FURTKA_BUNDLED_APPS_DIR", DEFAULT_BUNDLED_APPS_DIR))
def catalog_dir() -> Path:
return Path(os.environ.get("FURTKA_CATALOG_DIR", DEFAULT_CATALOG_DIR))
def catalog_apps_dir() -> Path:
return catalog_dir() / "apps"
def users_file() -> Path:
return Path(os.environ.get("FURTKA_USERS_FILE", DEFAULT_USERS_FILE))
def static_www_dir() -> Path:
return Path(os.environ.get("FURTKA_STATIC_WWW", DEFAULT_STATIC_WWW))
def gateway_config_file() -> Path:
return Path(os.environ.get("FURTKA_GATEWAY_CONFIG", DEFAULT_GATEWAY_CONFIG))
def internet_routes_file() -> Path:
return Path(os.environ.get("FURTKA_INTERNET_ROUTES_FILE", DEFAULT_INTERNET_ROUTES_FILE))

View file

@ -1,21 +1,13 @@
from dataclasses import dataclass
from pathlib import Path
from furtka import deps, dockerops, installer
from furtka.manifest import SETTING_NAME_RE, ManifestError, load_manifest
from furtka import dockerops
from furtka.scanner import scan
_ON_START_TIMEOUT_SECONDS = 30.0
# Consumer .env values are exported into an on_start hook's environment under
# this prefix. Namespaced so a consumer setting named PATH/HOME/etc. can't
# clobber the provider container's own environment when the hook runs.
_CONSUMER_ENV_PREFIX = "FURTKA_CONSUMER_ENV_"
@dataclass(frozen=True)
class Action:
kind: str # "ensure_volume" | "compose_up" | "hook" | "skip" | "error"
kind: str # "ensure_volume" | "compose_up" | "skip"
target: str
detail: str = ""
@ -28,20 +20,13 @@ class Action:
def reconcile(apps_root: Path, dry_run: bool = False) -> list[Action]:
"""Walk the apps tree and bring docker into the desired state.
Apps are visited in dependency order — providers before consumers — so a
consumer's `on_start` hook runs against an already-up provider. Within a
tier, order stays alphabetical for deterministic boot logs. Apps with
unresolvable `requires` (missing provider, broken manifest cycle) are
visited last; reconcile's per-app isolation still kicks in if they fail.
Failures during one app's reconcile (Docker errors, missing binary, …) are
captured as Action(kind='error', …) and do NOT abort the whole sweep — the
other apps still get reconciled. Callers inspect the returned actions to
decide overall success.
"""
actions: list[Action] = []
results = scan(apps_root)
for result in deps.installed_topo_order(results):
for result in scan(apps_root):
if not result.ok:
actions.append(Action("skip", result.path.name, result.error or ""))
continue
@ -52,31 +37,6 @@ def reconcile(apps_root: Path, dry_run: bool = False) -> list[Action]:
actions.append(Action("ensure_volume", full))
if not dry_run:
dockerops.ensure_volume(full)
hook_failed = False
for req in m.requires:
if not req.on_start:
continue
hook_label = f"{m.name}:{req.app}:on_start"
actions.append(Action("hook", hook_label, req.on_start))
if dry_run:
continue
try:
_fire_on_start_hook(m, req, apps_root)
except (
dockerops.DockerError,
FileNotFoundError,
OSError,
ManifestError,
) as e:
actions.append(Action("error", m.name, f"on_start({req.app}): {e}"))
hook_failed = True
break
if hook_failed:
# Skip compose_up: a consumer whose provider's contract didn't
# get re-established (e.g. missing MQTT user) starting up
# blindly is worse than not starting it. The provider stays up
# and other apps in the sweep keep going.
continue
actions.append(Action("compose_up", m.name))
if not dry_run:
dockerops.compose_up(result.path, m.name)
@ -88,56 +48,5 @@ def reconcile(apps_root: Path, dry_run: bool = False) -> list[Action]:
return actions
def _fire_on_start_hook(consumer, req, apps_root: Path) -> None:
"""Run a single `on_start` hook against the provider's running container.
Reconciler-local helper — kept narrow on purpose so reconcile's main loop
stays scannable. Errors propagate; the caller decorates with the per-app
Action("error", ...) and skips compose_up for this consumer.
"""
provider_dir = apps_root / req.app
provider_manifest_path = provider_dir / "manifest.json"
if not provider_manifest_path.is_file():
raise FileNotFoundError(f"required app {req.app!r} is not installed")
# Validate provider manifest loads (otherwise scanner would have skipped
# it and we'd still try to exec — fail loud here instead).
load_manifest(provider_manifest_path, expected_name=req.app)
hook_abs = provider_dir / req.on_start
if not hook_abs.is_file():
raise FileNotFoundError(f"on_start hook {req.on_start!r} missing in provider {req.app}")
service = deps.provider_exec_service(provider_dir, req.app)
dockerops.compose_exec_script(
provider_dir,
req.app,
service,
hook_abs,
env=_on_start_hook_env(consumer, apps_root),
timeout=_ON_START_TIMEOUT_SECONDS,
)
def _on_start_hook_env(consumer, apps_root: Path) -> dict[str, str]:
"""Build the environment handed to an on_start hook.
Beyond the consumer's name/version, this injects the consumer's stored
`.env` values (namespaced under `FURTKA_CONSUMER_ENV_`) so the hook can
re-establish provider-side state *idempotently* — e.g. re-create an MQTT
account with the SAME password the consumer already holds. The reconciler
does not merge an on_start hook's stdout back into the consumer's `.env`
(unlike on_install): on_start reads consumer state, it doesn't mutate it.
Only UPPER_SNAKE_CASE keys are injected so a hand-edited `.env` can't
produce a malformed `docker compose exec --env` argument.
"""
env = {
"FURTKA_CONSUMER_APP": consumer.name,
"FURTKA_CONSUMER_VERSION": consumer.version,
}
consumer_env = installer.read_env_values(apps_root / consumer.name / ".env")
for key, value in consumer_env.items():
if SETTING_NAME_RE.match(key):
env[f"{_CONSUMER_ENV_PREFIX}{key}"] = value
return env
def has_errors(actions: list[Action]) -> bool:
return any(a.kind == "error" for a in actions)

View file

@ -1,75 +0,0 @@
"""Single lookup layer for "where does app <name> live right now?".
Three origins an app folder can come from:
- ``catalog`` — the daily-synced ``/var/lib/furtka/catalog/apps/`` tree
that ``furtka.catalog.sync_catalog`` maintains.
- ``bundled`` — the seed ``/opt/furtka/current/apps/`` tree shipped
inside the core release tarball. Used for first-boot before any
catalog sync has run, and as the fallback when the catalog is stale,
missing, or doesn't know about this app.
- ``local`` — an explicit directory path passed to ``furtka app install
/path/to/src``; bypasses this module entirely.
Catalog wins on collision. The precedence is deliberate — when the user
pressed "Sync apps catalog" they want what they synced, not whatever the
core tarball happened to carry.
"""
from __future__ import annotations
from dataclasses import dataclass
from pathlib import Path
from furtka.paths import bundled_apps_dir, catalog_apps_dir
@dataclass(frozen=True)
class AppSource:
path: Path
origin: str # "catalog" | "bundled" | "local"
def resolve_app_name(name: str) -> AppSource | None:
"""Return the source folder for a bundled/catalog app name.
Checks catalog first, then bundled seed. Presence is tested by
``manifest.json`` existing — an empty folder or a stray ``.env``
won't register. Returns ``None`` if the name isn't known anywhere.
"""
cat = catalog_apps_dir() / name
if (cat / "manifest.json").is_file():
return AppSource(cat, "catalog")
bundled = bundled_apps_dir() / name
if (bundled / "manifest.json").is_file():
return AppSource(bundled, "bundled")
return None
def list_available() -> list[AppSource]:
"""Catalog ∪ bundled, catalog wins on name collision.
Each entry is a folder containing a manifest.json. Ordering is
alphabetical by folder name, which matches how the scanner sorts so
the UI list stays stable across sync/reboot.
"""
seen: dict[str, AppSource] = {}
cat_root = catalog_apps_dir()
if cat_root.is_dir():
for entry in sorted(cat_root.iterdir()):
if not entry.is_dir():
continue
if not (entry / "manifest.json").is_file():
continue
seen[entry.name] = AppSource(entry, "catalog")
bundled_root = bundled_apps_dir()
if bundled_root.is_dir():
for entry in sorted(bundled_root.iterdir()):
if not entry.is_dir():
continue
if entry.name in seen:
continue
if not (entry / "manifest.json").is_file():
continue
seen[entry.name] = AppSource(entry, "bundled")
return [seen[name] for name in sorted(seen)]

View file

@ -29,19 +29,18 @@ the updater at a tmpdir.
from __future__ import annotations
import fcntl
import http.client
import hashlib
import json
import os
import shutil
import subprocess
import tarfile
import time
import urllib.error
import urllib.request
from dataclasses import dataclass
from pathlib import Path
from furtka import _release_common as _rc
FORGEJO_HOST = os.environ.get("FURTKA_FORGEJO_HOST", "forgejo.sourcegate.online")
FORGEJO_REPO = os.environ.get("FURTKA_FORGEJO_REPO", "daniel/furtka")
_FURTKA_ROOT = Path(os.environ.get("FURTKA_ROOT", "/opt/furtka"))
@ -50,12 +49,7 @@ _CADDYFILE_LIVE = Path(os.environ.get("FURTKA_CADDYFILE_PATH", "/etc/caddy/Caddy
_CADDY_SNIPPET_DIR = Path(
os.environ.get("FURTKA_CADDY_SNIPPET_DIR", str(_CADDYFILE_LIVE.parent / "furtka.d"))
)
_CADDY_HTTPS_SNIPPET_DIR = Path(
os.environ.get("FURTKA_CADDY_HTTPS_SNIPPET_DIR", str(_CADDYFILE_LIVE.parent / "furtka-https.d"))
)
_SYSTEMD_DIR = Path(os.environ.get("FURTKA_SYSTEMD_DIR", "/etc/systemd/system"))
_HOSTNAME_FILE = Path(os.environ.get("FURTKA_HOSTNAME_FILE", "/etc/hostname"))
_CADDYFILE_HOSTNAME_MARKER = "__FURTKA_HOSTNAME__"
class UpdateError(RuntimeError):
@ -99,11 +93,37 @@ def read_current_version() -> str:
return "dev"
def _forgejo_api(path: str) -> dict | list:
return _rc.forgejo_api(FORGEJO_HOST, FORGEJO_REPO, path, error_cls=UpdateError)
def _forgejo_api(path: str) -> dict:
url = f"https://{FORGEJO_HOST}/api/v1/repos/{FORGEJO_REPO}{path}"
req = urllib.request.Request(url, headers={"Accept": "application/json"})
try:
with urllib.request.urlopen(req, timeout=15) as resp:
return json.loads(resp.read())
except (urllib.error.URLError, json.JSONDecodeError) as e:
raise UpdateError(f"forgejo api {url}: {e}") from e
_version_tuple = _rc.version_tuple
def _version_tuple(v: str) -> tuple:
"""Compare CalVer tags like 26.1-alpha < 26.1-beta < 26.1 < 26.2-alpha.
The "stable" release (no suffix) sorts after its own pre-releases. Uses a
tuple of (year, release, stage-rank, stage-tag). Stage rank: alpha=0,
beta=1, rc=2, stable=3, unknown=-1.
"""
stage_rank = {"alpha": 0, "beta": 1, "rc": 2}
head, _, suffix = v.partition("-")
try:
year_str, release_str = head.split(".", 1)
year = int(year_str)
release = int(release_str)
except (ValueError, IndexError):
return (-1, -1, -1, v)
if not suffix:
return (year, release, 3, "")
for name, rank in stage_rank.items():
if suffix.startswith(name):
return (year, release, rank, suffix)
return (year, release, -1, suffix)
def check_update() -> UpdateCheck:
@ -143,117 +163,78 @@ def check_update() -> UpdateCheck:
def _download(url: str, dest: Path) -> None:
_rc.download(url, dest, error_cls=UpdateError)
dest.parent.mkdir(parents=True, exist_ok=True)
req = urllib.request.Request(url)
try:
with urllib.request.urlopen(req, timeout=60) as resp, dest.open("wb") as f:
shutil.copyfileobj(resp, f)
except urllib.error.URLError as e:
raise UpdateError(f"download {url}: {e}") from e
_sha256_of = _rc.sha256_of
def _sha256_of(path: Path) -> str:
h = hashlib.sha256()
with path.open("rb") as f:
for chunk in iter(lambda: f.read(1024 * 1024), b""):
h.update(chunk)
return h.hexdigest()
def verify_tarball(tarball: Path, expected_sha: str) -> None:
_rc.verify_tarball(tarball, expected_sha, error_cls=UpdateError)
actual = _sha256_of(tarball)
if actual != expected_sha:
raise UpdateError(f"sha256 mismatch: expected {expected_sha}, got {actual}")
def _parse_sha256_sidecar(text: str) -> str:
return _rc.parse_sha256_sidecar(text, error_cls=UpdateError)
"""Extract the hash from a standard `sha256sum` sidecar line."""
line = text.strip().split("\n", 1)[0].strip()
if not line:
raise UpdateError("empty sha256 sidecar")
return line.split()[0]
def _extract_tarball(tarball: Path, dest: Path) -> str:
return _rc.extract_tarball(tarball, dest, error_cls=UpdateError)
def _current_hostname() -> str:
"""Read the box's hostname from /etc/hostname, falling back to 'furtka'.
Used to substitute the __FURTKA_HOSTNAME__ marker in the shipped Caddyfile
so Caddy's `tls internal` sees a real name to issue a leaf cert for.
"""
"""Extract the tarball and return the VERSION read from its root."""
dest.mkdir(parents=True, exist_ok=True)
with tarfile.open(tarball, "r:gz") as tf:
# defensive: refuse entries that would escape dest
for member in tf.getmembers():
if member.name.startswith(("/", "..")) or ".." in Path(member.name).parts:
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:
name = _HOSTNAME_FILE.read_text().strip()
except (FileNotFoundError, PermissionError, OSError):
return "furtka"
return name or "furtka"
def _maybe_migrate_preserve_https() -> None:
"""26.14 → 26.15 migration: if the box already had the force-HTTPS
redirect snippet on disk, that means the user explicitly opted
into HTTPS under the old regime. Under the new opt-in regime,
HTTPS also requires a separate listener snippet — write it here so
the user's HTTPS doesn't silently break when the Caddyfile refresh
removes the default hostname block.
"""
redirect_snippet = _CADDY_SNIPPET_DIR / "redirect.caddyfile"
https_snippet = _CADDY_HTTPS_SNIPPET_DIR / "https.caddyfile"
if not redirect_snippet.is_file() or https_snippet.is_file():
return
hostname = _current_hostname()
https_snippet.write_text(
f"{hostname}.local, {hostname} {{\n\ttls internal\n\timport furtka_routes\n}}\n"
)
def _maybe_migrate_redirect_snippet() -> None:
"""26.21 migration: swap the old catch-all 301 redirect for the
hostname-scoped 302 one, so boxes reached by IP can load the UI again
and turning HTTPS off stops sticking in browser caches."""
from furtka import https
redirect_snippet = _CADDY_SNIPPET_DIR / "redirect.caddyfile"
if not redirect_snippet.is_file():
return
if redirect_snippet.read_text() != https.LEGACY_REDIRECT_CONTENT:
return
redirect_snippet.write_text(https._redirect_snippet_content(_current_hostname()))
tf.extractall(dest, filter="data")
except TypeError:
tf.extractall(dest)
version_file = dest / "VERSION"
if not version_file.is_file():
raise UpdateError("tarball has no VERSION file at root")
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).
Substitutes __FURTKA_HOSTNAME__ with the current hostname before comparing
and writing — same rendering the webinstaller applies at install time, so
a self-update lands byte-identical content when nothing else changed.
"""
if the file changed (so caddy needs more than a bare reload)."""
if not source.is_file():
return False
# Snippet dirs for the /api/furtka/https/force toggle. Pre-HTTPS
# installs don't have them; ensure both so the Caddyfile's glob
# imports can't trip an older Caddy on missing paths during the
# first reload. furtka-https.d is new in 26.15-alpha — older boxes
# upgrading across this version line won't have it on disk yet.
# Snippet dir for the /api/furtka/https/force toggle. Pre-HTTPS installs
# don't have this dir; ensure it so the Caddyfile's glob import can't
# trip an older Caddy on a missing path during the first reload.
_CADDY_SNIPPET_DIR.mkdir(mode=0o755, parents=True, exist_ok=True)
_CADDY_HTTPS_SNIPPET_DIR.mkdir(mode=0o755, parents=True, exist_ok=True)
# Migration: pre-26.15 Caddyfile always served :443 via tls internal,
# so a box that had the "force HTTPS" redirect toggle ON relied on
# HTTPS being there implicitly. After this Caddyfile refresh the
# hostname block is gone, so the redirect would 301 to a dead :443.
# Preserve intent by writing the HTTPS listener snippet too.
_maybe_migrate_preserve_https()
_maybe_migrate_redirect_snippet()
rendered = source.read_text().replace(_CADDYFILE_HOSTNAME_MARKER, _current_hostname())
if _CADDYFILE_LIVE.is_file() and rendered == _CADDYFILE_LIVE.read_text():
if _CADDYFILE_LIVE.is_file() and source.read_bytes() == _CADDYFILE_LIVE.read_bytes():
return False
_CADDYFILE_LIVE.parent.mkdir(parents=True, exist_ok=True)
_CADDYFILE_LIVE.write_text(rendered)
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.
Newly-linked `.timer` units are additionally `systemctl enable`d *and
started* so that a self-update introducing a timer (e.g. 26.5 → 26.6
adding furtka-catalog-sync.timer, 26.19 → 26.20 adding
furtka-welcome.timer) activates it right away — the installer's enable
list only applies to fresh installs, and `enable` alone only takes
effect at the next boot (the bench sat on a dead welcome timer after the
26.20 update).
Units are linked through the `/opt/furtka/current` symlink, not the
versioned directory that `unit_dir` scans, so the link keeps following
later updates and rollbacks the same way the installer-created links do.
"""
into /etc/systemd/system/. Returns the list of newly-linked unit names."""
if not unit_dir.is_dir():
return []
linked = []
@ -263,10 +244,7 @@ def _link_new_units(unit_dir: Path) -> list[str]:
target = _SYSTEMD_DIR / unit_file.name
if target.exists() or target.is_symlink():
continue
_run(["systemctl", "link", str(current_symlink() / "assets" / "systemd" / unit_file.name)])
if unit_file.suffix == ".timer":
_run(["systemctl", "enable", unit_file.name])
_run(["systemctl", "start", unit_file.name])
_run(["systemctl", "link", str(unit_file)])
linked.append(unit_file.name)
return linked
@ -281,10 +259,9 @@ def write_state(stage: str, **extra) -> None:
def read_state() -> dict:
try:
data = json.loads(state_path().read_text())
except (OSError, UnicodeDecodeError, json.JSONDecodeError):
return json.loads(state_path().read_text())
except (FileNotFoundError, json.JSONDecodeError):
return {}
return data if isinstance(data, dict) else {}
def acquire_lock():
@ -308,56 +285,18 @@ def _run(cmd: list[str]) -> None:
def _health_check(url: str, deadline_s: float = 30.0) -> bool:
"""Poll *url* until we get *any* response from the Python server.
Treats any 2xx-4xx response as "server is up". A 401 on
/api/apps after the 26.11-alpha auth-guard shipped is a perfectly
valid signal that the new code imported + the socket is listening
— rejecting the request is still "alive". Only 5xx or connection-
level failures count as unhealthy.
Rationale: pre-26.13 this function hit /api/apps and expected 200,
which silently broke every upgrade across the auth boundary (26.10
→ 26.11+) and auto-rolled back. Now we just need proof the new
process came up.
"""
end = time.time() + deadline_s
while time.time() < end:
try:
with urllib.request.urlopen(url, timeout=3) as resp:
# Any 2xx/3xx → alive. urllib follows redirects by
# default, so a 302 → /login resolves to /login's 200.
if resp.status < 500:
if resp.status == 200:
return True
except urllib.error.HTTPError as e:
# 4xx → server is up, just refused us (auth, bad request,
# whatever). Counts as healthy for the "did it come back"
# check. 5xx → genuinely broken, don't accept.
if 400 <= e.code < 500:
return True
except (OSError, http.client.HTTPException):
# Connection refused / reset / timeout (URLError is an OSError;
# a read timeout or RemoteDisconnected is not wrapped by urllib)
# → not up yet, retry.
except urllib.error.URLError:
pass
time.sleep(1)
return False
def _swap_symlink(link: Path, target: str | Path) -> None:
"""Point `link` at `target` atomically.
unlink() + symlink_to() leaves a window with no /opt/furtka/current at
all — a power cut there boots a box with no services. rename() over the
old link is atomic.
"""
tmp = link.with_name(link.name + ".new")
if tmp.is_symlink() or tmp.exists():
tmp.unlink()
tmp.symlink_to(target)
os.replace(tmp, link)
def prepare_update(check: UpdateCheck, download_dir: Path | None = None) -> tuple[Path, str]:
"""Download + verify the tarball. Returns (tarball_path, version)."""
if not check.tarball_url or not check.sha256_url:
@ -402,11 +341,15 @@ def apply_update(tarball: Path, version: str) -> None:
target.chmod(0o755)
write_state("swapping", latest=version)
previous = os.readlink(current) if current.is_symlink() else None
old_caddyfile = _CADDYFILE_LIVE.read_text() if _CADDYFILE_LIVE.is_file() else None
previous = None
if current.is_symlink():
previous = os.readlink(current)
current.unlink()
try:
_swap_symlink(current, target)
current.symlink_to(target)
except OSError as e:
if previous:
current.symlink_to(previous)
raise UpdateError(f"symlink swap failed: {e}") from e
write_state("restarting", latest=version)
@ -422,41 +365,27 @@ def apply_update(tarball: Path, version: str) -> None:
_run(["systemctl", "daemon-reload"])
_run(["systemctl", "try-restart", "furtka-reconcile.service"])
_run(["systemctl", "restart", "furtka-api.service"])
except (UpdateError, OSError) as e:
# OSError: e.g. disk full or EACCES writing the Caddyfile — the new
# version is already live, so this must roll back too.
_rollback(previous, version, f"service restart failed: {e}", old_caddyfile)
raise UpdateError(f"service restart failed: {e} — rolled back") from e
except UpdateError as e:
_rollback(previous, version, f"service restart failed: {e}")
raise
write_state("verifying", latest=version)
ok = _health_check("http://127.0.0.1:7000/api/apps", deadline_s=30.0)
if not ok:
_rollback(previous, version, "health check failed after restart", old_caddyfile)
_rollback(previous, version, "health check failed after restart")
raise UpdateError("new version failed health check — rolled back")
write_state("done", version=version)
def _rollback(
previous_target: str | None,
failed_version: str,
reason: str,
old_caddyfile: str | None = None,
) -> None:
def _rollback(previous_target: str | None, failed_version: str, reason: str) -> None:
current = current_symlink()
if previous_target:
_swap_symlink(current, previous_target)
# The new release may have shipped Caddy routes the old code
# doesn't serve; put the previous Caddyfile back before reloading.
if old_caddyfile is not None:
try:
_CADDYFILE_LIVE.write_text(old_caddyfile)
except OSError:
pass
if current.is_symlink():
current.unlink()
current.symlink_to(previous_target)
# Best-effort restart on the previous target — if it fails too the
# box is in a hard state, but we can only surface the reason.
subprocess.run(["systemctl", "daemon-reload"], check=False)
subprocess.run(["systemctl", "reload", "caddy"], check=False)
subprocess.run(["systemctl", "restart", "furtka-api.service"], check=False)
write_state(
"rolled_back",
@ -473,7 +402,6 @@ def run_update() -> UpdateCheck:
UpdateError on any failure; the state file records the stage.
"""
with acquire_lock():
try:
check = check_update()
if not check.update_available:
write_state("done", version=check.current, note="already up to date")
@ -481,17 +409,6 @@ def run_update() -> UpdateCheck:
tarball, version = prepare_update(check)
apply_update(tarball, version)
return check
except Exception as e:
# Without a terminal stage the settings page keeps showing
# "Downloading…" forever. Rollback already wrote its own stage.
if read_state().get("stage") != "rolled_back":
try:
write_state("error", reason=str(e) or type(e).__name__)
except OSError:
pass
if isinstance(e, UpdateError):
raise
raise UpdateError(str(e) or type(e).__name__) from e
def rollback() -> str:
@ -511,7 +428,8 @@ def rollback() -> str:
raise UpdateError("no other version slots available to roll back to")
target_name = candidates[0]
target = versions_dir() / target_name
_swap_symlink(current, target)
current.unlink()
current.symlink_to(target)
subprocess.run(["systemctl", "daemon-reload"], check=False)
subprocess.run(["systemctl", "restart", "furtka-api.service"], check=False)
write_state("rolled_back_manual", restored_to=target_name)

View file

@ -1,160 +0,0 @@
"""WireGuard tunnel to a connected furtka-gateway.
Structural mirror of furtka.https's snippet-write-plus-reload pattern:
write/remove /etc/wireguard/furtka-gw.conf, then flip the wg-quick@furtka-gw
systemd unit, reverting the conf file if that call fails so a bad config
can't leave the tunnel half-configured. One deliberate difference from
https.py: enabling and disabling are genuinely different systemctl
subcommands here (Caddy only ever needs a single `reload` either way), so
the injectable `reload` callable takes the resolved action ("enable" or
"disable") rather than being a fixed zero-argument call.
This module never generates its own keypair or contacts furtka-gateway
directly — furtka.gateway.register() gets the full WireGuard interface
config (including the private key; see furtka-gateway's wgeasy.py for why
that's unavoidable with stock wg-easy) and hands it to
set_tunnel_enabled() here.
"""
import os
import subprocess
from pathlib import Path
DEFAULT_CONF_PATH = Path("/etc/wireguard/furtka-gw.conf")
INTERFACE = "furtka-gw"
def default_conf_path() -> Path:
return Path(os.environ.get("FURTKA_WIREGUARD_CONF", DEFAULT_CONF_PATH))
class WireguardError(Exception):
"""Recoverable failure from set_tunnel_enabled — the caller should 5xx."""
def _conf_content(wg: dict) -> str:
"""Render a wg-quick .conf from a furtka-gateway registration response's
`wg` block. AllowedIPs here governs what THIS box routes *through* the
tunnel — furtka-gateway hands back a split-tunnel value (its own peer
subnet only), which this module passes through verbatim rather than
widening it, so a compromised gateway can't pull the box's other
traffic through it.
`preshared_key` is only present when the gateway's wg-easy is v15
(which generates one per client by default and enforces it
server-side) — omitted (None/missing) for v14, which has none.
Dropping a present preshared key here would break the handshake
silently, since the server side would still expect it.
"""
preshared_key_line = ""
if wg.get("preshared_key"):
preshared_key_line = f"PresharedKey = {wg['preshared_key']}\n"
return (
"[Interface]\n"
f"PrivateKey = {wg['private_key']}\n"
f"Address = {wg['address']}\n"
"\n"
"[Peer]\n"
f"PublicKey = {wg['server_public_key']}\n"
f"{preshared_key_line}"
f"Endpoint = {wg['endpoint']}\n"
f"AllowedIPs = {wg['allowed_ips']}\n"
"PersistentKeepalive = 25\n"
)
def _default_reload(action: str) -> None:
subprocess.run(
["systemctl", action, "--now", f"wg-quick@{INTERFACE}"],
check=True,
capture_output=True,
text=True,
)
def set_tunnel_enabled(
enabled: bool,
wg: dict | None = None,
conf_path: Path | None = None,
reload=_default_reload,
) -> bool:
"""Bring the tunnel up (writing conf_path from `wg`) or down.
`wg` is required when enabling ({private_key, address, server_public_key,
endpoint, allowed_ips} — furtka.gateway.register()'s return shape),
ignored when disabling. Reverts conf_path to its pre-call state if the
systemctl action fails, so a bad config can't leave the tunnel wedged.
"""
if enabled and wg is None:
raise ValueError("wg config is required to enable the tunnel")
if conf_path is None:
conf_path = default_conf_path()
try:
conf_path.parent.mkdir(mode=0o700, parents=True, exist_ok=True)
had_conf = conf_path.is_file()
previous = conf_path.read_text() if had_conf else None
if enabled:
conf_path.write_text(_conf_content(wg))
conf_path.chmod(0o600)
elif had_conf:
conf_path.unlink()
except OSError as e:
raise WireguardError(f"could not access {conf_path}: {e}") from e
try:
reload("enable" if enabled else "disable")
except subprocess.CalledProcessError as e:
_revert(conf_path, previous)
msg = (e.stderr or e.stdout or "").strip() or f"exit {e.returncode}"
raise WireguardError(f"systemctl failed: {msg}") from e
except FileNotFoundError as e:
_revert(conf_path, previous)
raise WireguardError(f"systemctl not available: {e}") from e
return enabled
def _revert(conf_path: Path, previous: str | None) -> None:
if previous is None:
try:
conf_path.unlink()
except FileNotFoundError:
pass
else:
conf_path.write_text(previous)
def _default_wg_show() -> str:
result = subprocess.run(
["wg", "show", INTERFACE, "latest-handshakes"],
check=True,
capture_output=True,
text=True,
)
return result.stdout.strip()
def status(conf_path: Path | None = None, wg_show=_default_wg_show) -> dict:
"""Whether the tunnel is configured, and its latest handshake if `wg`
tooling is available (None if not — e.g. wg-tools missing, or the
interface isn't actually up yet right after enabling).
Read-only and best-effort: any OSError stat-ing conf_path (missing
parent dir, an unexpected permission situation) is treated the same as
"not configured" rather than raised — a status check must never 500
the whole request over something this innocuous.
"""
if conf_path is None:
conf_path = default_conf_path()
try:
configured = conf_path.is_file()
except OSError:
configured = False
latest_handshake = None
if configured:
try:
latest_handshake = wg_show()
except (subprocess.CalledProcessError, FileNotFoundError):
latest_handshake = None
return {"configured": configured, "latest_handshake": latest_handshake}

View file

@ -21,31 +21,15 @@ The script re-execs itself inside a privileged `archlinux:latest` container. Tha
The build starts from Arch's stock `releng` profile (the same one used to build the official Arch ISO), then overlays our customizations from `overlay/`:
| Overlay file | Effect |
|----------------------------------------------|----------------------------------------------------------------------------------|
|-------------------------------------------|----------------------------------------------------------------------------------|
| `overlay/packages.extra` | Appended to the package list. Adds `python`, `python-flask`, `avahi`, `nss-mdns` |
| `overlay/profiledef.sh` | Appended to `profiledef.sh`. Renames the ISO to `furtka-*` with a dated version |
| `overlay/airootfs/opt/furtka/` | Directory where `webinstaller/` is copied at build time |
| `overlay/airootfs/etc/hostname` | Live-ISO hostname (`proksi`) so mDNS advertises the installer as `proksi.local` |
| `overlay/airootfs/etc/issue` | Welcome banner on the TTY pointing users at `http://proksi.local:5000` |
| `overlay/airootfs/usr/local/bin/furtka-update-issue` | Rewrites `/etc/issue` at runtime so the banner always carries a fallback line: the DHCP-assigned IP(s), or a "no IP yet — check cable / DHCP" hint until one arrives. Only touches the file (and `agetty --reload`s) when the text changed |
| `overlay/airootfs/etc/systemd/system/` | `furtka-webinstaller.service` (Flask on :5000) + `furtka-issue.service` (banner-updater, first run at boot) in `multi-user.target.wants/`, and `furtka-issue.timer` (re-runs the updater every 5 s) in `timers.target.wants/` |
| `overlay/airootfs/etc/systemd/system/` | Contains `furtka-webinstaller.service` + a symlink into `multi-user.target.wants/` so it auto-starts on boot |
The systemd service runs `flask --app app run --host 0.0.0.0 --port 5000` under `/opt/furtka`. The `0.0.0.0` binding is important — the Flask default is localhost-only, which wouldn't be reachable from another machine on the LAN.
mDNS is wired: `avahi-daemon` + `nss-mdns` come from `packages.extra`, the live ISO's hostname is `proksi`, and as soon as `systemd-networkd-wait-online` fires the installer is reachable at `http://proksi.local:5000`. The raw IP still shows on the console for fallback — some Windows clients need the Bonjour service for `.local` to resolve at all.
### archinstall is pinned
The webinstaller imports archinstall internals and `furtka-webinstaller.service` sed-patches one of its files, so `build.sh` does not take whatever archinstall the Arch repos currently ship. It downloads the version in `ARCHINSTALL_PIN` from `archive.archlinux.org`, checks it against `ARCHINSTALL_SHA256`, and serves it from a local `[furtka-pinned]` repo listed before `[core]` in the build's `pacman.conf` (pacman takes a package from the first repo that has it). Only the build uses that repo; the live system's own `pacman.conf` is untouched.
The build fails with "pinned archinstall … targets Python X, Arch now ships Y" once Arch moves to a new Python — the pinned package would install into a dead `site-packages`. To bump:
1. Pick the new version from <https://archive.archlinux.org/packages/a/archinstall/>.
2. `curl -LO` the `.pkg.tar.zst`, `sha256sum` it, and update `ARCHINSTALL_PIN` + `ARCHINSTALL_SHA256` at the top of `build.sh`.
3. Check that `webinstaller/app.py:build_disk_config` still imports and that the `ExecStartPre` sed in `furtka-webinstaller.service` still matches (`journalctl -u furtka-webinstaller` on the live ISO).
4. Full VM install (UEFI and BIOS) before merging.
`build.sh` also deletes releng's `getty@tty1.service.d/autologin.conf`. With root auto-logged-in, tty1 belongs to a shell and `agetty --reload` can't redraw the banner, so the IP line was a snapshot of whatever DHCP had done by the time getty started. Without autologin tty1 sits at the login prompt showing the live banner; for a shell type `root` + Enter (releng's root has no password) — the banner says so.
mDNS (`proksi.local`) via avahi is installed but not yet wired. First milestone is just "boot → browser → wizard at raw IP". Naming comes next.
## Test flow
@ -62,33 +46,12 @@ The build fails with "pinned archinstall … targets Python X, Arch now ships Y"
5. Find its IP in Proxmox's VM summary (or your router's DHCP table)
6. Open `http://<vm-ip>:5000` — the existing 3-screen wizard should be there
## Real hardware (what differs from the VM flow)
Learned on the first bare-metal run, see [docs/hardware-test-2026-08.md](../docs/hardware-test-2026-08.md):
- **BIOS boxes work.** The hybrid ISO boots via syslinux on legacy firmware
(you'll see the stock Arch splash — only the menu labels are branded), and
the installer detects the missing `/sys/firmware/efi` and picks GRUB + MBR.
- **Pull the USB stick before "restart now".** Most BIOSes boot USB before
the disk; the done page says so above the button since 26.20-alpha.
- **Disks with a previous OS are fine since 26.20-alpha.** The live ISO
auto-activates old LVM/RAID/swap; `webinstaller/diskprep.py` releases the
disk before archinstall partitions it. On 26.19 and earlier you'd hit
`unable to inform the kernel of the change … in use` at 12 % — clear it
from the tty1 root shell with `dmsetup remove <node>` for every dm node
shown by `lsblk`, then `wipefs -a /dev/sdX` and retry.
- **Slow NICs.** The console banner's IP fallback line is timer-driven
since 26.20-alpha; on 26.19 it only appeared if DHCP beat getty.
- **Shell on the live ISO**: since 26.20-alpha tty1 stays at the login
prompt (banner refreshes live); log in as `root`, no password. sshd runs;
set a root password with `passwd` to reach it over the network.
## What you see after install + reboot
Once `archinstall` finishes and you click **Reboot now**, the VM comes up into the installed system. No more port `:5000` — the wizard ISO is gone. Instead:
- **Console**: agetty shows `Furtka is ready. Open http://<hostname>.local …` with the IP fallback underneath.
- **Browser** at `http://<hostname>.local` (default `http://furtka.local` — the form's default hostname is `furtka`; only the live-installer ISO uses `proksi`): Caddy-served landing page with three live status tiles (uptime, Docker version, free disk) refreshed every 30 s by `furtka-status.timer`. HTTPS is opt-in (26.15-alpha) — flip the toggle in `/settings` to switch on Caddy's `tls internal` on `:443`, then trust `rootCA.crt` from `/settings` to clear browser warnings.
- **Browser** at `http://<hostname>.local` (default `http://proksi.local`): Caddy-served landing page with three live status tiles (uptime, Docker version, free disk) refreshed every 30 s by `furtka-status.timer`.
- **SSH**: `ssh <user>@<hostname>.local` works; `docker ps` works without `sudo` because the user is in the `docker` group.
This is a demo shell — no Authentik, no app store yet. The landing page lives at `/srv/furtka/www/`, served by Caddy on `:80` per `/etc/caddy/Caddyfile`. All of this is written into the target by `webinstaller/app.py`'s `_post_install_commands` via archinstall's `custom_commands`.
@ -96,4 +59,5 @@ This is a demo shell — no Authentik, no app store yet. The landing page lives
## Known rough edges
- **Disk space**: the first time you build on a fresh host, the squashfs/xorriso steps need ~15 GB free. If the host's LVM-root is smaller, `xorriso` silently dies at the very end with "Image size exceeds free space on media".
- **Live-installer wizard is still HTTP-only**. `http://proksi.local:5000` during install has no TLS; once the box reboots, Caddy can serve `tls internal` on `:443` if the user opts in via `/settings` (26.15-alpha), but bringing TLS to the wizard itself is a later milestone.
- **No HTTPS yet**. The Furtka plan is "local CA + green padlock on `https://proksi.local`" — that's a later milestone. For now, plain HTTP.
- **Boot USB could appear as an install target on bare metal**. On a VM the ISO is a CD-ROM (filtered) and SATA is the only disk, so the picker only shows the install target. On bare metal with a USB stick, the USB is `TYPE=disk` and shows up alongside the real install drive; a user could in theory pick the USB they just booted from. Mitigating this needs detecting the boot media (via `findmnt /run/archiso/bootmnt` or similar) and filtering it out in `webinstaller/drives.py`.

View file

@ -27,11 +27,7 @@ fi
# ---- inside the container from here on ----
echo "==> Syncing pacman, installing archiso"
pacman -Syu --noconfirm --needed archiso curl zstd
# archinstall build the webinstaller is validated against (see pin step below).
ARCHINSTALL_PIN="4.4-1"
ARCHINSTALL_SHA256="6cb5da75c108ef2f5d25a7a20baf6c52cc927d5e06890dd10182e6eb4be815cb"
pacman -Syu --noconfirm --needed archiso
PROFILE_SRC="/usr/share/archiso/configs/releng"
PROFILE_WORK="/tmp/furtka-profile"
@ -47,52 +43,9 @@ cat "$SCRIPT_DIR/overlay/packages.extra" >> "$PROFILE_WORK/packages.x86_64"
cat "$SCRIPT_DIR/overlay/profiledef.sh" >> "$PROFILE_WORK/profiledef.sh"
echo "==> Pinning archinstall ${ARCHINSTALL_PIN}"
# The webinstaller imports archinstall internals (build_disk_config) and
# sed-patches one of its files at service start, so a silent archinstall
# upgrade in the Arch repos can break every install with no change on our
# side. Serve the validated build from a local repo listed *before* [core]:
# pacman takes a package from the first repo that has it, whatever the
# version elsewhere. Bump deliberately: new version + sha256 below, rebuild,
# run the smoke VM (see iso/README.md).
PINNED_REPO="/tmp/furtka-pinned-repo"
rm -rf "$PINNED_REPO" && mkdir -p "$PINNED_REPO"
ARCHINSTALL_PKG="archinstall-${ARCHINSTALL_PIN}-any.pkg.tar.zst"
curl -fsSL --retry 3 -o "$PINNED_REPO/$ARCHINSTALL_PKG" \
"https://archive.archlinux.org/packages/a/archinstall/$ARCHINSTALL_PKG"
echo "${ARCHINSTALL_SHA256} $PINNED_REPO/$ARCHINSTALL_PKG" | sha256sum -c -
# archinstall is an "any" package installed under /usr/lib/python3.X; after
# Arch moves to a new Python it would install into a dead directory. Fail
# the build with a clear message instead of shipping a broken installer.
pkg_py=$(tar --zstd -tf "$PINNED_REPO/$ARCHINSTALL_PKG" \
| sed -n 's|^usr/lib/python\(3\.[0-9]*\)/.*|\1|p' | sort -u)
repo_py=$(pacman -Si python | awk -F': ' '/^Version/ {split($2, v, "."); print v[1] "." v[2]}')
if [ "$pkg_py" != "$repo_py" ]; then
echo "ERROR: pinned archinstall ${ARCHINSTALL_PIN} targets Python $pkg_py, Arch now ships $repo_py." >&2
echo " Bump ARCHINSTALL_PIN/ARCHINSTALL_SHA256 in iso/build.sh (see iso/README.md)." >&2
exit 1
fi
repo-add -q "$PINNED_REPO/furtka-pinned.db.tar.gz" "$PINNED_REPO/$ARCHINSTALL_PKG"
# The sha256 above already pins the exact bytes.
sed -i "0,/^\[core\]/s||[furtka-pinned]\nSigLevel = Optional TrustAll\nServer = file://$PINNED_REPO\n\n[core]|" \
"$PROFILE_WORK/pacman.conf"
grep -q '^\[furtka-pinned\]' "$PROFILE_WORK/pacman.conf"
cp -a "$SCRIPT_DIR/overlay/airootfs/." "$PROFILE_WORK/airootfs/"
# releng auto-logs root in on tty1. That turns our /etc/issue banner into a
# one-shot snapshot: once agetty has handed tty1 to a shell, `agetty --reload`
# from furtka-update-issue has nothing left to redraw, so the IP fallback only
# shows if DHCP happened to beat getty. Drop the drop-in: tty1 stays at the
# login prompt with a live-refreshed banner (furtka-issue.timer), and a shell
# is one `root` + Enter away (releng's root has no password) — the banner says
# so.
rm -rf "$PROFILE_WORK/airootfs/etc/systemd/system/getty@tty1.service.d"
echo "==> Rebranding boot menu (GRUB + syslinux + systemd-boot)"
# BIOS boot menu background: Furtka lockup instead of the Arch logo
# (generated by website/tools/brand-assets.py, 640x480 as syslinux wants).
cp "$SCRIPT_DIR/overlay/splash.png" "$PROFILE_WORK/syslinux/splash.png"
# releng ships menu entries labelled "Arch Linux install medium" across three
# bootloader configs (BIOS syslinux, GRUB, systemd-boot for UEFI). Rewrite to
# our brand. Done with sed (not a static overlay) so upstream archiso file
@ -125,11 +78,6 @@ cp -a "$REPO_ROOT/webinstaller/." "$PROFILE_WORK/airootfs/opt/furtka/"
# next to webinstaller/app.py so _resolve_assets_dir() finds it at runtime.
cp -a "$REPO_ROOT/assets" "$PROFILE_WORK/airootfs/opt/furtka/assets"
rm -rf "$PROFILE_WORK/airootfs/opt/furtka/__pycache__"
# VERSION next to the webinstaller so the wizard footer can render the
# release string at runtime instead of carrying a hardcoded one. Matches
# what the resource-manager payload ships in its own VERSION file below.
ISO_VERSION=$(grep -E '^version = ' "$REPO_ROOT/pyproject.toml" | head -1 | sed 's/.*= "\(.*\)"/\1/')
echo "$ISO_VERSION" > "$PROFILE_WORK/airootfs/opt/furtka/VERSION"
# Pack the resource manager (furtka/ Python package + bundled apps/) as a
# tarball that webinstaller hands to archinstall via custom_commands. Lives at
@ -146,9 +94,9 @@ cp -a "$REPO_ROOT/apps" "$PAYLOAD_STAGE/"
cp -a "$REPO_ROOT/assets" "$PAYLOAD_STAGE/"
find "$PAYLOAD_STAGE" -type d -name __pycache__ -exec rm -rf {} +
# VERSION at tarball root: the installer reads it to choose the versions/<ver>/
# directory name and /opt/furtka/current/VERSION reports it at runtime. Same
# value we wrote into /opt/furtka/VERSION for the live wizard footer above.
echo "$ISO_VERSION" > "$PAYLOAD_STAGE/VERSION"
# directory name and /opt/furtka/current/VERSION reports it at runtime.
grep -E '^version = ' "$REPO_ROOT/pyproject.toml" | head -1 \
| sed 's/.*= "\(.*\)"/\1/' > "$PAYLOAD_STAGE/VERSION"
tar -czf "$PROFILE_WORK/airootfs/opt/furtka-resource-manager.tar.gz" \
-C "$PAYLOAD_STAGE" .
rm -rf "$PAYLOAD_STAGE"

View file

@ -2,6 +2,5 @@
Furtka Live Installer starting…
Once ready, open http://proksi.local:5000 on another device
on your network. The exact URL (and an IP fallback) will
appear here in a few seconds.
on your network. The exact URL will appear below.

View file

@ -1,12 +1,12 @@
[Unit]
Description=Write Furtka /etc/issue with current IP for the console welcome
# No network-online dependency on purpose: the first run should paint the
# banner (with a "no IP yet" fallback) as early as possible; the timer
# re-runs it until an address shows up.
After=network-online.target
Wants=network-online.target
[Service]
Type=oneshot
ExecStart=/usr/local/bin/furtka-update-issue
RemainAfterExit=yes
[Install]
WantedBy=multi-user.target

View file

@ -1,10 +0,0 @@
[Unit]
Description=Keep the Furtka console welcome (IP fallback) up to date
[Timer]
OnBootSec=3s
OnUnitActiveSec=5s
AccuracySec=1s
[Install]
WantedBy=timers.target

View file

@ -2,53 +2,22 @@
# Regenerates /etc/issue so the live-ISO console tells the user which URL
# to open in their browser. Shows proksi.local (via avahi/mDNS) as the
# preferred URL and the raw IP as a fallback for networks where mDNS
# doesn't work.
#
# Runs at boot and then every few seconds from furtka-issue.timer, so the
# fallback line is *always* on screen: with the address once DHCP has
# handed one out, or with a "no IP yet" hint before that / when the cable
# is unplugged. Only rewrites + nudges agetty when the text actually
# changed, so the login prompt doesn't flicker on every tick.
set -u
# doesn't work. Reload at the end nudges agetty to redraw.
set -e
issue=/etc/issue
ip=$(ip -4 -o addr show scope global 2>/dev/null | awk '{print $4}' | cut -d/ -f1 | head -1)
mapfile -t ips < <(ip -4 -o addr show scope global 2>/dev/null | awk '{print $4}' | cut -d/ -f1)
new=$(
# Furtka mark (docs/brand/console-banner.txt, from brand-assets.py)
echo ' ▄▄▄▄▄▄▄▄'
echo ' ▄██████████▄'
echo ' █████▀▀▀▀▀▀███'
echo ' ████▀ ████'
echo ' ████ █████████'
echo ' ████ ████'
echo ' ████ ▄▄▄▄▄████'
echo ' ████ █████████'
echo ' ████ █████████'
echo ' ▀▀▀▀ ▀▀▀▀▀▀▀▀▀'
{
echo
echo " Open Furtka in a browser on another device on your network:"
echo
echo " http://proksi.local:5000 (easy — try this first)"
if ((${#ips[@]})); then
for ip in "${ips[@]}"; do
if [ -n "$ip" ]; then
echo " http://${ip}:5000 (fallback if the first doesn't work)"
done
else
echo " (fallback: no IP address yet — waiting for the network."
echo " Check the cable / DHCP. This line updates by itself.)"
fi
echo
echo " Then follow the wizard to install Furtka on this machine."
echo " The install downloads packages, so this machine needs a network"
echo " cable with internet access. Wi-Fi is not set up automatically."
echo
echo " Need a shell? Log in as root (no password)."
echo
)
} > /etc/issue
if [ "$new" != "$(cat "$issue" 2>/dev/null)" ]; then
printf '%s\n' "$new" > "$issue"
agetty --reload 2>/dev/null || true
fi

Binary file not shown.

Before

Width:  |  Height:  |  Size: 4.3 KiB

View file

@ -8,43 +8,10 @@ server {
charset utf-8;
gzip on;
gzip_vary on;
gzip_min_length 1024;
gzip_comp_level 6;
gzip_types
text/css
text/plain
text/xml
application/javascript
application/json
application/xml
application/rss+xml
application/atom+xml
image/svg+xml
font/woff
font/woff2;
location / {
try_files $uri $uri/ $uri.html =404;
}
# Poll counter (ops/poll/pollsvc.py) — cookie-free, rate-limited.
location /api/poll/ {
# Public traffic arrives from the edge proxy via 192.168.178.164.
# Only that hop is trusted when unwinding X-Forwarded-For, so a
# client-supplied X-Forwarded-For cannot forge a fresh "device".
set_real_ip_from 192.168.178.164;
real_ip_header X-Forwarded-For;
real_ip_recursive on;
limit_req zone=poll burst=10 nodelay;
proxy_pass http://127.0.0.1:8090/;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header User-Agent $http_user_agent;
proxy_read_timeout 5s;
}
location = /favicon.svg {
access_log off;
log_not_found off;

View file

@ -1,2 +0,0 @@
# Rate limit for the poll API (http context; included from conf.d).
limit_req_zone $binary_remote_addr zone=poll:1m rate=30r/m;

View file

@ -16,7 +16,6 @@ install -d -o "$OWNER" -g "$OWNER" -m 0755 "$WEBROOT"
install -d -o "$OWNER" -g "$OWNER" -m 0755 "$SRCROOT"
cp "$(dirname "$0")/furtka.org.conf" "$SITE_CONF"
cp "$(dirname "$0")/poll-ratelimit.conf" /etc/nginx/conf.d/poll-ratelimit.conf
ln -sfn "$SITE_CONF" "$SITE_LINK"
# Drop the Ubuntu default site so it doesn't shadow us on :80.

View file

@ -1,17 +0,0 @@
[Unit]
Description=furtka.org poll counter
After=network.target
[Service]
ExecStart=/usr/bin/python3 /opt/furtka-poll/pollsvc.py
DynamicUser=yes
StateDirectory=furtka-poll
Restart=on-failure
RestartSec=3
NoNewPrivileges=yes
ProtectSystem=strict
ProtectHome=yes
PrivateTmp=yes
[Install]
WantedBy=multi-user.target

View file

@ -1,172 +0,0 @@
#!/usr/bin/env python3
"""Tiny cookie-free poll counter for furtka.org.
Runs behind nginx on 127.0.0.1:8090. One SQLite file, no dependencies
beyond the standard library.
GET /<poll> -> {"poll": "logo", "counts": {"m": 3, "r": 5}, "total": 8}
POST /<poll> -> body {"choice": "m"}; answers like GET plus "yours"
A device is identified by a salted SHA-256 of client IP (as resolved by
nginx from the trusted edge proxy) + User-Agent, so nobody needs a cookie
and the raw address is never stored. Voting again
from the same device changes the vote instead of adding one.
"""
import hashlib
import json
import logging
import os
import sqlite3
import sys
import threading
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
POLLS = {
"logo": ("m", "r"),
}
STATE_DIR = os.environ.get("STATE_DIRECTORY", "/var/lib/furtka-poll")
DB_PATH = os.path.join(STATE_DIR, "votes.db")
SALT_PATH = os.path.join(STATE_DIR, "salt")
LISTEN = os.environ.get("POLL_LISTEN", "127.0.0.1")
PORT = int(os.environ.get("POLL_PORT", "8090"))
MAX_BODY = 512
log = logging.getLogger("pollsvc")
_lock = threading.Lock()
def _salt() -> bytes:
try:
with open(SALT_PATH, "rb") as f:
s = f.read()
if len(s) >= 16:
return s
except FileNotFoundError:
pass
s = os.urandom(32)
with open(SALT_PATH, "wb") as f:
f.write(s)
os.chmod(SALT_PATH, 0o600)
return s
def _db() -> sqlite3.Connection:
conn = sqlite3.connect(DB_PATH, check_same_thread=False)
conn.execute(
"CREATE TABLE IF NOT EXISTS votes ("
" poll TEXT NOT NULL, device TEXT NOT NULL, choice TEXT NOT NULL,"
" ts INTEGER NOT NULL DEFAULT (unixepoch()),"
" PRIMARY KEY (poll, device))"
)
conn.commit()
return conn
SALT = None
CONN = None
def counts(poll: str) -> dict:
with _lock:
rows = CONN.execute(
"SELECT choice, COUNT(*) FROM votes WHERE poll=? GROUP BY choice", (poll,)
).fetchall()
c = {k: 0 for k in POLLS[poll]}
for choice, n in rows:
if choice in c:
c[choice] = n
return {"poll": poll, "counts": c, "total": sum(c.values())}
class Handler(BaseHTTPRequestHandler):
server_version = "furtka-poll/1"
def log_message(self, fmt, *args): # quiet; nginx has the access log
log.debug(fmt, *args)
def _client_ip(self) -> str:
# nginx resolves the trusted proxy chain (real_ip module, see
# ops/nginx/furtka.org.conf) and hands us the result as X-Real-IP.
# We never parse X-Forwarded-For here: its leading entries are
# whatever the client chose to send.
return self.headers.get("X-Real-IP") or self.client_address[0]
def _device(self) -> str:
ua = self.headers.get("User-Agent", "")
h = hashlib.sha256(SALT + self._client_ip().encode() + b"|" + ua.encode())
return h.hexdigest()[:32]
def _json(self, status: int, payload: dict):
body = json.dumps(payload).encode()
self.send_response(status)
self.send_header("Content-Type", "application/json; charset=utf-8")
self.send_header("Content-Length", str(len(body)))
self.send_header("Cache-Control", "no-store")
self.end_headers()
self.wfile.write(body)
def _poll(self):
name = self.path.strip("/").split("?")[0]
if name not in POLLS:
self._json(404, {"error": "no such poll"})
return None
return name
def do_GET(self):
poll = self._poll()
if not poll:
return
payload = counts(poll)
with _lock:
row = CONN.execute(
"SELECT choice FROM votes WHERE poll=? AND device=?", (poll, self._device())
).fetchone()
payload["yours"] = row[0] if row else None
self._json(200, payload)
def do_POST(self):
poll = self._poll()
if not poll:
return
try:
n = int(self.headers.get("Content-Length", "0"))
if n > MAX_BODY:
raise ValueError("body too large")
data = json.loads(self.rfile.read(n) or b"{}")
choice = str(data.get("choice", ""))
except (ValueError, json.JSONDecodeError):
self._json(400, {"error": "bad request"})
return
if choice not in POLLS[poll]:
self._json(400, {"error": "unknown choice"})
return
with _lock:
CONN.execute(
"INSERT INTO votes (poll, device, choice) VALUES (?,?,?)"
" ON CONFLICT(poll, device) DO UPDATE SET choice=excluded.choice, ts=unixepoch()",
(poll, self._device(), choice),
)
CONN.commit()
payload = counts(poll)
payload["yours"] = choice
self._json(200, payload)
def main():
global SALT, CONN
logging.basicConfig(
level=os.environ.get("POLL_LOGLEVEL", "INFO"),
stream=sys.stderr,
format="%(levelname)s %(message)s",
)
os.makedirs(STATE_DIR, exist_ok=True)
SALT = _salt()
CONN = _db()
srv = ThreadingHTTPServer((LISTEN, PORT), Handler)
log.info("listening on %s:%d, db %s", LISTEN, PORT, DB_PATH)
srv.serve_forever()
if __name__ == "__main__":
main()

View file

@ -1,26 +0,0 @@
#!/usr/bin/env bash
# Install / update the poll counter on forge-runner-01. Idempotent.
#
# Usage (on the VM, with sudo):
# sudo ops/poll/setup-poll.sh # install / update
# sudo ops/poll/setup-poll.sh --reset # ... and wipe all votes
set -euo pipefail
HERE="$(cd "$(dirname "$0")" && pwd)"
if [ "${1:-}" = "--reset" ]; then
systemctl stop furtka-poll 2>/dev/null || true
rm -f /var/lib/furtka-poll/votes.db
echo "votes wiped"
fi
install -d -m 0755 /opt/furtka-poll
install -m 0644 "$HERE/pollsvc.py" /opt/furtka-poll/pollsvc.py
install -m 0644 "$HERE/furtka-poll.service" /etc/systemd/system/furtka-poll.service
systemctl daemon-reload
systemctl enable --now furtka-poll
systemctl restart furtka-poll
sleep 1
systemctl --no-pager --lines=3 status furtka-poll
curl -fsS http://127.0.0.1:8090/logo && echo
echo "OK: furtka-poll running on 127.0.0.1:8090"

View file

@ -1,6 +1,6 @@
[project]
name = "furtka"
version = "26.21-alpha"
version = "26.4-alpha"
description = "Open-source home server OS — simple enough for everyone."
requires-python = ">=3.11"
readme = "README.md"

View file

@ -99,29 +99,4 @@ upload_asset "$TARBALL"
upload_asset "$SHA_FILE"
upload_asset "$RELEASE_JSON"
# Optional: attach the live-installer ISO when dist/furtka-<version>.iso
# exists. Release workflows that want this build the ISO via iso/build.sh
# and move the output here before calling publish-release. Local runs
# that skip the ISO step still publish the core release successfully.
#
# Soft-fail: the ISO is ~1 GB and Forgejo's reverse proxy has returned
# 504 on the upload even when the write eventually succeeds. The core
# tarball (which boxes need for self-update) is already uploaded above,
# so don't let an ISO transport hiccup fail the whole release.
ISO="$DIST_DIR/furtka-$VERSION.iso"
if [ -f "$ISO" ]; then
if ! upload_asset "$ISO"; then
echo "warning: ISO upload failed — release published without ISO asset" >&2
fi
# Checksum sidecar for the download page on furtka.org (and anyone
# verifying by hand). Uploaded even when the ISO upload reported a
# failure — a 504 there often still lands the file. Written from inside
# dist/ so the line carries the bare filename `sha256sum -c` expects.
ISO_SHA="$ISO.sha256"
(cd "$DIST_DIR" && sha256sum "$(basename "$ISO")" > "$(basename "$ISO_SHA")")
if ! upload_asset "$ISO_SHA"; then
echo "warning: ISO checksum upload failed" >&2
fi
fi
echo "Release $VERSION published: https://$HOST/$REPO/releases/tag/$VERSION"

View file

@ -50,22 +50,9 @@ SHORT_SHA="${SHA:0:12}"
API="https://${PVE_TEST_HOST}:8006/api2/json"
api() {
# Wrapper so that on non-2xx we print the PVE response body to stderr
# before bubbling the failure — otherwise `--fail-with-body` output
# gets swallowed by callers that pipe to /dev/null, and you're left
# staring at "curl: (22)" with no idea which permission is missing.
local body rc
body=$(curl --silent --show-error --fail-with-body -k \
curl --silent --show-error --fail-with-body -k \
--header "Authorization: PVEAPIToken=${PVE_TEST_TOKEN}" \
"$@" 2>&1)
rc=$?
if [[ $rc -ne 0 ]]; then
echo "!! PVE API call failed (rc=$rc)" >&2
echo "!! request: $*" >&2
[[ -n "$body" ]] && echo "!! response: $body" >&2
return $rc
fi
printf '%s' "$body"
"$@"
}
# PVE returns {"data": <payload>}; grab .data into a python expression.
@ -153,19 +140,14 @@ MAC_LOWER="$(echo "$MAC" | tr 'A-Z' 'a-z')"
IP=""
deadline=$((SECONDS + 150))
while (( SECONDS < deadline )); do
# Capture-then-parse instead of piping directly into awk. `awk '... exit'`
# exits on first match, which SIGPIPEs the upstream arp-scan (exit 141).
# With `set -o pipefail` active that kills the whole script — exactly what
# happened the first time host-networking gave arp-scan real matches.
SCAN=""
if command -v arp-scan >/dev/null 2>&1; then
SCAN="$(sudo arp-scan --localnet --quiet --ignoredups 2>/dev/null || true)"
IP="$(awk -v m="$MAC_LOWER" 'tolower($2) == m { print $1; exit }' <<<"$SCAN")"
IP="$(sudo arp-scan --localnet --quiet --ignoredups 2>/dev/null \
| awk -v m="$MAC_LOWER" 'tolower($2) == m { print $1; exit }')"
fi
if [[ -z "$IP" ]] && command -v nmap >/dev/null 2>&1; then
sudo nmap -sn -T4 192.168.178.0/24 >/dev/null 2>&1 || true
NEIGH="$(ip neigh show)"
IP="$(awk -v m="$MAC_LOWER" 'tolower($5) == m && $1 ~ /^[0-9]/ { print $1; exit }' <<<"$NEIGH")"
IP="$(ip neigh show \
| awk -v m="$MAC_LOWER" 'tolower($5) == m && $1 ~ /^[0-9]/ { print $1; exit }')"
fi
[[ -n "$IP" ]] && break
sleep 5

File diff suppressed because it is too large Load diff

View file

@ -87,7 +87,7 @@ def test_build_archinstall_config_includes_post_install_bootstrap(monkeypatch, t
}
)
for pkg in ("caddy", "avahi", "nss-mdns", "wireguard-tools"):
for pkg in ("caddy", "avahi", "nss-mdns"):
assert pkg in cfg["packages"]
# Packaged units go in `services` (enabled before custom_commands runs);
# our own units don't exist at that point, so they must be enabled from
@ -195,211 +195,3 @@ def test_build_archinstall_creds_uses_archinstall_sentinel_keys():
"groups": [],
}
]
def test_log_page_done_state_shows_usb_step_before_restart(monkeypatch, tmp_path):
"""The 'pull the USB stick' step must be visible *before* the restart
button — after the click the box reboots within seconds (first hardware
bench booted the stick again)."""
import app as app_module
log_path = tmp_path / "install.log"
log_path.write_text("Installation completed without any errors.\n")
monkeypatch.setattr(app_module, "INSTALL_LOG", log_path)
app_module.settings["hostname"] = "bench"
html = app_module.app.test_client().get("/install/log").get_data(as_text=True)
assert '<ol class="next-steps">' in html
assert "http://bench.local" in html
assert "USB stick is out" in html
assert html.index("next-steps") < html.index("USB stick is out")
def test_log_page_running_state_has_no_restart_button(monkeypatch, tmp_path):
import app as app_module
log_path = tmp_path / "install.log"
log_path.write_text("Creating partitions: /dev/sda\n")
monkeypatch.setattr(app_module, "INSTALL_LOG", log_path)
html = app_module.app.test_client().get("/install/log").get_data(as_text=True)
assert "next-steps" not in html
assert "install_reboot" not in html and "restart now" not in html
def test_nonzero_exit_without_traceback_is_an_error():
import app as app_module
# OOM kill / signal: archinstall dies mid-pacstrap and prints nothing.
log = "Installing packages: ['base'\n"
assert app_module.parse_install_progress(log)["status"] == "running"
assert app_module.parse_install_progress(log, exit_code=-9)["status"] == "error"
assert app_module.parse_install_progress(log, exit_code=0)["status"] == "running"
class _FakeProc:
def __init__(self, code=None):
self.code = code
def poll(self):
return self.code
def _install_env(monkeypatch, tmp_path):
import app as app_module
monkeypatch.setattr(app_module, "STATE_DIR", tmp_path)
monkeypatch.setattr(app_module, "INSTALL_LOG", tmp_path / "install.log")
monkeypatch.setattr(app_module, "_install_proc", None)
monkeypatch.delenv("FURTKA_DRY_RUN", raising=False)
monkeypatch.setitem(app_module.settings, "boot_drive", "/dev/sda")
monkeypatch.setitem(app_module.settings, "username", "u")
monkeypatch.setitem(app_module.settings, "password", "pw12345678")
monkeypatch.setattr(app_module, "release_disk", lambda d: [f"released {d}"])
monkeypatch.setattr(app_module, "build_disk_config", lambda d: {"device": d})
return app_module
def test_install_run_refuses_second_concurrent_install(monkeypatch, tmp_path):
app_module = _install_env(monkeypatch, tmp_path)
spawned = []
monkeypatch.setattr(
app_module, "spawn_archinstall", lambda *a: spawned.append(a) or _FakeProc()
)
client = app_module.app.test_client()
assert client.post("/install/run").status_code == 302
assert client.post("/install/run").status_code == 302
assert len(spawned) == 1
def test_install_run_disk_probe_failure_lands_in_log(monkeypatch, tmp_path):
app_module = _install_env(monkeypatch, tmp_path)
def boom(_):
raise RuntimeError("parted could not read /dev/sda")
monkeypatch.setattr(app_module, "build_disk_config", boom)
resp = app_module.app.test_client().post("/install/run")
assert resp.status_code == 302
log = (tmp_path / "install.log").read_text()
assert "Traceback (most recent call last)" in log
assert app_module.parse_install_progress(log)["status"] == "error"
def test_install_log_json_reports_crashed_archinstall(monkeypatch, tmp_path):
app_module = _install_env(monkeypatch, tmp_path)
(tmp_path / "install.log").write_text("Installing packages: ['base'\n")
monkeypatch.setattr(app_module, "_install_proc", _FakeProc(code=137))
data = app_module.app.test_client().get("/install/log.json").get_json()
assert data["progress"]["status"] == "error"
assert "exit code 137" in data["log"]
def test_step2_rejects_disk_not_offered(monkeypatch):
import app as app_module
offered = [
{
"name": "/dev/sda",
"usable": True,
"size": "500 GB",
"type_label": "SSD",
"health_label": "Healthy",
"score": 27,
"reason": "",
"warning": "",
},
{
"name": "/dev/sdb",
"usable": False,
"size": "8 GB",
"type_label": "USB",
"health_label": "Status unknown",
"score": 10,
"reason": "Too small",
"warning": "",
},
]
monkeypatch.setattr(app_module, "list_scored_devices", lambda: offered)
monkeypatch.setitem(app_module.settings, "boot_drive", "")
client = app_module.app.test_client()
for bad in ("/dev/sdb", "/dev/nope", "/etc/passwd"):
resp = client.post("/install/step2", data={"boot_drive": bad})
assert resp.status_code == 200
assert app_module.settings["boot_drive"] == ""
resp = client.post("/install/step2", data={"boot_drive": "/dev/sda"})
assert resp.status_code == 302
assert app_module.settings["boot_drive"] == "/dev/sda"
def test_validate_step1_lowercases_names():
errors, values = validate_step1(
{
"hostname": "Furtka",
"username": "Daniel",
"password": "topsecretpw",
"password2": "topsecretpw",
"language": "de",
}
)
assert errors == []
assert values["hostname"] == "furtka"
assert values["username"] == "daniel"
def test_validate_step1_rejects_reserved_usernames():
for name in ("root", "caddy", "docker", "nobody"):
errors, _ = validate_step1(
{
"hostname": "furtka",
"username": name,
"password": "topsecretpw",
"password2": "topsecretpw",
"language": "en",
}
)
assert len(errors) == 1 and "reserved" in errors[0], name
def test_post_install_sets_wait_online_any(monkeypatch):
import base64
import app as app_module
cmds = app_module._post_install_commands("h", "u", "pw12345678")
hits = [c for c in cmds if "systemd-networkd-wait-online.service.d" in c]
assert len(hits) == 1
payload = hits[0].split("printf %s ")[1].split(" |")[0]
assert "--any" in base64.b64decode(payload).decode()
def test_wizard_pages_redirect_to_progress_while_installing(monkeypatch, tmp_path):
app_module = _install_env(monkeypatch, tmp_path)
monkeypatch.setattr(app_module, "_install_proc", _FakeProc(code=None))
client = app_module.app.test_client()
for path in ("/install/step1", "/install/step2", "/install/overview"):
resp = client.get(path)
assert resp.status_code == 302
assert resp.headers["Location"].endswith("/install/log")
def test_error_page_offers_retry(monkeypatch, tmp_path):
app_module = _install_env(monkeypatch, tmp_path)
(tmp_path / "install.log").write_text("Traceback (most recent call last)\nboom\n")
html = app_module.app.test_client().get("/install/log").get_data(as_text=True)
assert "Try again" in html
assert "next-steps" not in html
def test_detect_bootloader_by_firmware(tmp_path):
import app as app_module
efi = tmp_path / "efi"
assert app_module._detect_bootloader(efi) == "Grub" # BIOS / legacy
efi.mkdir()
assert app_module._detect_bootloader(efi) == "Systemd-boot" # no bitness file
(efi / "fw_platform_size").write_text("64\n")
assert app_module._detect_bootloader(efi) == "Systemd-boot"
(efi / "fw_platform_size").write_text("32\n")
assert app_module._detect_bootloader(efi) == "Limine"

View file

@ -1,231 +0,0 @@
import json
from datetime import UTC, datetime, timedelta
import pytest
from furtka import auth
@pytest.fixture
def tmp_users_file(tmp_path, monkeypatch):
path = tmp_path / "users.json"
monkeypatch.setenv("FURTKA_USERS_FILE", str(path))
# Sessions and lockout state are module-level; wipe between tests so
# one doesn't leak a valid token (or a stale failure counter) into
# the next.
auth.SESSIONS.clear()
auth.LOCKOUT.clear_all()
return path
def test_hash_password_roundtrip():
h = auth.hash_password("hunter2")
assert h != "hunter2" # Not plain text.
assert auth.verify_password("hunter2", h) is True
assert auth.verify_password("hunter3", h) is False
def test_hash_password_is_salted():
# Two calls with the same password must produce different hashes.
a = auth.hash_password("same")
b = auth.hash_password("same")
assert a != b
# But both verify against the original.
assert auth.verify_password("same", a)
assert auth.verify_password("same", b)
def test_load_users_returns_empty_when_missing(tmp_users_file):
assert not tmp_users_file.exists()
assert auth.load_users() == {}
@pytest.mark.parametrize("content", ["{not json", "[]", "", "\x00\x00"])
def test_load_users_fails_closed_on_corrupt_file(tmp_users_file, content):
"""A damaged users.json must not read as "no admin yet" — that would
turn /login into an open create-admin form for anyone on the LAN."""
tmp_users_file.write_text(content)
with pytest.raises(auth.UsersFileError):
auth.load_users()
with pytest.raises(auth.UsersFileError):
auth.setup_needed()
def test_save_users_atomic_and_0600(tmp_users_file):
auth.save_users({"admin": {"hash": "x", "username": "daniel"}})
assert tmp_users_file.exists()
mode = tmp_users_file.stat().st_mode & 0o777
assert mode == 0o600, f"expected 0o600, got {oct(mode)}"
loaded = json.loads(tmp_users_file.read_text())
assert loaded["admin"]["username"] == "daniel"
def test_setup_needed_true_on_missing_file(tmp_users_file):
assert auth.setup_needed() is True
def test_setup_needed_true_on_empty_dict(tmp_users_file):
tmp_users_file.write_text("{}")
assert auth.setup_needed() is True
def test_setup_needed_false_when_admin_exists(tmp_users_file):
auth.create_admin("daniel", "secret-pw")
assert auth.setup_needed() is False
def test_create_admin_overwrites_file(tmp_users_file):
auth.create_admin("daniel", "secret-pw")
auth.create_admin("robert", "new-pw")
users = auth.load_users()
assert users["admin"]["username"] == "robert"
def test_authenticate_happy(tmp_users_file):
auth.create_admin("daniel", "secret-pw")
assert auth.authenticate("daniel", "secret-pw") is True
def test_authenticate_wrong_username(tmp_users_file):
auth.create_admin("daniel", "secret-pw")
assert auth.authenticate("robert", "secret-pw") is False
def test_authenticate_wrong_password(tmp_users_file):
auth.create_admin("daniel", "secret-pw")
assert auth.authenticate("daniel", "wrong") is False
def test_authenticate_no_admin(tmp_users_file):
assert auth.authenticate("daniel", "anything") is False
# ---- Session store ---------------------------------------------------------
def test_session_create_and_lookup(tmp_users_file):
s = auth.SESSIONS.create("daniel")
assert s.username == "daniel"
assert s.token
looked_up = auth.SESSIONS.lookup(s.token)
assert looked_up is not None
assert looked_up.username == "daniel"
def test_session_lookup_unknown_token(tmp_users_file):
assert auth.SESSIONS.lookup("not-a-real-token") is None
def test_session_lookup_none_token(tmp_users_file):
assert auth.SESSIONS.lookup(None) is None
assert auth.SESSIONS.lookup("") is None
def test_session_revoke(tmp_users_file):
s = auth.SESSIONS.create("daniel")
auth.SESSIONS.revoke(s.token)
assert auth.SESSIONS.lookup(s.token) is None
def test_session_expires(tmp_users_file, monkeypatch):
# Build a session store with a 0-second TTL so lookup immediately
# treats new sessions as expired.
store = auth.SessionStore(ttl_seconds=0)
s = store.create("daniel")
# Force the clock forward a hair so the > check fires.
monkeypatch.setattr(
auth,
"datetime",
_FakeDatetime(datetime.now(UTC) + timedelta(seconds=1)),
)
# The module-local datetime reference inside SessionStore.lookup
# resolves at call time. Verify that an expired session is dropped.
assert store.lookup(s.token) is None
class _FakeDatetime:
"""Tiny shim — only `.now(tz)` is used from SessionStore."""
def __init__(self, fixed_utc):
self._fixed = fixed_utc
def now(self, tz=None):
if tz is None:
return self._fixed.replace(tzinfo=None)
return self._fixed.astimezone(tz)
# ---- Login attempts / lockout ----------------------------------------------
def test_lockout_under_threshold_still_allowed(tmp_users_file):
store = auth.LoginAttempts(max_failures=3, window_seconds=60, lockout_seconds=60)
key = ("daniel", "10.0.0.1")
for _ in range(2):
store.register_failure(key)
assert store.is_locked(key) is False
assert store.retry_after_seconds(key) == 0
def test_lockout_triggers_at_threshold(tmp_users_file):
store = auth.LoginAttempts(max_failures=3, window_seconds=60, lockout_seconds=60)
key = ("daniel", "10.0.0.1")
for _ in range(3):
store.register_failure(key)
assert store.is_locked(key) is True
assert store.retry_after_seconds(key) > 0
assert store.retry_after_seconds(key) <= 60
def test_lockout_window_decay(tmp_users_file, monkeypatch):
store = auth.LoginAttempts(max_failures=3, window_seconds=60, lockout_seconds=60)
key = ("daniel", "10.0.0.1")
for _ in range(3):
store.register_failure(key)
assert store.is_locked(key) is True
# Jump 2 minutes ahead — all failures are older than the window
# and should be pruned on the next check.
monkeypatch.setattr(
auth,
"datetime",
_FakeDatetime(datetime.now(UTC) + timedelta(seconds=121)),
)
assert store.is_locked(key) is False
assert store.retry_after_seconds(key) == 0
def test_lockout_clear_resets(tmp_users_file):
store = auth.LoginAttempts(max_failures=2, window_seconds=60, lockout_seconds=60)
key = ("daniel", "10.0.0.1")
store.register_failure(key)
store.register_failure(key)
assert store.is_locked(key) is True
store.clear(key)
assert store.is_locked(key) is False
assert store.retry_after_seconds(key) == 0
def test_lockout_keys_are_independent(tmp_users_file):
store = auth.LoginAttempts(max_failures=2, window_seconds=60, lockout_seconds=60)
locked = ("daniel", "1.1.1.1")
other_ip = ("daniel", "2.2.2.2")
other_user = ("robert", "1.1.1.1")
store.register_failure(locked)
store.register_failure(locked)
assert store.is_locked(locked) is True
assert store.is_locked(other_ip) is False
assert store.is_locked(other_user) is False
def test_lockout_clear_all_wipes_every_key(tmp_users_file):
store = auth.LoginAttempts(max_failures=2, window_seconds=60, lockout_seconds=60)
a = ("daniel", "1.1.1.1")
b = ("robert", "2.2.2.2")
store.register_failure(a)
store.register_failure(a)
store.register_failure(b)
store.register_failure(b)
assert store.is_locked(a) and store.is_locked(b)
store.clear_all()
assert not store.is_locked(a)
assert not store.is_locked(b)

View file

@ -1,452 +0,0 @@
"""Tests for the apps-catalog sync flow.
Same shape as ``tests/test_updater.py``: fixture reloads the module with
env-overridden paths, fake tarballs land in tmp_path, Forgejo API is
stubbed via ``urllib.request.urlopen`` monkeypatching so nothing talks
to the network.
Asserts end-to-end atomicity: on any failure path — bad sha256, broken
tarball, invalid manifest — the live catalog dir is either left
untouched (if one existed) or absent (if it didn't).
"""
from __future__ import annotations
import io
import json
import tarfile
from pathlib import Path
import pytest
@pytest.fixture
def catalog(tmp_path, monkeypatch):
monkeypatch.setenv("FURTKA_CATALOG_DIR", str(tmp_path / "var_lib_furtka_catalog"))
monkeypatch.setenv("FURTKA_CATALOG_STATE", str(tmp_path / "var_lib_furtka_catalog-state.json"))
monkeypatch.setenv("FURTKA_CATALOG_LOCK", str(tmp_path / "catalog.lock"))
monkeypatch.setenv("FURTKA_FORGEJO_HOST", "forgejo.test.local")
monkeypatch.setenv("FURTKA_CATALOG_REPO", "daniel/furtka-apps")
import importlib
from furtka import catalog as c
from furtka import paths as p
importlib.reload(p)
importlib.reload(c)
return c
def _manifest(name: str = "fileshare") -> dict:
return {
"name": name,
"display_name": "Fileshare",
"version": "0.1.0",
"description": "Test fixture app",
"volumes": ["files"],
"ports": [445],
"icon": "icon.svg",
}
def _make_catalog_tarball(
path: Path,
version: str,
*,
apps: list[tuple[str, dict]] | None = None,
extra_entries: list[tuple[str, bytes]] | None = None,
compose_overrides: dict[str, bytes] | None = None,
) -> None:
"""Build a minimal valid catalog tarball.
`apps` is a list of (folder_name, manifest_dict). Each app folder gets
a `manifest.json` + a stub `docker-compose.yaml` + `icon.svg`.
`compose_overrides` (folder_name -> bytes) replaces that stub content
for specific apps — for testing catalog-side compose checks (e.g.
`internet.viable` + `privileged: true`) without needing a real image.
`extra_entries` lets tests inject malformed content (path-traversal,
missing VERSION, ...) without rebuilding the helper.
"""
apps = apps if apps is not None else [("fileshare", _manifest())]
compose_overrides = compose_overrides or {}
buf = io.BytesIO()
with tarfile.open(fileobj=buf, mode="w:gz") as tf:
entries: list[tuple[str, bytes]] = [("VERSION", f"{version}\n".encode())]
for folder, m in apps:
entries.append((f"apps/{folder}/manifest.json", json.dumps(m).encode()))
compose = compose_overrides.get(folder, b"services:\n app:\n image: scratch\n")
entries.append((f"apps/{folder}/docker-compose.yaml", compose))
entries.append((f"apps/{folder}/icon.svg", b"<svg/>"))
if extra_entries:
entries.extend(extra_entries)
for name, data in entries:
info = tarfile.TarInfo(name=name)
info.size = len(data)
tf.addfile(info, io.BytesIO(data))
path.write_bytes(buf.getvalue())
def _stub_forgejo_release(
monkeypatch,
catalog,
*,
tag: str,
tarball_url: str = "https://forgejo.test.local/t.tar.gz",
sha_url: str = "https://forgejo.test.local/t.tar.gz.sha256",
releases: list | None = None,
):
"""Patch ``_rc.forgejo_api`` so check_catalog sees a canned release list."""
if releases is None:
releases = [
{
"tag_name": tag,
"assets": [
{"name": f"furtka-apps-{tag}.tar.gz", "browser_download_url": tarball_url},
{
"name": f"furtka-apps-{tag}.tar.gz.sha256",
"browser_download_url": sha_url,
},
],
}
]
def fake_api(host, repo, path, *, error_cls=RuntimeError):
return releases
from furtka import _release_common as _rc
monkeypatch.setattr(_rc, "forgejo_api", fake_api)
def _stub_download(monkeypatch, catalog, mapping: dict[str, bytes]):
"""Patch ``_rc.download`` so sync_catalog pulls from an in-memory map."""
from furtka import _release_common as _rc
def fake_download(url, dest, *, error_cls=RuntimeError):
if url not in mapping:
raise error_cls(f"test: no fake content for {url}")
dest.parent.mkdir(parents=True, exist_ok=True)
dest.write_bytes(mapping[url])
monkeypatch.setattr(_rc, "download", fake_download)
# --------------------------------------------------------------------------- #
# check_catalog
# --------------------------------------------------------------------------- #
def test_check_catalog_reports_update_when_versions_differ(catalog, monkeypatch, tmp_path):
# Pretend we already have catalog version 26.5 on disk; Forgejo reports 26.6.
catalog.catalog_dir().mkdir(parents=True)
(catalog.catalog_dir() / "VERSION").write_text("26.5\n")
_stub_forgejo_release(monkeypatch, catalog, tag="26.6")
check = catalog.check_catalog()
assert check.current == "26.5"
assert check.latest == "26.6"
assert check.update_available is True
assert check.tarball_url.endswith(".tar.gz")
assert check.sha256_url.endswith(".sha256")
def test_check_catalog_reports_up_to_date_when_same_version(catalog, monkeypatch):
catalog.catalog_dir().mkdir(parents=True)
(catalog.catalog_dir() / "VERSION").write_text("26.5\n")
_stub_forgejo_release(monkeypatch, catalog, tag="26.5")
check = catalog.check_catalog()
assert check.current == "26.5"
assert check.latest == "26.5"
assert check.update_available is False
def test_check_catalog_treats_missing_current_as_installable(catalog, monkeypatch):
# Fresh box, no catalog ever synced — any release is an update.
_stub_forgejo_release(monkeypatch, catalog, tag="26.5")
check = catalog.check_catalog()
assert check.current is None
assert check.update_available is True
def test_check_catalog_raises_when_no_releases_published(catalog, monkeypatch):
_stub_forgejo_release(monkeypatch, catalog, tag="x", releases=[])
with pytest.raises(catalog.CatalogError, match="no catalog releases"):
catalog.check_catalog()
# --------------------------------------------------------------------------- #
# sync_catalog — happy + error paths
# --------------------------------------------------------------------------- #
def test_sync_catalog_happy_path(catalog, monkeypatch, tmp_path):
import hashlib
tarball_path = tmp_path / "tarball.tar.gz"
_make_catalog_tarball(tarball_path, "26.6")
tarball_bytes = tarball_path.read_bytes()
sha = hashlib.sha256(tarball_bytes).hexdigest()
_stub_forgejo_release(monkeypatch, catalog, tag="26.6")
_stub_download(
monkeypatch,
catalog,
{
"https://forgejo.test.local/t.tar.gz": tarball_bytes,
"https://forgejo.test.local/t.tar.gz.sha256": (
f"{sha} furtka-apps-26.6.tar.gz\n".encode()
),
},
)
check = catalog.sync_catalog()
assert check.latest == "26.6"
assert (catalog.catalog_dir() / "VERSION").read_text().strip() == "26.6"
assert (catalog.catalog_dir() / "apps" / "fileshare" / "manifest.json").is_file()
state = catalog.read_state()
assert state["stage"] == "done"
assert state["version"] == "26.6"
def test_sync_catalog_noop_when_already_current(catalog, monkeypatch, tmp_path):
catalog.catalog_dir().mkdir(parents=True)
(catalog.catalog_dir() / "VERSION").write_text("26.5\n")
_stub_forgejo_release(monkeypatch, catalog, tag="26.5")
check = catalog.sync_catalog()
assert check.update_available is False
assert catalog.read_state()["stage"] == "done"
def test_sync_catalog_refuses_sha256_mismatch(catalog, monkeypatch, tmp_path):
tarball_path = tmp_path / "tarball.tar.gz"
_make_catalog_tarball(tarball_path, "26.6")
_stub_forgejo_release(monkeypatch, catalog, tag="26.6")
_stub_download(
monkeypatch,
catalog,
{
"https://forgejo.test.local/t.tar.gz": tarball_path.read_bytes(),
# Hash for some OTHER content — will mismatch.
"https://forgejo.test.local/t.tar.gz.sha256": (b"0" * 64 + b" wrong.tar.gz\n"),
},
)
with pytest.raises(catalog.CatalogError, match="sha256 mismatch"):
catalog.sync_catalog()
# Live catalog never existed, must still not exist after the failed sync.
assert not catalog.catalog_dir().exists()
def test_sync_catalog_refuses_tarball_with_invalid_manifest(catalog, monkeypatch, tmp_path):
import hashlib
bad_manifest = {"name": "broken"} # missing required fields
tarball_path = tmp_path / "tarball.tar.gz"
_make_catalog_tarball(tarball_path, "26.6", apps=[("broken", bad_manifest)])
tarball_bytes = tarball_path.read_bytes()
sha = hashlib.sha256(tarball_bytes).hexdigest()
_stub_forgejo_release(monkeypatch, catalog, tag="26.6")
_stub_download(
monkeypatch,
catalog,
{
"https://forgejo.test.local/t.tar.gz": tarball_bytes,
"https://forgejo.test.local/t.tar.gz.sha256": (
f"{sha} furtka-apps-26.6.tar.gz\n".encode()
),
},
)
with pytest.raises(catalog.CatalogError, match="invalid manifest"):
catalog.sync_catalog()
# Staging was cleaned; live catalog never materialised.
assert not catalog.catalog_dir().exists()
def test_sync_catalog_refuses_privileged_internet_viable_app(catalog, monkeypatch, tmp_path):
import hashlib
viable_manifest = dict(_manifest(), internet={"viable": True, "port": 445})
tarball_path = tmp_path / "tarball.tar.gz"
_make_catalog_tarball(
tarball_path,
"26.6",
apps=[("fileshare", viable_manifest)],
compose_overrides={
"fileshare": b"services:\n app:\n image: scratch\n privileged: true\n"
},
)
tarball_bytes = tarball_path.read_bytes()
sha = hashlib.sha256(tarball_bytes).hexdigest()
_stub_forgejo_release(monkeypatch, catalog, tag="26.6")
_stub_download(
monkeypatch,
catalog,
{
"https://forgejo.test.local/t.tar.gz": tarball_bytes,
"https://forgejo.test.local/t.tar.gz.sha256": (
f"{sha} furtka-apps-26.6.tar.gz\n".encode()
),
},
)
with pytest.raises(catalog.CatalogError, match="must not run privileged"):
catalog.sync_catalog()
assert not catalog.catalog_dir().exists()
def test_sync_catalog_allows_privileged_app_that_is_not_internet_viable(
catalog, monkeypatch, tmp_path
):
"""privileged: true is only disqualifying for internet.viable apps —
home-assistant's real catalog entry runs privileged for USB/Bluetooth
access and is simply not marked viable."""
import hashlib
tarball_path = tmp_path / "tarball.tar.gz"
_make_catalog_tarball(
tarball_path,
"26.6",
apps=[("fileshare", _manifest())],
compose_overrides={
"fileshare": b"services:\n app:\n image: scratch\n privileged: true\n"
},
)
tarball_bytes = tarball_path.read_bytes()
sha = hashlib.sha256(tarball_bytes).hexdigest()
_stub_forgejo_release(monkeypatch, catalog, tag="26.6")
_stub_download(
monkeypatch,
catalog,
{
"https://forgejo.test.local/t.tar.gz": tarball_bytes,
"https://forgejo.test.local/t.tar.gz.sha256": (
f"{sha} furtka-apps-26.6.tar.gz\n".encode()
),
},
)
catalog.sync_catalog()
assert catalog.catalog_dir().exists()
def test_sync_catalog_preserves_existing_catalog_on_failure(catalog, monkeypatch, tmp_path):
"""A failed sync must leave the previous live catalog intact so boxes
keep working until the next successful sync."""
import hashlib
# Seed a live catalog that represents a previous successful sync.
live = catalog.catalog_dir()
live.mkdir(parents=True)
(live / "VERSION").write_text("26.5\n")
(live / "apps").mkdir()
bad_manifest = {"name": "broken"} # invalid
tarball_path = tmp_path / "tarball.tar.gz"
_make_catalog_tarball(tarball_path, "26.6", apps=[("broken", bad_manifest)])
sha = hashlib.sha256(tarball_path.read_bytes()).hexdigest()
_stub_forgejo_release(monkeypatch, catalog, tag="26.6")
_stub_download(
monkeypatch,
catalog,
{
"https://forgejo.test.local/t.tar.gz": tarball_path.read_bytes(),
"https://forgejo.test.local/t.tar.gz.sha256": f"{sha} x\n".encode(),
},
)
with pytest.raises(catalog.CatalogError):
catalog.sync_catalog()
# The 26.5 live catalog survives the failed 26.6 sync.
assert (live / "VERSION").read_text().strip() == "26.5"
def test_sync_catalog_lock_contention(catalog, monkeypatch):
_stub_forgejo_release(monkeypatch, catalog, tag="26.6")
# Hold the lock from outside; the real sync_catalog call must refuse.
first = catalog.acquire_lock()
try:
with pytest.raises(catalog.CatalogError, match="already in progress"):
catalog.sync_catalog()
finally:
first.close()
# --------------------------------------------------------------------------- #
# state + current-version helpers
# --------------------------------------------------------------------------- #
def test_read_current_catalog_version_absent(catalog):
assert catalog.read_current_catalog_version() is None
def test_read_current_catalog_version_empty_file(catalog):
catalog.catalog_dir().mkdir(parents=True)
(catalog.catalog_dir() / "VERSION").write_text("\n")
assert catalog.read_current_catalog_version() is None
def test_write_and_read_state_round_trip(catalog):
catalog.write_state("downloading", latest="26.6")
s = catalog.read_state()
assert s["stage"] == "downloading"
assert s["latest"] == "26.6"
assert "updated_at" in s
def test_sync_catalog_failure_writes_error_state(catalog, monkeypatch):
"""A boot-time network failure must not leave the state file on
'checking' forever — the UI would show 'checking…' with nothing running."""
from furtka import _release_common as _rc
def offline(host, repo, path, *, error_cls=RuntimeError):
raise error_cls("forgejo api https://x/releases: Network is unreachable")
monkeypatch.setattr(_rc, "forgejo_api", offline)
with pytest.raises(catalog.CatalogError, match="Network is unreachable"):
catalog.sync_catalog()
state = catalog.read_state()
assert state["stage"] == "error"
assert "Network is unreachable" in state["error"]
def test_sync_catalog_unexpected_error_lands_as_error_stage(catalog, monkeypatch):
"""Disk full / mid-body disconnect used to leave the stage at
'downloading' forever because only CatalogError was caught."""
_stub_forgejo_release(monkeypatch, catalog, tag="26.6")
def boom(*a, **kw):
raise OSError(28, "No space left on device")
monkeypatch.setattr(catalog._rc, "download", boom)
with pytest.raises(catalog.CatalogError, match="No space left"):
catalog.sync_catalog()
assert catalog.read_state()["stage"] == "error"
def test_release_download_wraps_socket_errors(tmp_path, monkeypatch):
from furtka import _release_common as rc
def reset(*a, **kw):
raise ConnectionResetError("reset by peer")
monkeypatch.setattr(rc.urllib.request, "urlopen", reset)
with pytest.raises(rc.ReleaseError, match="reset by peer"):
rc.download("https://x/y", tmp_path / "y")
with pytest.raises(rc.ReleaseError, match="reset by peer"):
rc.forgejo_api("x", "a/b", "/releases")

View file

@ -32,21 +32,9 @@ def test_app_list_json_with_one_app(tmp_path, monkeypatch, capsys):
"display_name": "Network Files",
"version": "0.1.0",
"description": "SMB",
"description_long": "Long description here.",
"volumes": ["files"],
"ports": [445],
"icon": "icon.svg",
"open_url": "smb://{host}/files",
"settings": [
{
"name": "SMB_USER",
"label": "User",
"description": "SMB user",
"type": "text",
"default": "furtka",
"required": True,
}
],
}
)
)
@ -55,44 +43,7 @@ def test_app_list_json_with_one_app(tmp_path, monkeypatch, capsys):
data = json.loads(capsys.readouterr().out)
assert len(data) == 1
assert data[0]["ok"] is True
m = data[0]["manifest"]
assert m["name"] == "fileshare"
assert m["description_long"] == "Long description here."
assert m["open_url"] == "smb://{host}/files"
assert len(m["settings"]) == 1
assert m["settings"][0]["name"] == "SMB_USER"
assert m["settings"][0]["required"] is True
assert m["settings"][0]["default"] == "furtka"
assert m["internet"] == {"viable": False, "port": None}
def test_gateway_connect_bg_dispatches_to_run_connect(tmp_path, monkeypatch):
"""CLI `gateway connect-bg <url> <token> <name>` must call
gateway.run_connect(url, token, name) — the entry point the HTTP API
fires via systemd-run for POST /api/gateway/connect."""
_set_env(monkeypatch, tmp_path)
from furtka import gateway
called = []
monkeypatch.setattr(
gateway, "run_connect", lambda url, token, name: called.append((url, token, name))
)
rc = main(["gateway", "connect-bg", "https://gw.example.com", "reg-token", "my-box"])
assert rc == 0
assert called == [("https://gw.example.com", "reg-token", "my-box")]
def test_gateway_connect_bg_returns_1_on_failure(tmp_path, monkeypatch, capsys):
_set_env(monkeypatch, tmp_path)
from furtka import gateway
def boom(url, token, name):
raise RuntimeError("gateway unreachable")
monkeypatch.setattr(gateway, "run_connect", boom)
rc = main(["gateway", "connect-bg", "https://gw.example.com", "reg-token", "my-box"])
assert rc == 1
assert "gateway connect-bg failed" in capsys.readouterr().err
assert data[0]["manifest"]["name"] == "fileshare"
def test_reconcile_dry_run_empty(tmp_path, monkeypatch, capsys):
@ -101,130 +52,3 @@ def test_reconcile_dry_run_empty(tmp_path, monkeypatch, capsys):
assert rc == 0
out = capsys.readouterr().out
assert "0 actions" in out
def test_app_install_bg_dispatches_to_runner(tmp_path, monkeypatch):
"""CLI `app install-bg <name>` must call install_runner.run_install(name).
This is the entry point the HTTP API fires via systemd-run; regression
here would leave the UI hanging at "pulling_image…" forever because
the background never transitions state.
"""
_set_env(monkeypatch, tmp_path)
from furtka import install_runner
called = []
monkeypatch.setattr(install_runner, "run_install", lambda name: called.append(name))
rc = main(["app", "install-bg", "fileshare"])
assert rc == 0
assert called == ["fileshare"]
def test_app_install_bg_returns_1_on_failure(tmp_path, monkeypatch, capsys):
_set_env(monkeypatch, tmp_path)
from furtka import install_runner
def boom(name):
raise RuntimeError("compose pull failed")
monkeypatch.setattr(install_runner, "run_install", boom)
rc = main(["app", "install-bg", "fileshare"])
assert rc == 1
err = capsys.readouterr().err
assert "install-bg failed" in err
assert "compose pull failed" in err
# --- Dependency-aware install + remove ---------------------------------------
def _write_manifest(root, name, **overrides):
app = root / name
app.mkdir(parents=True, exist_ok=True)
payload = {
"name": name,
"display_name": name,
"version": "0.1.0",
"description": "x",
"volumes": [],
"ports": [],
"icon": "icon.svg",
**overrides,
}
(app / "manifest.json").write_text(json.dumps(payload))
(app / "docker-compose.yaml").write_text("services: {}\n")
return app
def test_app_remove_blocked_by_dependent(tmp_path, monkeypatch, capsys):
_set_env(monkeypatch, tmp_path)
_write_manifest(tmp_path, "mosquitto")
_write_manifest(tmp_path, "zigbee2mqtt", requires=[{"app": "mosquitto"}])
rc = main(["app", "remove", "mosquitto"])
assert rc == 2
err = capsys.readouterr().err
assert "required by: zigbee2mqtt" in err
def test_app_remove_unblocked_when_no_dependents(tmp_path, monkeypatch):
_set_env(monkeypatch, tmp_path)
_write_manifest(tmp_path, "mosquitto")
from furtka import dockerops
monkeypatch.setattr(dockerops, "compose_down", lambda *a, **k: None)
rc = main(["app", "remove", "mosquitto"])
assert rc == 0
assert not (tmp_path / "mosquitto").exists()
def test_app_install_uses_plan_for_named_install(tmp_path, monkeypatch, capsys):
"""Named install pulls in dependencies via plan_install."""
_set_env(monkeypatch, tmp_path)
bundled = tmp_path / "bundled"
bundled.mkdir()
monkeypatch.setenv("FURTKA_BUNDLED_APPS_DIR", str(bundled))
# No catalog dir — bundled-only.
monkeypatch.setenv("FURTKA_CATALOG_DIR", str(tmp_path / "catalog"))
_write_manifest(bundled, "mosquitto")
_write_manifest(bundled, "zigbee2mqtt", requires=[{"app": "mosquitto"}])
from furtka import install_runner, installer
# Stub install_from so we don't actually copy files / mess with placeholders.
install_calls: list[str] = []
def fake_install_from(src, settings=None):
install_calls.append(src.name)
return tmp_path / src.name
monkeypatch.setattr(installer, "install_from", fake_install_from)
monkeypatch.setattr(install_runner, "_INSTALL_PLAN", tmp_path / "install-plan.json")
# Named installs drive the docker phase through run_install (so on_install
# hooks fire) — stub it and assert it's invoked with the chosen target.
run_calls: list[str] = []
monkeypatch.setattr(install_runner, "run_install", lambda name: run_calls.append(name))
rc = main(["app", "install", "zigbee2mqtt"])
assert rc == 0
# Provider installed before consumer.
assert install_calls == ["mosquitto", "zigbee2mqtt"]
# The docker phase (which fires on_install hooks) ran for the target.
assert run_calls == ["zigbee2mqtt"]
# And the plan file was staged for run_install to consume.
plan = json.loads(install_runner.plan_path().read_text())
assert plan["target"] == "zigbee2mqtt"
assert plan["to_install"] == ["mosquitto", "zigbee2mqtt"]
def test_app_install_named_with_cycle_exits_2(tmp_path, monkeypatch, capsys):
_set_env(monkeypatch, tmp_path)
bundled = tmp_path / "bundled"
bundled.mkdir()
monkeypatch.setenv("FURTKA_BUNDLED_APPS_DIR", str(bundled))
monkeypatch.setenv("FURTKA_CATALOG_DIR", str(tmp_path / "catalog"))
_write_manifest(bundled, "a", requires=[{"app": "b"}])
_write_manifest(bundled, "b", requires=[{"app": "a"}])
rc = main(["app", "install", "a"])
assert rc == 2
err = capsys.readouterr().err
assert "circular" in err.lower()

View file

@ -1,183 +0,0 @@
import json
import pytest
from furtka import deps
BASE_MANIFEST = {
"display_name": "X",
"version": "0.1.0",
"description": "x",
"volumes": [],
"ports": [],
"icon": "icon.svg",
}
@pytest.fixture
def apps_root(tmp_path, monkeypatch):
"""Three roots: installed, catalog, bundled. Each set up empty by default."""
installed = tmp_path / "installed"
catalog = tmp_path / "catalog" / "apps"
bundled = tmp_path / "bundled"
for p in (installed, catalog, bundled):
p.mkdir(parents=True)
monkeypatch.setenv("FURTKA_APPS_DIR", str(installed))
monkeypatch.setenv("FURTKA_CATALOG_DIR", str(tmp_path / "catalog"))
monkeypatch.setenv("FURTKA_BUNDLED_APPS_DIR", str(bundled))
return {"installed": installed, "catalog": catalog, "bundled": bundled}
def _write_manifest(root, name, **overrides):
app = root / name
app.mkdir(parents=True, exist_ok=True)
payload = dict(BASE_MANIFEST, name=name, **overrides)
(app / "manifest.json").write_text(json.dumps(payload))
return app
def test_plan_install_no_deps(apps_root):
_write_manifest(apps_root["catalog"], "alone")
plan = deps.plan_install("alone")
assert plan.target == "alone"
assert plan.install_order == ("alone",)
assert plan.to_install == ("alone",)
assert plan.already_installed == frozenset()
def test_plan_install_linear_chain(apps_root):
# A requires B, B requires C — all in catalog, none installed yet.
_write_manifest(apps_root["catalog"], "c")
_write_manifest(apps_root["catalog"], "b", requires=[{"app": "c"}])
_write_manifest(apps_root["catalog"], "a", requires=[{"app": "b"}])
plan = deps.plan_install("a")
assert plan.install_order == ("c", "b", "a")
assert plan.to_install == ("c", "b", "a")
def test_plan_install_diamond(apps_root):
# A requires B and C; B requires D; C requires D. D must appear once,
# before B and C, which come before A.
_write_manifest(apps_root["catalog"], "d")
_write_manifest(apps_root["catalog"], "b", requires=[{"app": "d"}])
_write_manifest(apps_root["catalog"], "c", requires=[{"app": "d"}])
_write_manifest(apps_root["catalog"], "a", requires=[{"app": "b"}, {"app": "c"}])
plan = deps.plan_install("a")
order = plan.install_order
# D first, A last, B and C in between (deterministically alphabetical).
assert order[0] == "d"
assert order[-1] == "a"
assert set(order[1:-1]) == {"b", "c"}
assert order.count("d") == 1
def test_plan_install_already_installed_provider(apps_root):
_write_manifest(apps_root["installed"], "b") # provider already installed
_write_manifest(apps_root["catalog"], "b")
_write_manifest(apps_root["catalog"], "a", requires=[{"app": "b"}])
plan = deps.plan_install("a")
assert plan.install_order == ("b", "a")
assert plan.to_install == ("a",)
assert plan.already_installed == frozenset({"b"})
def test_plan_install_cycle_two_node(apps_root):
# Manifest validator already rejects self-reference at load time, but
# mutual references (A -> B -> A) only show up at plan time.
_write_manifest(apps_root["catalog"], "b", requires=[{"app": "a"}])
_write_manifest(apps_root["catalog"], "a", requires=[{"app": "b"}])
with pytest.raises(deps.DependencyError, match="circular"):
deps.plan_install("a")
def test_plan_install_cycle_three_node(apps_root):
_write_manifest(apps_root["catalog"], "c", requires=[{"app": "a"}])
_write_manifest(apps_root["catalog"], "b", requires=[{"app": "c"}])
_write_manifest(apps_root["catalog"], "a", requires=[{"app": "b"}])
with pytest.raises(deps.DependencyError, match="a -> b -> c -> a"):
deps.plan_install("a")
def test_plan_install_missing_provider(apps_root):
_write_manifest(apps_root["catalog"], "a", requires=[{"app": "ghost"}])
with pytest.raises(deps.DependencyError, match="ghost"):
deps.plan_install("a")
def test_plan_install_prefers_installed_over_catalog(apps_root):
# If a provider exists in both installed and catalog, we resolve via
# installed (so we read the actual on-disk manifest the user has).
_write_manifest(apps_root["installed"], "b")
_write_manifest(apps_root["catalog"], "b", requires=[{"app": "extra"}])
_write_manifest(apps_root["catalog"], "a", requires=[{"app": "b"}])
plan = deps.plan_install("a")
# The installed manifest has no requires, so "extra" is NOT pulled in.
assert plan.install_order == ("b", "a")
def test_dependents_of_empty(apps_root):
assert deps.dependents_of("anything") == ()
def test_dependents_of_finds_consumers(apps_root):
_write_manifest(apps_root["installed"], "x")
_write_manifest(apps_root["installed"], "a", requires=[{"app": "x"}])
_write_manifest(apps_root["installed"], "b", requires=[{"app": "x"}])
_write_manifest(apps_root["installed"], "unrelated")
assert deps.dependents_of("x") == ("a", "b")
assert deps.dependents_of("unrelated") == ()
def test_installed_topo_order_preserves_alpha_when_independent(apps_root):
from furtka.scanner import scan
_write_manifest(apps_root["installed"], "alpha")
_write_manifest(apps_root["installed"], "bravo")
_write_manifest(apps_root["installed"], "charlie")
ordered = deps.installed_topo_order(scan(apps_root["installed"]))
assert [r.manifest.name for r in ordered] == ["alpha", "bravo", "charlie"]
def test_installed_topo_order_puts_providers_first(apps_root):
from furtka.scanner import scan
# Alphabetically z2m comes before mqtt? No — but let's force the
# dependency to win. consumer=alpha requires=provider=zulu, so naive
# alpha order would put alpha first. Topo must flip them.
_write_manifest(apps_root["installed"], "zulu")
_write_manifest(apps_root["installed"], "alpha", requires=[{"app": "zulu"}])
ordered = deps.installed_topo_order(scan(apps_root["installed"]))
names = [r.manifest.name for r in ordered]
assert names == ["zulu", "alpha"]
def test_installed_topo_order_missing_provider_tails_app(apps_root):
from furtka.scanner import scan
_write_manifest(apps_root["installed"], "good")
_write_manifest(apps_root["installed"], "needy", requires=[{"app": "ghost"}])
ordered = deps.installed_topo_order(scan(apps_root["installed"]))
names = [r.manifest.name for r in ordered]
# `good` first (no deps), `needy` last (unresolved).
assert names == ["good", "needy"]
def test_provider_exec_service_picks_first_service(apps_root, monkeypatch):
from furtka import dockerops
monkeypatch.setattr(
dockerops,
"compose_image_tags",
lambda app_dir, project: {"server": "img:1", "worker": "img:2"},
)
assert deps.provider_exec_service(apps_root["installed"] / "x", "x") == "server"
def test_provider_exec_service_falls_back_to_project_on_docker_error(apps_root, monkeypatch):
from furtka import dockerops
def boom(app_dir, project):
raise dockerops.DockerError("docker not running")
monkeypatch.setattr(dockerops, "compose_image_tags", boom)
assert deps.provider_exec_service(apps_root["installed"] / "x", "myproj") == "myproj"

View file

@ -1,146 +0,0 @@
"""release_disk() against a recorded `lsblk -P` picture of the first
hardware bench: an SSD with an old Proxmox VE `pve` volume group stacked on
sda3 (swap, root, thin pool). Commands are captured through the injected
runner — nothing touches real devices."""
import subprocess
from pathlib import Path
import pytest
from diskprep import release_disk
# What the bench looked like before cleanup (lsblk -lnP -o NAME,TYPE,MOUNTPOINTS /dev/sda).
BENCH_BEFORE = """NAME="sda" TYPE="disk" MOUNTPOINTS=""
NAME="sda1" TYPE="part" MOUNTPOINTS=""
NAME="sda3" TYPE="part" MOUNTPOINTS=""
NAME="pve-swap" TYPE="lvm" MOUNTPOINTS="[SWAP]"
NAME="pve-root" TYPE="lvm" MOUNTPOINTS="/mnt/old"
NAME="pve-data_tmeta" TYPE="lvm" MOUNTPOINTS=""
NAME="pve-data-tpool" TYPE="lvm" MOUNTPOINTS=""
NAME="pve-data" TYPE="lvm" MOUNTPOINTS=""
NAME="pve-data_tdata" TYPE="lvm" MOUNTPOINTS=""
NAME="pve-data-tpool" TYPE="lvm" MOUNTPOINTS=""
NAME="pve-data" TYPE="lvm" MOUNTPOINTS=""
"""
BENCH_CLEAN = """NAME="sda" TYPE="disk" MOUNTPOINTS=""
NAME="sda1" TYPE="part" MOUNTPOINTS=""
NAME="sda3" TYPE="part" MOUNTPOINTS=""
"""
EMPTY = """NAME="sda" TYPE="disk" MOUNTPOINTS=""
"""
class FakeRunner:
"""Answers lsblk from a script of snapshots (one per call, last one
sticks) and records everything else."""
def __init__(self, lsblk_outputs):
self.lsblk_outputs = list(lsblk_outputs)
self.calls = []
def __call__(self, cmd, capture_output=True, text=True):
if cmd[0] == "lsblk":
if len(self.lsblk_outputs) > 1:
out = self.lsblk_outputs.pop(0)
else:
out = self.lsblk_outputs[0]
return subprocess.CompletedProcess(cmd, 0, out, "")
self.calls.append(cmd)
return subprocess.CompletedProcess(cmd, 0, "", "")
def test_release_tears_down_lvm_stack_leaves_first():
# lsblk #1 (inventory) and #2 (first removal pass) see the full stack;
# everything after that is clean — the dm removals "worked".
runner = FakeRunner([BENCH_BEFORE, BENCH_BEFORE, BENCH_CLEAN])
lines = release_disk("/dev/sda", run=runner)
calls = runner.calls
assert ["swapoff", "/dev/pve-swap"] in calls
assert ["umount", "--all-targets", "/dev/pve-root"] in calls
removed = [c[2] for c in calls if c[:2] == ["dmsetup", "remove"]]
# Thin LV before its pool, pool before tmeta/tdata.
assert removed.index("pve-data") < removed.index("pve-data-tpool")
assert removed.index("pve-data-tpool") < removed.index("pve-data_tmeta")
assert set(removed) == {
"pve-swap",
"pve-root",
"pve-data_tmeta",
"pve-data-tpool",
"pve-data",
"pve-data_tdata",
}
assert ["wipefs", "-a", "/dev/sda3"] in calls
assert ["wipefs", "-a", "/dev/sda1"] in calls
assert ["wipefs", "-a", "/dev/sda"] in calls
assert calls[-1] == ["blockdev", "--rereadpt", "/dev/sda"]
assert not any("WARNING" in line for line in lines)
assert lines[0].startswith("Releasing /dev/sda")
def test_release_warns_when_stack_survives():
runner = FakeRunner([BENCH_BEFORE]) # never becomes clean
lines = release_disk("/dev/sda", run=runner)
assert any("WARNING: still in use" in line for line in lines)
# Still tries to give archinstall a chance.
assert ["blockdev", "--rereadpt", "/dev/sda"] in runner.calls
def test_release_empty_disk_only_wipes_and_rereads():
runner = FakeRunner([EMPTY])
lines = release_disk("sda", run=runner) # bare name is accepted too
assert runner.calls == [["wipefs", "-a", "/dev/sda"], ["blockdev", "--rereadpt", "/dev/sda"]]
assert any("nothing stacked" in line for line in lines)
def test_release_survives_missing_tools():
def runner(cmd, capture_output=True, text=True):
if cmd[0] == "lsblk":
return subprocess.CompletedProcess(cmd, 0, BENCH_BEFORE, "")
raise FileNotFoundError(cmd[0])
lines = release_disk("/dev/sda", run=runner)
assert any("command not found" in line for line in lines)
@pytest.fixture
def app_module(monkeypatch, tmp_path):
import app as app_module
monkeypatch.setattr(app_module, "INSTALL_LOG", tmp_path / "install.log")
monkeypatch.setattr(app_module, "STATE_DIR", tmp_path / "state")
return app_module
def test_install_run_releases_disk_before_spawning(app_module, monkeypatch):
order = []
def fake_release(disk):
order.append(("release", disk))
return ["Releasing X"]
monkeypatch.setattr(app_module, "release_disk", fake_release)
monkeypatch.setattr(app_module, "spawn_archinstall", lambda *a: order.append(("spawn",)))
monkeypatch.setattr(app_module, "build_archinstall_config", lambda s: {})
monkeypatch.setattr(app_module, "build_archinstall_creds", lambda s: {})
monkeypatch.delenv("FURTKA_DRY_RUN", raising=False)
app_module.settings.update(boot_drive="/dev/sda", username="u", password="pw12345678")
client = app_module.app.test_client()
resp = client.post("/install/run")
assert resp.status_code == 302
assert order == [("release", "/dev/sda"), ("spawn",)]
assert Path(app_module.INSTALL_LOG).read_text().startswith("Releasing X")
def test_progress_names_disk_in_use_failure(app_module):
log = (
"Traceback (most recent call last):\n"
"_ped.IOException: ... unable to inform the kernel of the change ..."
)
progress = app_module.parse_install_progress(log)
assert progress["status"] == "error"
assert "still in use" in progress["phase"]

View file

@ -1,116 +0,0 @@
import subprocess
import pytest
from furtka import dockerops
class FakeProc:
def __init__(self, stdout=b"", stderr=b"", returncode=0):
self.stdout = stdout
self.stderr = stderr
self.returncode = returncode
def test_compose_exec_builds_command(tmp_path, monkeypatch):
recorded = {}
def fake_run(cmd, **kwargs):
recorded["cmd"] = cmd
recorded["kwargs"] = kwargs
return FakeProc(stdout="ok\n", returncode=0)
monkeypatch.setattr(subprocess, "run", fake_run)
out = dockerops.compose_exec(tmp_path, "myproj", "svc", ["echo", "hi"])
assert out == "ok\n"
cmd = recorded["cmd"]
# docker compose --project-name myproj --file <path>/docker-compose.yaml exec -T svc echo hi
assert cmd[0] == "docker"
assert cmd[1] == "compose"
assert "--project-name" in cmd and "myproj" in cmd
assert "exec" in cmd
assert "-T" in cmd
# -T must come before the service name
assert cmd.index("-T") < cmd.index("svc")
# argv appended after service
assert cmd[-2:] == ["echo", "hi"]
def test_compose_exec_propagates_env(tmp_path, monkeypatch):
recorded = {}
def fake_run(cmd, **kwargs):
recorded["cmd"] = cmd
return FakeProc()
monkeypatch.setattr(subprocess, "run", fake_run)
dockerops.compose_exec(tmp_path, "p", "s", ["true"], env={"A": "1", "B": "two"})
cmd = recorded["cmd"]
# `--env A=1 --env B=two` should appear before the service name.
s_idx = cmd.index("s")
env_args = cmd[:s_idx]
assert env_args.count("--env") == 2
assert "A=1" in env_args
assert "B=two" in env_args
def test_compose_exec_raises_on_nonzero(tmp_path, monkeypatch):
def fake_run(cmd, **kwargs):
return FakeProc(stdout="", stderr="boom", returncode=2)
monkeypatch.setattr(subprocess, "run", fake_run)
with pytest.raises(dockerops.DockerError, match="exited 2"):
dockerops.compose_exec(tmp_path, "p", "s", ["fail"])
def test_compose_exec_raises_on_timeout(tmp_path, monkeypatch):
def fake_run(cmd, **kwargs):
raise subprocess.TimeoutExpired(cmd, timeout=kwargs.get("timeout"))
monkeypatch.setattr(subprocess, "run", fake_run)
with pytest.raises(dockerops.DockerError, match="timed out"):
dockerops.compose_exec(tmp_path, "p", "s", ["sleep", "9999"], timeout=1)
def test_compose_exec_script_streams_via_stdin(tmp_path, monkeypatch):
script = tmp_path / "hook.sh"
body = b"#!/bin/sh\necho hello\n"
script.write_bytes(body)
recorded = {}
def fake_run(cmd, **kwargs):
recorded["cmd"] = cmd
recorded["input"] = kwargs["input"]
return FakeProc(stdout=b"hello\n", returncode=0)
monkeypatch.setattr(subprocess, "run", fake_run)
out = dockerops.compose_exec_script(tmp_path, "p", "s", script)
assert out == "hello\n"
# exec ... s sh -s (script body comes in on stdin)
cmd = recorded["cmd"]
assert cmd[-3:] == ["s", "sh", "-s"]
assert recorded["input"] == body
def test_compose_exec_script_raises_on_nonzero(tmp_path, monkeypatch):
script = tmp_path / "fail.sh"
script.write_bytes(b"exit 1\n")
def fake_run(cmd, **kwargs):
return FakeProc(stdout=b"", stderr=b"hook says no", returncode=1)
monkeypatch.setattr(subprocess, "run", fake_run)
with pytest.raises(dockerops.DockerError, match="hook fail.sh exited 1"):
dockerops.compose_exec_script(tmp_path, "p", "s", script)
def test_compose_exec_script_raises_on_timeout(tmp_path, monkeypatch):
script = tmp_path / "slow.sh"
script.write_bytes(b"sleep 10\n")
def fake_run(cmd, **kwargs):
raise subprocess.TimeoutExpired(cmd, timeout=kwargs.get("timeout"))
monkeypatch.setattr(subprocess, "run", fake_run)
with pytest.raises(dockerops.DockerError, match="hook slow.sh timed out"):
dockerops.compose_exec_script(tmp_path, "p", "s", script, timeout=1)

View file

@ -1,63 +1,47 @@
import json
import drives
import pytest
from drives import (
MIN_DISK_GB,
_boot_device_from_cmdline,
classify_drive,
format_size,
get_drive_type_label,
get_drive_type_score,
get_size_score,
parse_lsblk_json,
parse_lsblk_output,
parse_size_gb,
score_device,
)
GB = 1024**3
def test_parse_size_gb_terabytes():
assert parse_size_gb("1T") == 1024.0
def _lsblk(*entries):
"""Build lsblk -J output. Each entry: (name, size_gb, type, extra-dict)."""
devs = []
for name, size_gb, dev_type, *extra in entries:
d = {
"name": name,
"size": int(size_gb * GB),
"type": dev_type,
"ro": False,
"rm": False,
"tran": "sata",
"rota": True,
}
if extra:
d.update(extra[0])
devs.append(d)
return json.dumps({"blockdevices": devs})
def test_parse_size_gb_gigabytes():
assert parse_size_gb("500G") == 500.0
@pytest.fixture(autouse=True)
def _smart_passed(monkeypatch):
monkeypatch.setattr(drives, "_smart_status", lambda _: "passed")
def test_parse_size_gb_megabytes():
assert parse_size_gb("2048M") == 2.0
def _names(devices):
return [d["name"] for d in devices]
def test_parse_size_gb_european_comma_decimal():
assert parse_size_gb("1,5T") == 1.5 * 1024
def test_classify_uses_transport_and_rotation():
assert classify_drive("nvme0n1", tran="nvme", rota=False) == "NVMe"
assert classify_drive("sda", tran="sata", rota=False) == "SSD"
assert classify_drive("sda", tran="sata", rota=True) == "HDD"
assert classify_drive("mmcblk0", tran="", rota=False) == "eMMC"
assert classify_drive("sdb", tran="usb", rota=False) == "USB"
assert classify_drive("sdc", tran="", rota=True, removable=True) == "USB"
# SD cards in a built-in reader are removable mmc — still eMMC/mmc class.
assert classify_drive("mmcblk1", tran="", rota=False, removable=True) == "eMMC"
def test_parse_size_gb_empty_returns_none():
assert parse_size_gb("") is None
def test_type_scores_rank_internal_over_usb():
assert get_drive_type_score("NVMe") > get_drive_type_score("SSD")
assert get_drive_type_score("SSD") > get_drive_type_score("HDD")
assert get_drive_type_score("HDD") > get_drive_type_score("USB")
def test_parse_size_gb_unknown_unit_returns_none():
assert parse_size_gb("500K") is None
def test_drive_type_score_nvme():
assert get_drive_type_score("/dev/nvme0n1") == 15
def test_drive_type_score_ssd():
assert get_drive_type_score("/dev/ssd0") == 10
def test_drive_type_score_hdd_fallback():
assert get_drive_type_score("/dev/sda") == 5
def test_size_score_bands():
@ -67,145 +51,47 @@ def test_size_score_bands():
assert get_size_score(1024) == 10
def test_format_size():
assert format_size(500 * GB) == "500 GB"
assert format_size(1024 * GB) == "1 TB"
assert format_size(int(1.5 * 1024 * GB)) == "1.5 TB"
assert format_size(int(7.5 * GB)) == "7.5 GB"
def test_score_device_sums_type_and_size(monkeypatch):
import drives
monkeypatch.setattr(drives, "get_drive_health", lambda _: 10)
assert score_device("/dev/nvme0n1", 1024) == 15 + 10 + 10
assert score_device("/dev/sda", 64) == 5 + 10 + 5
def test_drops_loop_rom_zram_and_emmc_boot_areas():
out = _lsblk(
("loop0", 2.5, "loop"),
("sr0", 1, "rom"),
("zram0", 4, "disk"),
("mmcblk0boot0", 0.004, "disk"),
("mmcblk0boot1", 0.004, "disk"),
("mmcblk0rpmb", 0.004, "disk"),
("mmcblk0", 64, "disk", {"tran": "", "rota": False}),
)
assert _names(parse_lsblk_json(out)) == ["/dev/mmcblk0"]
def test_parse_lsblk_drops_loop_and_rom(monkeypatch):
import drives
monkeypatch.setattr(drives, "_smart_status", lambda _: "passed")
output = "loop0 2.5G loop\nsr0 1024M rom\nsda 500G disk\nnvme0n1 1T disk\n"
devices = parse_lsblk_output(output)
names = [d["name"] for d in devices]
assert names == ["/dev/nvme0n1", "/dev/sda"]
def test_drops_empty_card_reader_slots_and_read_only_disks():
out = _lsblk(
("sdc", 0, "disk", {"rm": True, "tran": "usb"}),
("sdd", 500, "disk", {"ro": True}),
("sda", 500, "disk"),
)
assert _names(parse_lsblk_json(out)) == ["/dev/sda"]
def test_parse_lsblk_attaches_human_labels(monkeypatch):
import drives
monkeypatch.setattr(drives, "_smart_status", lambda _: "passed")
output = "nvme0n1 1T disk\n"
[dev] = parse_lsblk_output(output)
assert dev["type_label"] == "NVMe"
assert dev["health_label"] == "Healthy"
def test_ssd_beats_bigger_hdd():
# Regression: sdX SSDs used to be labelled HDD, so the 2 TB spinning disk
# won the recommendation over the 256 GB SSD.
out = _lsblk(
("sda", 2048, "disk", {"rota": True}),
("sdb", 256, "disk", {"rota": False}),
)
devices = parse_lsblk_json(out)
assert _names(devices) == ["/dev/sdb", "/dev/sda"]
assert [d["type_label"] for d in devices] == ["SSD", "HDD"]
def test_parse_lsblk_surfaces_smart_warning(monkeypatch):
import drives
def test_usb_disk_ranked_after_internal():
out = _lsblk(
("sda", 32, "disk", {"tran": "usb", "rota": False, "rm": True}),
("mmcblk0", 32, "disk", {"tran": "", "rota": False}),
)
devices = parse_lsblk_json(out)
assert _names(devices) == ["/dev/mmcblk0", "/dev/sda"]
assert devices[1]["warning"]
def test_too_small_disk_listed_but_unusable_and_last():
out = _lsblk(
("sda", MIN_DISK_GB - 1, "disk", {"rota": False}),
("sdb", 500, "disk"),
)
devices = parse_lsblk_json(out)
assert _names(devices) == ["/dev/sdb", "/dev/sda"]
assert devices[0]["usable"] is True
assert devices[1]["usable"] is False
assert str(MIN_DISK_GB) in devices[1]["reason"]
def test_bios_warns_about_disks_over_2tb():
out = _lsblk(("sda", 4096, "disk"))
[bios] = parse_lsblk_json(out, uefi=False)
[efi] = parse_lsblk_json(out, uefi=True)
assert "2 TB" in bios["warning"]
assert efi["warning"] == ""
def test_surfaces_smart_warning(monkeypatch):
monkeypatch.setattr(drives, "_smart_status", lambda _: "failed")
[dev] = parse_lsblk_json(_lsblk(("sda", 500, "disk")))
[dev] = parse_lsblk_output("sda 500G disk\n")
assert dev["health_label"] == "SMART warning"
def test_accepts_old_util_linux_string_flags():
out = json.dumps(
{
"blockdevices": [
{
"name": "sda",
"size": str(500 * GB),
"type": "disk",
"ro": "0",
"rm": "0",
"tran": "sata",
"rota": "0",
},
]
}
)
[dev] = parse_lsblk_json(out)
assert dev["type_label"] == "SSD"
def test_drive_type_label_nvme_ssd_hdd():
assert get_drive_type_label("/dev/nvme0n1") == "NVMe"
assert get_drive_type_label("/dev/ssd0") == "SSD"
assert get_drive_type_label("/dev/sda") == "HDD"
def test_handles_empty_and_garbage_output():
assert parse_lsblk_json("") == []
assert parse_lsblk_json("not json") == []
def test_drops_boot_usb():
out = _lsblk(
("sda", 500, "disk"),
("sdb", 16, "disk", {"tran": "usb"}),
("nvme0n1", 1024, "disk", {"tran": "nvme", "rota": False}),
)
assert _names(parse_lsblk_json(out, boot_disk="sdb")) == ["/dev/nvme0n1", "/dev/sda"]
def test_boot_device_from_cmdline(monkeypatch):
calls = []
class R:
returncode = 0
stdout = "/dev/sdb1\n"
monkeypatch.setattr(drives, "_run", lambda cmd, **_: calls.append(cmd) or R())
cmdline = "BOOT_IMAGE=/vmlinuz archisosearchuuid=2026-08-25-10-00-00-00 quiet"
assert _boot_device_from_cmdline(cmdline) == "/dev/sdb1"
assert calls[-1] == ["blkid", "-U", "2026-08-25-10-00-00-00"]
assert _boot_device_from_cmdline("archisolabel=FURTKA_2620") == "/dev/sdb1"
assert calls[-1] == ["blkid", "-L", "FURTKA_2620"]
assert _boot_device_from_cmdline("archisodevice=/dev/sr0") == "/dev/sr0"
assert _boot_device_from_cmdline("quiet splash") is None
def test_boot_disk_whole_disk_iso(monkeypatch):
# dd-written stick: ISO filesystem on the whole disk, PKNAME empty.
class R:
def __init__(self, out):
self.returncode = 0
self.stdout = out
def fake_run(cmd, **_):
if cmd[0] == "findmnt":
return R("/dev/sdb\n")
return R("\n")
monkeypatch.setattr(drives, "_run", fake_run)
assert drives._boot_disk_name() == "sdb"
def test_parse_lsblk_handles_empty_output():
assert parse_lsblk_output("") == []

View file

@ -1,350 +0,0 @@
"""Tests for furtka.gateway — the box-side register/rotate/deregister client.
Same shape as test_updater.py's network tests: monkeypatch
urllib.request.urlopen directly rather than standing up a real server.
"""
from __future__ import annotations
import json
import urllib.error
import pytest
from furtka import gateway
class _FakeResp:
def __init__(self, body: dict):
self._body = json.dumps(body).encode()
def __enter__(self):
return self
def __exit__(self, *a):
return False
def read(self):
return self._body
WG = {
"private_key": "cHJpdmF0ZS1rZXk=",
"address": "10.8.0.2/24",
"server_public_key": "c2VydmVyLXB1YmxpYy1rZXk=",
"endpoint": "gateway.example.com:51820",
"allowed_ips": "10.8.0.0/24",
}
@pytest.fixture
def gw(tmp_path, monkeypatch):
monkeypatch.setenv("FURTKA_GATEWAY_CONFIG", str(tmp_path / "gateway.json"))
monkeypatch.setenv("FURTKA_GATEWAY_CONNECT_STATE", str(tmp_path / "gateway-connect-state.json"))
monkeypatch.setenv("FURTKA_GATEWAY_CONNECT_LOCK", str(tmp_path / "gateway-connect.lock"))
import importlib
from furtka import paths as p
importlib.reload(p)
importlib.reload(gateway)
return gateway
def test_load_config_returns_none_when_never_registered(gw):
assert gw.load_config() is None
def test_register_saves_config(gw, monkeypatch):
calls = []
def fake_urlopen(req, timeout):
calls.append((req.full_url, json.loads(req.data)))
return _FakeResp({"box_id": "abc123", "box_token": "abc123.secret", "wg": WG})
monkeypatch.setattr("urllib.request.urlopen", fake_urlopen)
config = gw.register("https://gw.example.com/", "reg-token", "my-box")
assert config["gateway_url"] == "https://gw.example.com" # trailing slash stripped
assert config["box_id"] == "abc123"
assert config["box_token"] == "abc123.secret"
assert config["wg"] == WG
assert gw.load_config() == config
url, body = calls[0]
assert url == "https://gw.example.com/v1/boxes/register"
assert body == {"registration_token": "reg-token", "box_name": "my-box"}
def test_register_config_file_is_0600(gw, monkeypatch, tmp_path):
monkeypatch.setattr(
"urllib.request.urlopen",
lambda req, timeout: _FakeResp({"box_id": "abc", "box_token": "abc.x", "wg": WG}),
)
gw.register("https://gw.example.com", "tok", "box")
from furtka.paths import gateway_config_file
assert oct(gateway_config_file().stat().st_mode)[-3:] == "600"
def test_register_raises_gateway_error_on_http_error(gw, monkeypatch):
def raising(req, timeout):
raise urllib.error.HTTPError(req.full_url, 401, "invalid registration token", {}, None)
monkeypatch.setattr("urllib.request.urlopen", raising)
with pytest.raises(gateway.GatewayError, match="HTTP 401"):
gw.register("https://gw.example.com", "bad-token", "my-box")
assert gw.load_config() is None
def test_rotate_token_updates_stored_config(gw, monkeypatch):
monkeypatch.setattr(
"urllib.request.urlopen",
lambda req, timeout: _FakeResp({"box_id": "abc", "box_token": "abc.old", "wg": WG}),
)
gw.register("https://gw.example.com", "tok", "box")
def fake_rotate(req, timeout):
assert req.headers.get("Authorization") == "Bearer abc.old"
assert req.full_url == "https://gw.example.com/v1/boxes/abc/rotate-token"
return _FakeResp({"box_token": "abc.new"})
monkeypatch.setattr("urllib.request.urlopen", fake_rotate)
new_token = gw.rotate_token()
assert new_token == "abc.new"
assert gw.load_config()["box_token"] == "abc.new"
def test_rotate_token_without_prior_registration_raises(gw):
with pytest.raises(gateway.GatewayError, match="not connected"):
gw.rotate_token()
def test_deregister_calls_gateway_and_clears_config(gw, monkeypatch):
monkeypatch.setattr(
"urllib.request.urlopen",
lambda req, timeout: _FakeResp({"box_id": "abc", "box_token": "abc.x", "wg": WG}),
)
gw.register("https://gw.example.com", "tok", "box")
calls = []
def fake_deregister(req, timeout):
calls.append(req.full_url)
return _FakeResp({"status": "ok"})
monkeypatch.setattr("urllib.request.urlopen", fake_deregister)
gw.deregister()
assert calls == ["https://gw.example.com/v1/boxes/abc/deregister"]
assert gw.load_config() is None
def test_deregister_when_never_registered_is_a_noop(gw, monkeypatch):
calls = []
monkeypatch.setattr("urllib.request.urlopen", lambda req, timeout: calls.append(1))
gw.deregister()
assert calls == []
def test_publish_route_requires_connection(gw):
with pytest.raises(gateway.GatewayError, match="not connected"):
gw.publish_route("vaultwarden", "vault.example.com", 8081)
def test_publish_route_posts_to_routes_endpoint(gw, monkeypatch):
monkeypatch.setattr(
"urllib.request.urlopen",
lambda req, timeout: _FakeResp({"box_id": "abc", "box_token": "abc.x", "wg": WG}),
)
gw.register("https://gw.example.com", "tok", "box")
def fake_publish(req, timeout):
assert req.full_url == "https://gw.example.com/v1/routes"
assert req.headers.get("Authorization") == "Bearer abc.x"
assert json.loads(req.data) == {
"app_name": "vaultwarden",
"subdomain": "vault.example.com",
"port": 8081,
}
return _FakeResp({"route_id": "r1", "public_url": "https://vault.example.com/"})
monkeypatch.setattr("urllib.request.urlopen", fake_publish)
result = gw.publish_route("vaultwarden", "vault.example.com", 8081)
assert result == {"route_id": "r1", "public_url": "https://vault.example.com/"}
def test_unpublish_route_requires_connection(gw):
with pytest.raises(gateway.GatewayError, match="not connected"):
gw.unpublish_route("r1")
def test_unpublish_route_calls_delete_endpoint(gw, monkeypatch):
monkeypatch.setattr(
"urllib.request.urlopen",
lambda req, timeout: _FakeResp({"box_id": "abc", "box_token": "abc.x", "wg": WG}),
)
gw.register("https://gw.example.com", "tok", "box")
calls = []
def fake_delete(req, timeout):
calls.append((req.full_url, req.get_method(), req.headers.get("Authorization")))
return _FakeResp({"status": "ok"})
monkeypatch.setattr("urllib.request.urlopen", fake_delete)
gw.unpublish_route("r1")
assert calls == [("https://gw.example.com/v1/routes/r1", "DELETE", "Bearer abc.x")]
def test_write_and_read_state_round_trip(gw):
gw.write_state("registering")
s = gw.read_state()
assert s["stage"] == "registering"
assert "updated_at" in s
def test_read_state_missing_file_returns_empty_dict(gw):
assert gw.read_state() == {}
def test_acquire_lock_contention(gw):
first = gw.acquire_lock()
try:
with pytest.raises(gateway.GatewayError, match="already in progress"):
gw.acquire_lock()
finally:
first.close()
def test_run_connect_happy_path(gw, monkeypatch):
monkeypatch.setattr(
"urllib.request.urlopen",
lambda req, timeout: _FakeResp({"box_id": "abc", "box_token": "abc.x", "wg": WG}),
)
tunnel_calls = []
monkeypatch.setattr(
gw.wireguard,
"set_tunnel_enabled",
lambda enabled, wg=None: tunnel_calls.append((enabled, wg)),
)
gw.run_connect("https://gw.example.com", "tok", "my-box")
assert tunnel_calls == [(True, WG)]
assert gw.read_state()["stage"] == "done"
assert gw.load_config()["box_id"] == "abc"
def test_run_connect_registration_failure_writes_error_state(gw, monkeypatch):
def raising(req, timeout):
raise urllib.error.HTTPError(req.full_url, 401, "invalid registration token", {}, None)
monkeypatch.setattr("urllib.request.urlopen", raising)
gw.run_connect("https://gw.example.com", "bad-tok", "my-box")
state = gw.read_state()
assert state["stage"] == "error"
assert "401" in state["error"]
assert gw.load_config() is None
def test_run_connect_tunnel_failure_writes_error_state(gw, monkeypatch):
monkeypatch.setattr(
"urllib.request.urlopen",
lambda req, timeout: _FakeResp({"box_id": "abc", "box_token": "abc.x", "wg": WG}),
)
def failing_tunnel(enabled, wg=None):
raise gw.wireguard.WireguardError("systemctl failed: bad config")
monkeypatch.setattr(gw.wireguard, "set_tunnel_enabled", failing_tunnel)
gw.run_connect("https://gw.example.com", "tok", "my-box")
state = gw.read_state()
assert state["stage"] == "error"
assert "bad config" in state["error"]
# Registration itself succeeded — config is still saved even though the
# tunnel failed to come up, so a retry doesn't need to re-register.
assert gw.load_config()["box_id"] == "abc"
def test_run_connect_retry_after_tunnel_failure_reuses_registration(gw, monkeypatch):
register_calls = []
def urlopen(req, timeout):
register_calls.append(req.full_url)
return _FakeResp({"box_id": "abc", "box_token": "abc.x", "wg": WG})
monkeypatch.setattr("urllib.request.urlopen", urlopen)
def failing_tunnel(enabled, wg=None):
raise gw.wireguard.WireguardError("systemctl failed: unit missing")
monkeypatch.setattr(gw.wireguard, "set_tunnel_enabled", failing_tunnel)
gw.run_connect("https://gw.example.com", "tok", "my-box")
assert gw.read_state()["stage"] == "error"
tunnel_calls = []
monkeypatch.setattr(
gw.wireguard,
"set_tunnel_enabled",
lambda enabled, wg=None: tunnel_calls.append((enabled, wg)),
)
gw.run_connect("https://gw.example.com/", "tok", "my-box")
# Registered exactly once; the retry only brought the tunnel up.
assert register_calls == ["https://gw.example.com/v1/boxes/register"]
assert tunnel_calls == [(True, WG)]
assert gw.read_state()["stage"] == "done"
assert gw.load_config()["box_id"] == "abc"
def test_run_connect_refuses_a_different_gateway_while_registered(gw, monkeypatch):
monkeypatch.setattr(
"urllib.request.urlopen",
lambda req, timeout: _FakeResp({"box_id": "abc", "box_token": "abc.x", "wg": WG}),
)
gw.register("https://gw.example.com", "tok", "box")
def must_not_call(req, timeout):
raise AssertionError("must not register with a second gateway")
monkeypatch.setattr("urllib.request.urlopen", must_not_call)
monkeypatch.setattr(gw.wireguard, "set_tunnel_enabled", lambda enabled, wg=None: None)
gw.run_connect("https://other.example.com", "tok", "box")
state = gw.read_state()
assert state["stage"] == "error"
assert "disconnect first" in state["error"]
assert gw.load_config()["gateway_url"] == "https://gw.example.com"
def test_deregister_failure_keeps_local_config_for_retry(gw, monkeypatch):
monkeypatch.setattr(
"urllib.request.urlopen",
lambda req, timeout: _FakeResp({"box_id": "abc", "box_token": "abc.x", "wg": WG}),
)
gw.register("https://gw.example.com", "tok", "box")
def raising(req, timeout):
raise urllib.error.URLError("connection refused")
monkeypatch.setattr("urllib.request.urlopen", raising)
with pytest.raises(gateway.GatewayError):
gw.deregister()
# Local config must survive so the caller can retry later.
assert gw.load_config() is not None

View file

@ -1,15 +1,11 @@
"""Tests for furtka.https — fingerprint extraction + HTTPS toggle.
Since 26.15-alpha the toggle writes/removes TWO snippets atomically:
- The top-level HTTPS listener snippet (enables :443 + tls internal)
- The :80-scoped redirect snippet (forces HTTP → HTTPS)
"""Tests for furtka.https — fingerprint extraction + force-HTTPS toggle.
The fingerprint case uses a throwaway self-signed EC cert with a known
reference fingerprint (computed once via `openssl x509 -fingerprint
-sha256 -noout`) so we verify the PEM → DER → SHA256 path without a
runtime subprocess dependency. The toggle cases stub the caddy reload
so we assert both snippet files are written / removed together and that
reload failures roll BOTH state back.
so we assert the snippet file is written / removed and that reload
failures roll state back.
"""
import subprocess
@ -38,22 +34,6 @@ _TEST_CERT_FP_SHA256 = (
)
def _paths(tmp_path):
"""Return the four paths the toggle touches, in a dict for kwargs
spreading. Keeps each test's fixture boilerplate small."""
return {
"snippet_dir": tmp_path / "furtka.d",
"snippet": tmp_path / "furtka.d" / "redirect.caddyfile",
"https_snippet_dir": tmp_path / "furtka-https.d",
"https_snippet": tmp_path / "furtka-https.d" / "https.caddyfile",
"hostname_file": tmp_path / "etc_hostname",
}
def _prepare_hostname(tmp_path, value="testbox"):
(tmp_path / "etc_hostname").write_text(f"{value}\n")
def test_ca_fingerprint_matches_openssl(tmp_path):
cert = tmp_path / "root.crt"
cert.write_text(_TEST_CERT_PEM)
@ -73,7 +53,7 @@ def test_ca_fingerprint_no_pem_block(tmp_path):
def test_status_no_ca_no_snippet(tmp_path):
s = https.status(ca_path=tmp_path / "root.crt", https_snippet=tmp_path / "https.caddyfile")
s = https.status(ca_path=tmp_path / "root.crt", snippet=tmp_path / "redirect.caddyfile")
assert s == {
"ca_available": False,
"fingerprint_sha256": None,
@ -82,141 +62,105 @@ def test_status_no_ca_no_snippet(tmp_path):
}
def test_status_with_ca_and_https_snippet(tmp_path):
def test_status_with_ca_and_snippet(tmp_path):
ca = tmp_path / "root.crt"
ca.write_text(_TEST_CERT_PEM)
https_snip = tmp_path / "https.caddyfile"
https_snip.write_text("furtka.local, furtka {\n\ttls internal\n\timport furtka_routes\n}\n")
s = https.status(ca_path=ca, https_snippet=https_snip)
snippet = tmp_path / "redirect.caddyfile"
snippet.write_text(https.REDIRECT_CONTENT)
s = https.status(ca_path=ca, snippet=snippet)
assert s["ca_available"] is True
assert s["fingerprint_sha256"] == _TEST_CERT_FP_SHA256
assert s["force_https"] is True
def test_status_force_reflects_https_snippet_not_redirect(tmp_path):
"""Authoritative signal for "HTTPS is on" is the listener snippet —
a lone redirect without a :443 listener wouldn't actually serve
HTTPS, so the status must NOT report it as on. Locks 26.15 semantic."""
ca = tmp_path / "root.crt"
ca.write_text(_TEST_CERT_PEM)
s = https.status(ca_path=ca, https_snippet=tmp_path / "does-not-exist.caddyfile")
assert s["force_https"] is False
def test_set_force_enable_writes_both_snippets_and_reloads(tmp_path):
_prepare_hostname(tmp_path)
p = _paths(tmp_path)
def test_set_force_enable_writes_snippet_and_reloads(tmp_path):
snippet_dir = tmp_path / "furtka.d"
snippet = snippet_dir / "redirect.caddyfile"
calls = []
def fake_reload():
calls.append("reload")
result = https.set_force_https(True, reload_caddy=fake_reload, **p)
result = https.set_force_https(
True, snippet_dir=snippet_dir, snippet=snippet, reload_caddy=fake_reload
)
assert result is True
assert p["snippet"].read_text() == https._redirect_snippet_content("testbox")
written = p["https_snippet"].read_text()
assert "testbox.local, testbox" in written
assert "tls internal" in written
assert "import furtka_routes" in written
assert snippet.read_text() == https.REDIRECT_CONTENT
assert calls == ["reload"]
def test_set_force_uses_fallback_hostname_when_file_missing(tmp_path):
# No /etc/hostname → fall back to 'furtka' so Caddy gets a parseable
# block instead of an empty hostname that would fail config load.
p = _paths(tmp_path)
result = https.set_force_https(True, reload_caddy=lambda: None, **p)
assert result is True
assert "furtka.local, furtka" in p["https_snippet"].read_text()
def test_set_force_disable_removes_snippet(tmp_path):
snippet_dir = tmp_path / "furtka.d"
snippet_dir.mkdir()
snippet = snippet_dir / "redirect.caddyfile"
snippet.write_text(https.REDIRECT_CONTENT)
def test_set_force_disable_removes_both_snippets(tmp_path):
_prepare_hostname(tmp_path)
p = _paths(tmp_path)
p["snippet_dir"].mkdir()
p["https_snippet_dir"].mkdir()
p["snippet"].write_text(https._redirect_snippet_content("testbox"))
p["https_snippet"].write_text("furtka.local { tls internal }\n")
result = https.set_force_https(False, reload_caddy=lambda: None, **p)
result = https.set_force_https(
False, snippet_dir=snippet_dir, snippet=snippet, reload_caddy=lambda: None
)
assert result is False
assert not p["snippet"].exists()
assert not p["https_snippet"].exists()
assert not snippet.exists()
def test_set_force_disable_is_idempotent_when_already_off(tmp_path):
p = _paths(tmp_path)
result = https.set_force_https(False, reload_caddy=lambda: None, **p)
snippet_dir = tmp_path / "furtka.d"
snippet = snippet_dir / "redirect.caddyfile"
result = https.set_force_https(
False, snippet_dir=snippet_dir, snippet=snippet, reload_caddy=lambda: None
)
assert result is False
assert not p["snippet"].exists()
assert not p["https_snippet"].exists()
assert not snippet.exists()
def test_reload_failure_rolls_back_enable(tmp_path):
_prepare_hostname(tmp_path)
p = _paths(tmp_path)
snippet_dir = tmp_path / "furtka.d"
snippet = snippet_dir / "redirect.caddyfile"
def failing_reload():
raise subprocess.CalledProcessError(1, ["systemctl"], stderr="bad config")
with pytest.raises(https.HttpsError, match="caddy reload failed: bad config"):
https.set_force_https(True, reload_caddy=failing_reload, **p)
# Rollback: since neither snippet existed before, neither exists after.
assert not p["snippet"].exists()
assert not p["https_snippet"].exists()
https.set_force_https(
True, snippet_dir=snippet_dir, snippet=snippet, reload_caddy=failing_reload
)
# Rollback: since snippet didn't exist before, it must not exist after.
assert not snippet.exists()
def test_reload_failure_rolls_back_disable(tmp_path):
_prepare_hostname(tmp_path)
p = _paths(tmp_path)
p["snippet_dir"].mkdir()
p["https_snippet_dir"].mkdir()
original_redirect = "redir https://{host}{uri} permanent\n# marker\n"
original_https = "# old https block\nfurtka.local { tls internal }\n"
p["snippet"].write_text(original_redirect)
p["https_snippet"].write_text(original_https)
snippet_dir = tmp_path / "furtka.d"
snippet_dir.mkdir()
snippet = snippet_dir / "redirect.caddyfile"
original = "redir https://{host}{uri} permanent\n# marker\n"
snippet.write_text(original)
def failing_reload():
raise subprocess.CalledProcessError(1, ["systemctl"], stderr="bad config")
with pytest.raises(https.HttpsError):
https.set_force_https(False, reload_caddy=failing_reload, **p)
# Rollback: both snippets are restored to their exact prior contents.
assert p["snippet"].read_text() == original_redirect
assert p["https_snippet"].read_text() == original_https
https.set_force_https(
False, snippet_dir=snippet_dir, snippet=snippet, reload_caddy=failing_reload
)
# Rollback: snippet is restored to its exact prior contents.
assert snippet.read_text() == original
def test_systemctl_missing_raises_and_rolls_back(tmp_path):
_prepare_hostname(tmp_path)
p = _paths(tmp_path)
snippet_dir = tmp_path / "furtka.d"
snippet = snippet_dir / "redirect.caddyfile"
def missing_systemctl():
raise FileNotFoundError(2, "No such file", "systemctl")
with pytest.raises(https.HttpsError, match="systemctl not available"):
https.set_force_https(True, reload_caddy=missing_systemctl, **p)
assert not p["snippet"].exists()
assert not p["https_snippet"].exists()
def test_redirect_snippet_only_covers_cert_names_and_is_temporary():
# Lock the exact directives. A regression here silently stops the
# redirect from taking effect even though the file-swap looks fine.
# Only the names the tls-internal block has a cert for are redirected
# (an IP would hit a failing TLS handshake), and 302 so switching HTTPS
# off isn't stuck in browser caches like the old 301 was.
assert https._redirect_snippet_content("mybox") == (
"@furtka_https_host host mybox.local mybox\n"
"redir @furtka_https_host https://{host}{uri} 302\n"
https.set_force_https(
True, snippet_dir=snippet_dir, snippet=snippet, reload_caddy=missing_systemctl
)
assert not snippet.exists()
def test_https_snippet_content_has_tls_internal_and_routes(tmp_path):
# Lock the shape of the opt-in HTTPS listener block. Caddy parses
# this verbatim — changing the shape without updating the test
# risks shipping a silently-broken Caddyfile import.
s = https._https_snippet_content("mybox")
assert "mybox.local, mybox {" in s
assert "\ttls internal" in s
assert "\timport furtka_routes" in s
assert s.endswith("}\n")
def test_redirect_snippet_content_is_caddy_redir_directive():
# Lock the exact directive. A regression here silently stops the
# redirect from taking effect even though the file-swap looks fine.
assert https.REDIRECT_CONTENT.strip() == "redir https://{host}{uri} permanent"

View file

@ -1,512 +0,0 @@
"""Tests for the background app-install runner.
Same shape as test_catalog.py / test_updater.py: fixture reloads the
module with env-overridden paths, dockerops calls are stubbed so nothing
touches a real daemon. Asserts that state transitions happen in the
right order and that exceptions flip the state to "error" with the
message before re-raising.
"""
from __future__ import annotations
import json
from pathlib import Path
import pytest
@pytest.fixture
def runner(tmp_path, monkeypatch):
apps = tmp_path / "apps"
apps.mkdir()
monkeypatch.setenv("FURTKA_APPS_DIR", str(apps))
monkeypatch.setenv("FURTKA_INSTALL_STATE", str(tmp_path / "install-state.json"))
monkeypatch.setenv("FURTKA_INSTALL_PLAN", str(tmp_path / "install-plan.json"))
monkeypatch.setenv("FURTKA_INSTALL_LOCK", str(tmp_path / "install.lock"))
import importlib
from furtka import install_runner as r
from furtka import paths as p
importlib.reload(p)
importlib.reload(r)
return r
def _write_installed_app(apps_dir: Path, name: str = "fileshare", **overrides):
app = apps_dir / name
app.mkdir()
manifest = {
"name": name,
"display_name": "Fileshare",
"version": "0.1.0",
"description": "Test fixture",
"volumes": ["files"],
"ports": [445],
"icon": "icon.svg",
**overrides,
}
(app / "manifest.json").write_text(json.dumps(manifest))
(app / "docker-compose.yaml").write_text("services: {}\n")
return app
def test_write_and_read_state_round_trip(runner):
runner.write_state("pulling_image", app="jellyfin")
s = runner.read_state()
assert s["stage"] == "pulling_image"
assert s["app"] == "jellyfin"
assert "updated_at" in s
def test_read_state_returns_empty_when_missing(runner):
assert runner.read_state() == {}
def test_read_state_returns_empty_on_junk(runner):
runner.state_path().parent.mkdir(parents=True, exist_ok=True)
runner.state_path().write_text("{not json")
assert runner.read_state() == {}
def test_acquire_lock_prevents_concurrent_runs(runner):
held = runner.acquire_lock()
try:
with pytest.raises(runner.InstallRunnerError, match="in progress"):
runner.acquire_lock()
finally:
held.close()
def test_run_install_happy_path(runner, monkeypatch):
import furtka.dockerops as dockerops
from furtka.paths import apps_dir
_write_installed_app(apps_dir(), "fileshare")
calls = []
monkeypatch.setattr(dockerops, "compose_pull", lambda *a, **k: calls.append(("pull", a)))
monkeypatch.setattr(dockerops, "ensure_volume", lambda name: calls.append(("vol", name)))
monkeypatch.setattr(dockerops, "compose_up", lambda *a, **k: calls.append(("up", a)))
runner.run_install("fileshare")
# Ordering: pull first, then volumes, then up.
assert [c[0] for c in calls] == ["pull", "vol", "up"]
# Exactly the namespaced volume name got created.
assert calls[1] == ("vol", "furtka_fileshare_files")
# Final state is "done" with the manifest version.
s = runner.read_state()
assert s["stage"] == "done"
assert s["app"] == "fileshare"
assert s["version"] == "0.1.0"
def test_run_install_writes_error_on_pull_failure(runner, monkeypatch):
import furtka.dockerops as dockerops
from furtka.paths import apps_dir
_write_installed_app(apps_dir(), "fileshare")
def boom(*a, **k):
raise dockerops.DockerError("pull failed: registry unreachable")
monkeypatch.setattr(dockerops, "compose_pull", boom)
monkeypatch.setattr(dockerops, "ensure_volume", lambda name: None)
monkeypatch.setattr(dockerops, "compose_up", lambda *a, **k: None)
with pytest.raises(dockerops.DockerError):
runner.run_install("fileshare")
s = runner.read_state()
assert s["stage"] == "error"
assert s["app"] == "fileshare"
assert "registry unreachable" in s["error"]
def test_run_install_writes_error_on_up_failure(runner, monkeypatch):
import furtka.dockerops as dockerops
from furtka.paths import apps_dir
_write_installed_app(apps_dir(), "fileshare")
monkeypatch.setattr(dockerops, "compose_pull", lambda *a, **k: None)
monkeypatch.setattr(dockerops, "ensure_volume", lambda name: None)
def boom(*a, **k):
raise dockerops.DockerError("compose up: container refused to start")
monkeypatch.setattr(dockerops, "compose_up", boom)
with pytest.raises(dockerops.DockerError):
runner.run_install("fileshare")
s = runner.read_state()
assert s["stage"] == "error"
assert "refused to start" in s["error"]
def test_run_install_releases_lock_after_done(runner, monkeypatch):
import furtka.dockerops as dockerops
from furtka.paths import apps_dir
_write_installed_app(apps_dir(), "fileshare")
monkeypatch.setattr(dockerops, "compose_pull", lambda *a, **k: None)
monkeypatch.setattr(dockerops, "ensure_volume", lambda name: None)
monkeypatch.setattr(dockerops, "compose_up", lambda *a, **k: None)
runner.run_install("fileshare")
# Lock released — a fresh acquire must succeed.
fh = runner.acquire_lock()
fh.close()
def test_run_install_releases_lock_after_error(runner, monkeypatch):
import furtka.dockerops as dockerops
from furtka.paths import apps_dir
_write_installed_app(apps_dir(), "fileshare")
monkeypatch.setattr(
dockerops, "compose_pull", lambda *a, **k: (_ for _ in ()).throw(dockerops.DockerError("x"))
)
with pytest.raises(dockerops.DockerError):
runner.run_install("fileshare")
fh = runner.acquire_lock()
fh.close()
# --- plan-aware multi-app installs -------------------------------------------
def _write_plan(plan_path: Path, target: str, to_install: list[str]) -> None:
plan_path.write_text(json.dumps({"target": target, "to_install": to_install}))
def _stub_docker_ops(monkeypatch, calls: list):
import furtka.dockerops as dockerops
def _pull(app_dir, project):
calls.append(("pull", project))
def _vol(name):
calls.append(("vol", name))
def _up(app_dir, project):
calls.append(("up", project))
monkeypatch.setattr(dockerops, "compose_pull", _pull)
monkeypatch.setattr(dockerops, "ensure_volume", _vol)
monkeypatch.setattr(dockerops, "compose_up", _up)
def test_run_install_iterates_plan_order(runner, monkeypatch):
from furtka.paths import apps_dir
_write_installed_app(apps_dir(), "mosquitto")
_write_installed_app(
apps_dir(),
"zigbee2mqtt",
requires=[{"app": "mosquitto"}],
)
_write_plan(runner.plan_path(), "zigbee2mqtt", ["mosquitto", "zigbee2mqtt"])
calls: list = []
_stub_docker_ops(monkeypatch, calls)
runner.run_install("zigbee2mqtt")
# mosquitto fully reconciled before zigbee2mqtt starts.
assert [c for c in calls if c[0] == "pull"] == [("pull", "mosquitto"), ("pull", "zigbee2mqtt")]
assert [c for c in calls if c[0] == "up"] == [("up", "mosquitto"), ("up", "zigbee2mqtt")]
s = runner.read_state()
assert s["stage"] == "done"
assert s["target"] == "zigbee2mqtt"
assert s["app"] == "zigbee2mqtt"
def test_run_install_fires_on_install_hook_against_provider(runner, monkeypatch):
import furtka.dockerops as dockerops
from furtka.paths import apps_dir
mosq = _write_installed_app(apps_dir(), "mosquitto")
# Provider ships a hook script.
(mosq / "hooks").mkdir()
hook = mosq / "hooks" / "create-user.sh"
hook.write_bytes(b"#!/bin/sh\necho MQTT_USER=z2m\necho MQTT_PASS=hunter2\n")
consumer = _write_installed_app(
apps_dir(),
"zigbee2mqtt",
requires=[{"app": "mosquitto", "on_install": "hooks/create-user.sh"}],
)
# Consumer's .env starts empty.
(consumer / ".env").write_text("")
_write_plan(runner.plan_path(), "zigbee2mqtt", ["mosquitto", "zigbee2mqtt"])
calls: list = []
_stub_docker_ops(monkeypatch, calls)
captured = {}
def fake_exec_script(app_dir, project, service, script_path, *, env, timeout):
captured["app_dir"] = app_dir
captured["project"] = project
captured["service"] = service
captured["script_path"] = script_path
captured["env"] = env
captured["timeout"] = timeout
return "MQTT_USER=z2m\nMQTT_PASS=hunter2\n"
# Tell the provider_exec_service helper to pick a deterministic service.
monkeypatch.setattr(
dockerops, "compose_image_tags", lambda a, p: {"mosquitto": "eclipse-mosquitto:2"}
)
monkeypatch.setattr(dockerops, "compose_exec_script", fake_exec_script)
runner.run_install("zigbee2mqtt")
# Hook was called against the provider, with the consumer's name + version
# in env, and the timeout we expect.
assert captured["project"] == "mosquitto"
assert captured["service"] == "mosquitto"
assert captured["script_path"] == hook
assert captured["env"] == {
"FURTKA_CONSUMER_APP": "zigbee2mqtt",
"FURTKA_CONSUMER_VERSION": "0.1.0",
}
assert captured["timeout"] == 60.0
# Consumer's .env now has the hook output.
env_text = (consumer / ".env").read_text()
assert "MQTT_USER=z2m" in env_text
assert "MQTT_PASS=hunter2" in env_text
# Mode 0600.
assert (consumer / ".env").stat().st_mode & 0o777 == 0o600
def test_run_install_hook_furtka_json_sentinel(runner, monkeypatch):
import furtka.dockerops as dockerops
from furtka.paths import apps_dir
_write_installed_app(apps_dir(), "mosquitto")
consumer = _write_installed_app(
apps_dir(),
"z2m",
requires=[{"app": "mosquitto", "on_install": "hooks/x.sh"}],
)
(apps_dir() / "mosquitto" / "hooks").mkdir()
(apps_dir() / "mosquitto" / "hooks" / "x.sh").write_bytes(b"")
(consumer / ".env").write_text("")
_write_plan(runner.plan_path(), "z2m", ["mosquitto", "z2m"])
calls: list = []
_stub_docker_ops(monkeypatch, calls)
monkeypatch.setattr(dockerops, "compose_image_tags", lambda a, p: {"mosquitto": "img"})
# Hook output mixes plain KEY=VALUE and a FURTKA_JSON sentinel. JSON
# wins on conflict (overlays plain).
monkeypatch.setattr(
dockerops,
"compose_exec_script",
lambda *a, **k: 'MQTT_USER=oldval\nFURTKA_JSON: {"MQTT_USER": "newval", "TOKEN": "abc"}\n',
)
runner.run_install("z2m")
env_text = (consumer / ".env").read_text()
assert "MQTT_USER=newval" in env_text # JSON overlay wins
assert "TOKEN=abc" in env_text
def test_run_install_hook_rejects_bad_key_name(runner, monkeypatch):
import furtka.dockerops as dockerops
from furtka.paths import apps_dir
_write_installed_app(apps_dir(), "mosquitto")
consumer = _write_installed_app(
apps_dir(),
"z2m",
requires=[{"app": "mosquitto", "on_install": "hooks/x.sh"}],
)
(apps_dir() / "mosquitto" / "hooks").mkdir()
(apps_dir() / "mosquitto" / "hooks" / "x.sh").write_bytes(b"")
(consumer / ".env").write_text("")
_write_plan(runner.plan_path(), "z2m", ["mosquitto", "z2m"])
calls: list = []
_stub_docker_ops(monkeypatch, calls)
monkeypatch.setattr(dockerops, "compose_image_tags", lambda a, p: {"mosquitto": "img"})
monkeypatch.setattr(dockerops, "compose_exec_script", lambda *a, **k: "lowercase_key=oops\n")
with pytest.raises(runner.InstallRunnerError, match="UPPER_SNAKE_CASE"):
runner.run_install("z2m")
s = runner.read_state()
assert s["stage"] == "error"
# Consumer's compose_up was never called because the hook failed.
assert not any(c[0] == "up" and c[1] == "z2m" for c in calls)
def test_run_install_hook_rejects_placeholder_value(runner, monkeypatch):
import furtka.dockerops as dockerops
from furtka.paths import apps_dir
_write_installed_app(apps_dir(), "mosquitto")
consumer = _write_installed_app(
apps_dir(),
"z2m",
requires=[{"app": "mosquitto", "on_install": "hooks/x.sh"}],
)
(apps_dir() / "mosquitto" / "hooks").mkdir()
(apps_dir() / "mosquitto" / "hooks" / "x.sh").write_bytes(b"")
(consumer / ".env").write_text("")
_write_plan(runner.plan_path(), "z2m", ["mosquitto", "z2m"])
calls: list = []
_stub_docker_ops(monkeypatch, calls)
monkeypatch.setattr(dockerops, "compose_image_tags", lambda a, p: {"mosquitto": "img"})
monkeypatch.setattr(dockerops, "compose_exec_script", lambda *a, **k: "MQTT_PASS=changeme\n")
with pytest.raises(runner.InstallRunnerError, match="placeholder"):
runner.run_install("z2m")
def test_run_install_hook_failure_skips_consumer_compose_up(runner, monkeypatch):
import furtka.dockerops as dockerops
from furtka.paths import apps_dir
_write_installed_app(apps_dir(), "mosquitto")
consumer = _write_installed_app(
apps_dir(),
"z2m",
requires=[{"app": "mosquitto", "on_install": "hooks/x.sh"}],
)
(apps_dir() / "mosquitto" / "hooks").mkdir()
(apps_dir() / "mosquitto" / "hooks" / "x.sh").write_bytes(b"")
(consumer / ".env").write_text("")
_write_plan(runner.plan_path(), "z2m", ["mosquitto", "z2m"])
calls: list = []
_stub_docker_ops(monkeypatch, calls)
monkeypatch.setattr(dockerops, "compose_image_tags", lambda a, p: {"mosquitto": "img"})
def boom(*a, **k):
raise dockerops.DockerError("hook returned 1: connection refused")
monkeypatch.setattr(dockerops, "compose_exec_script", boom)
with pytest.raises(dockerops.DockerError):
runner.run_install("z2m")
s = runner.read_state()
assert s["stage"] == "error"
assert s["target"] == "z2m"
# The provider's compose_up DID run earlier in the plan.
assert ("up", "mosquitto") in calls
# But the consumer's never did.
assert ("up", "z2m") not in calls
def test_run_install_missing_provider_hook_file_raises(runner, monkeypatch):
import furtka.dockerops as dockerops
from furtka.paths import apps_dir
_write_installed_app(apps_dir(), "mosquitto")
consumer = _write_installed_app(
apps_dir(),
"z2m",
requires=[{"app": "mosquitto", "on_install": "hooks/missing.sh"}],
)
(consumer / ".env").write_text("")
_write_plan(runner.plan_path(), "z2m", ["mosquitto", "z2m"])
calls: list = []
_stub_docker_ops(monkeypatch, calls)
monkeypatch.setattr(dockerops, "compose_image_tags", lambda a, p: {"mosquitto": "img"})
with pytest.raises(runner.InstallRunnerError, match="missing in provider"):
runner.run_install("z2m")
def test_run_install_plan_file_is_consumed_after_read(runner, monkeypatch):
"""After a run, the plan file is removed so a stale plan can't steer the next run."""
from furtka.paths import apps_dir
_write_installed_app(apps_dir(), "fileshare")
_write_plan(runner.plan_path(), "fileshare", ["fileshare"])
calls: list = []
_stub_docker_ops(monkeypatch, calls)
runner.run_install("fileshare")
assert not runner.plan_path().exists()
# --- _parse_hook_output (unit) -----------------------------------------------
def test_parse_hook_output_kv_only(runner):
out = runner._parse_hook_output("MQTT_USER=z2m\nMQTT_PASS=hunter2\n")
assert out == {"MQTT_USER": "z2m", "MQTT_PASS": "hunter2"}
def test_parse_hook_output_rejects_lowercase_key(runner):
with pytest.raises(runner.InstallRunnerError, match="UPPER_SNAKE_CASE"):
runner._parse_hook_output("lowercase=oops\n")
def test_parse_hook_output_furtka_json(runner):
out = runner._parse_hook_output('FURTKA_JSON: {"FOO": "bar", "BAZ": "qux"}\n')
assert out == {"FOO": "bar", "BAZ": "qux"}
def test_parse_hook_output_furtka_json_rejects_non_string(runner):
with pytest.raises(runner.InstallRunnerError, match="must be a string"):
runner._parse_hook_output('FURTKA_JSON: {"FOO": 42}\n')
def test_parse_hook_output_furtka_json_rejects_bad_payload(runner):
with pytest.raises(runner.InstallRunnerError, match="must be an object"):
runner._parse_hook_output('FURTKA_JSON: ["not", "a", "dict"]\n')
def test_parse_hook_output_furtka_json_invalid_json(runner):
with pytest.raises(runner.InstallRunnerError, match="invalid FURTKA_JSON"):
runner._parse_hook_output("FURTKA_JSON: {not json}\n")
def test_clear_stale_state_resets_abandoned_install(runner):
import time
runner.write_state("pulling_image", app="fileshare")
# Fresh state: could still be the runner starting up — leave it alone.
assert runner.clear_stale_state() is False
assert runner.read_state()["stage"] == "pulling_image"
# Two minutes later with nobody holding the lock: interrupted.
assert runner.clear_stale_state(now=time.time() + 120) is True
state = runner.read_state()
assert state["stage"] == "error"
assert state["app"] == "fileshare"
assert "interrupted" in state["error"]
def test_clear_stale_state_keeps_running_install(runner):
import time
runner.write_state("pulling_image", app="fileshare")
fh = runner.acquire_lock()
try:
assert runner.clear_stale_state(now=time.time() + 3600) is False
assert runner.read_state()["stage"] == "pulling_image"
finally:
fh.close()
def test_read_state_tolerates_non_object(runner):
runner.state_path().write_text("[1, 2]")
assert runner.read_state() == {}

View file

@ -73,31 +73,6 @@ def test_install_from_copies_files(tmp_path, fake_dirs):
assert (target / ".env").read_text() == "A=1"
def test_install_from_copies_subdirectories(tmp_path, fake_dirs):
# Dependency hooks live under scripts/ — the whole subtree must be copied,
# not just top-level files, or providers ship without their hooks.
src = _write_app_source(tmp_path, "mosquitto", VALID_MANIFEST, env_example="A=1")
scripts = src / "scripts"
scripts.mkdir()
(scripts / "provision-client.sh").write_text("#!/bin/sh\necho hi\n")
target = installer.install_from(src)
assert (target / "scripts" / "provision-client.sh").read_text() == "#!/bin/sh\necho hi\n"
def test_install_from_reinstall_drops_stale_subdir_files(tmp_path, fake_dirs):
# A reinstall whose source renamed/removed a hook must not leave the old one.
src = _write_app_source(tmp_path, "mosquitto", VALID_MANIFEST, env_example="A=1")
scripts = src / "scripts"
scripts.mkdir()
(scripts / "old-hook.sh").write_text("old\n")
installer.install_from(src)
(scripts / "old-hook.sh").unlink()
(scripts / "new-hook.sh").write_text("new\n")
target = installer.install_from(src)
assert not (target / "scripts" / "old-hook.sh").exists()
assert (target / "scripts" / "new-hook.sh").read_text() == "new\n"
def test_install_from_preserves_existing_env(tmp_path, fake_dirs):
src = _write_app_source(tmp_path, "fileshare", VALID_MANIFEST, env_example="A=new")
target = apps_dir() / "fileshare"
@ -292,228 +267,3 @@ def test_read_env_values_roundtrip(tmp_path, fake_dirs):
write_env(p, {"A": "plain", "B": "has space", "C": 'has "quote"', "D": ""})
values = read_env_values(p)
assert values == {"A": "plain", "B": "has space", "C": 'has "quote"', "D": ""}
# --- path-type settings ------------------------------------------------------
PATH_MANIFEST = dict(
VALID_MANIFEST,
name="jellyfin",
settings=[
{
"name": "MEDIA_PATH",
"label": "Medienordner",
"type": "path",
"required": True,
}
],
)
OPTIONAL_PATH_MANIFEST = dict(
VALID_MANIFEST,
name="jellyfin",
settings=[{"name": "OPTIONAL_PATH", "label": "Optional", "type": "path", "required": False}],
)
def test_install_with_valid_path_succeeds(tmp_path, fake_dirs):
media = tmp_path / "media"
media.mkdir()
src = _write_app_source(tmp_path, "jellyfin", PATH_MANIFEST)
installer.install_from(src, settings={"MEDIA_PATH": str(media)})
target = apps_dir() / "jellyfin"
assert f"MEDIA_PATH={media}" in (target / ".env").read_text()
def test_install_rejects_nonexistent_path(tmp_path, fake_dirs):
src = _write_app_source(tmp_path, "jellyfin", PATH_MANIFEST)
with pytest.raises(installer.InstallError, match="does not exist"):
installer.install_from(src, settings={"MEDIA_PATH": str(tmp_path / "ghost")})
def test_install_rejects_path_that_is_a_file(tmp_path, fake_dirs):
f = tmp_path / "not-a-dir"
f.write_text("hi")
src = _write_app_source(tmp_path, "jellyfin", PATH_MANIFEST)
with pytest.raises(installer.InstallError, match="is not a directory"):
installer.install_from(src, settings={"MEDIA_PATH": str(f)})
def test_install_rejects_relative_path(tmp_path, fake_dirs):
src = _write_app_source(tmp_path, "jellyfin", PATH_MANIFEST)
with pytest.raises(installer.InstallError, match="absolute path"):
installer.install_from(src, settings={"MEDIA_PATH": "media"})
def test_install_rejects_system_path(tmp_path, fake_dirs):
src = _write_app_source(tmp_path, "jellyfin", PATH_MANIFEST)
with pytest.raises(installer.InstallError, match="system path"):
installer.install_from(src, settings={"MEDIA_PATH": "/etc"})
def test_install_rejects_root_filesystem(tmp_path, fake_dirs):
src = _write_app_source(tmp_path, "jellyfin", PATH_MANIFEST)
with pytest.raises(installer.InstallError, match="system path"):
installer.install_from(src, settings={"MEDIA_PATH": "/"})
def test_install_rejects_deny_list_via_traversal(tmp_path, fake_dirs):
# /mnt/../etc resolves to /etc — must be caught after Path.resolve().
src = _write_app_source(tmp_path, "jellyfin", PATH_MANIFEST)
with pytest.raises(installer.InstallError, match="system path"):
installer.install_from(src, settings={"MEDIA_PATH": "/mnt/../etc"})
def test_install_accepts_empty_optional_path(tmp_path, fake_dirs):
src = _write_app_source(tmp_path, "jellyfin", OPTIONAL_PATH_MANIFEST)
installer.install_from(src, settings={"OPTIONAL_PATH": ""})
target = apps_dir() / "jellyfin"
assert (target / ".env").exists()
def test_update_env_rejects_invalid_path(tmp_path, fake_dirs):
# First install with a valid path.
media = tmp_path / "media"
media.mkdir()
src = _write_app_source(tmp_path, "jellyfin", PATH_MANIFEST)
installer.install_from(src, settings={"MEDIA_PATH": str(media)})
# Then try to update to a bad path.
with pytest.raises(installer.InstallError, match="does not exist"):
installer.update_env("jellyfin", {"MEDIA_PATH": str(tmp_path / "ghost")})
# --- parse_env_text ----------------------------------------------------------
def test_parse_env_text_basic():
from furtka.installer import parse_env_text
out = parse_env_text("A=1\nB=two\n#comment\n\nC=three=four\n")
assert out == {"A": "1", "B": "two", "C": "three=four"}
def test_parse_env_text_handles_quoted_values():
from furtka.installer import parse_env_text
out = parse_env_text('A="has space"\nB=\'plain\'\nC="quote \\"inside\\""\n')
assert out == {"A": "has space", "B": "plain", "C": 'quote "inside"'}
def test_parse_env_text_ignores_malformed_lines():
from furtka.installer import parse_env_text
out = parse_env_text("no-equals-sign\n=missing-key\nGOOD=ok\n")
assert out == {"GOOD": "ok", "": "missing-key"}
# --- install_plan driver -----------------------------------------------------
def test_install_plan_calls_install_from_in_order(tmp_path, fake_dirs, monkeypatch):
from furtka.deps import DepPlan
calls: list[tuple[str, dict | None]] = []
def fake_resolve(name):
return tmp_path / "src" / name
def fake_install_from(src, settings=None):
calls.append((src.name, settings))
return apps_dir() / src.name
monkeypatch.setattr(installer, "resolve_source", fake_resolve)
monkeypatch.setattr(installer, "install_from", fake_install_from)
plan = DepPlan(
target="a",
install_order=("c", "b", "a"),
already_installed=frozenset(),
to_install=("c", "b", "a"),
)
out = installer.install_plan(plan, settings_target={"K": "v"})
assert [name for name, _ in calls] == ["c", "b", "a"]
# Only the target receives settings.
assert calls[0] == ("c", None)
assert calls[1] == ("b", None)
assert calls[2] == ("a", {"K": "v"})
assert [p.name for p in out] == ["c", "b", "a"]
def test_install_plan_skips_already_installed(tmp_path, fake_dirs, monkeypatch):
from furtka.deps import DepPlan
calls: list[str] = []
def fake_resolve(name):
return tmp_path / "src" / name
def fake_install_from(src, settings=None):
calls.append(src.name)
return apps_dir() / src.name
monkeypatch.setattr(installer, "resolve_source", fake_resolve)
monkeypatch.setattr(installer, "install_from", fake_install_from)
plan = DepPlan(
target="a",
install_order=("b", "a"),
already_installed=frozenset({"b"}),
to_install=("a",),
)
installer.install_plan(plan)
assert calls == ["a"]
# --- hardening: app names, validate-before-write, .env quoting --------------
@pytest.mark.parametrize("bad", ["/", ".", "..", "../catalog", "a/b", "", "UPPER"])
def test_remove_refuses_non_app_names(fake_dirs, bad):
apps, _ = fake_dirs
(apps / "fileshare").mkdir()
with pytest.raises(installer.InstallError, match="not a valid app name"):
installer.remove(bad)
assert (apps / "fileshare").exists()
assert apps.exists()
def test_update_env_refuses_non_app_names(fake_dirs):
with pytest.raises(installer.InstallError, match="not a valid app name"):
installer.update_env("..", {"X": "1"})
def test_rejected_path_setting_never_reaches_env(tmp_path, fake_dirs):
media = tmp_path / "media"
media.mkdir()
src = _write_app_source(tmp_path, "jellyfin", PATH_MANIFEST)
installer.install_from(src, settings={"MEDIA_PATH": str(media)})
env = apps_dir() / "jellyfin" / ".env"
with pytest.raises(installer.InstallError, match="system path"):
installer.update_env("jellyfin", {"MEDIA_PATH": "/etc"})
assert "/etc" not in env.read_text()
assert f"MEDIA_PATH={media}" in env.read_text()
def test_newline_in_setting_is_rejected(tmp_path, fake_dirs):
m = dict(VALID_MANIFEST, settings=[{"name": "PW", "label": "PW", "type": "password"}])
src = _write_app_source(tmp_path, "fileshare", m)
with pytest.raises(installer.InstallError, match="line breaks"):
installer.install_from(src, settings={"PW": "abc\nCOMPOSE_PROJECT_NAME=x"})
env = apps_dir() / "fileshare" / ".env"
assert not env.exists() or "COMPOSE_PROJECT_NAME" not in env.read_text()
def test_dollar_values_are_single_quoted_and_round_trip(tmp_path):
env = tmp_path / ".env"
values = {"PW": "pa$word", "B": "has space", "C": "it's $5", "D": "plain"}
installer.write_env(env, values)
text = env.read_text()
assert "PW='pa$word'" in text
assert "D=plain" in text
assert installer.read_env_values(env) == values
def test_manifest_name_must_be_app_name(tmp_path, fake_dirs):
src = _write_app_source(tmp_path, "evil", dict(VALID_MANIFEST, name="../evil"))
with pytest.raises(installer.InstallError, match="lowercase"):
installer.install_from(src)

View file

@ -95,21 +95,6 @@ def test_settings_optional_default_empty(tmp_path):
m = load_manifest(path)
assert m.settings == ()
assert m.description_long == ""
assert m.open_url == ""
def test_open_url_stored_when_present(tmp_path):
payload = dict(VALID_MANIFEST, open_url="smb://{host}/files")
path = _write_app(tmp_path, "fileshare", payload)
m = load_manifest(path)
assert m.open_url == "smb://{host}/files"
def test_open_url_non_string_rejected(tmp_path):
payload = dict(VALID_MANIFEST, open_url=42)
path = _write_app(tmp_path, "fileshare", payload)
with pytest.raises(ManifestError, match="open_url"):
load_manifest(path)
def test_settings_parsed(tmp_path):
@ -155,27 +140,6 @@ def test_settings_reject_unknown_type(tmp_path):
load_manifest(path)
def test_settings_accept_path_type(tmp_path):
payload = dict(
VALID_MANIFEST,
settings=[
{
"name": "MEDIA_PATH",
"label": "Medienordner",
"description": "Absoluter Pfad zu deinen Medien",
"type": "path",
"required": True,
}
],
)
path = _write_app(tmp_path, "fileshare", payload)
m = load_manifest(path)
assert len(m.settings) == 1
assert m.settings[0].name == "MEDIA_PATH"
assert m.settings[0].type == "path"
assert m.settings[0].required is True
def test_settings_reject_duplicate_name(tmp_path):
bad = dict(
VALID_MANIFEST,
@ -191,158 +155,3 @@ def test_settings_non_list_rejected(tmp_path):
path = _write_app(tmp_path, "fileshare", bad)
with pytest.raises(ManifestError, match="settings must be a list"):
load_manifest(path)
def test_requires_optional_default_empty(tmp_path):
path = _write_app(tmp_path, "fileshare", VALID_MANIFEST)
m = load_manifest(path)
assert m.requires == ()
def test_requires_parsed_full_entry(tmp_path):
payload = dict(
VALID_MANIFEST,
name="zigbee2mqtt",
requires=[
{
"app": "mosquitto",
"on_install": "hooks/create-user.sh",
"on_start": "hooks/ensure-user.sh",
}
],
)
path = _write_app(tmp_path, "zigbee2mqtt", payload)
m = load_manifest(path)
assert len(m.requires) == 1
r = m.requires[0]
assert r.app == "mosquitto"
assert r.on_install == "hooks/create-user.sh"
assert r.on_start == "hooks/ensure-user.sh"
def test_requires_app_only_no_hooks(tmp_path):
payload = dict(VALID_MANIFEST, name="z2m", requires=[{"app": "mosquitto"}])
path = _write_app(tmp_path, "z2m", payload)
m = load_manifest(path)
assert m.requires[0].app == "mosquitto"
assert m.requires[0].on_install is None
assert m.requires[0].on_start is None
def test_requires_rejects_self_reference(tmp_path):
payload = dict(VALID_MANIFEST, requires=[{"app": "fileshare"}])
path = _write_app(tmp_path, "fileshare", payload)
with pytest.raises(ManifestError, match="self-reference"):
load_manifest(path)
def test_requires_rejects_duplicate_app(tmp_path):
payload = dict(
VALID_MANIFEST,
name="z2m",
requires=[{"app": "mosquitto"}, {"app": "mosquitto"}],
)
path = _write_app(tmp_path, "z2m", payload)
with pytest.raises(ManifestError, match="duplicate"):
load_manifest(path)
def test_requires_rejects_traversal_hook_path(tmp_path):
payload = dict(
VALID_MANIFEST,
name="z2m",
requires=[{"app": "mosquitto", "on_install": "../../etc/passwd"}],
)
path = _write_app(tmp_path, "z2m", payload)
with pytest.raises(ManifestError, match=r"must not contain '\.\.'"):
load_manifest(path)
def test_requires_rejects_absolute_hook_path(tmp_path):
payload = dict(
VALID_MANIFEST,
name="z2m",
requires=[{"app": "mosquitto", "on_start": "/tmp/hook.sh"}],
)
path = _write_app(tmp_path, "z2m", payload)
with pytest.raises(ManifestError, match="must be relative"):
load_manifest(path)
def test_requires_non_list_rejected(tmp_path):
payload = dict(VALID_MANIFEST, requires={"app": "mosquitto"})
path = _write_app(tmp_path, "fileshare", payload)
with pytest.raises(ManifestError, match="requires must be a list"):
load_manifest(path)
def test_requires_rejects_invalid_app_name(tmp_path):
payload = dict(VALID_MANIFEST, requires=[{"app": "Bad-Name!"}])
path = _write_app(tmp_path, "fileshare", payload)
with pytest.raises(ManifestError, match="lowercase app name"):
load_manifest(path)
def test_requires_rejects_empty_hook_string(tmp_path):
payload = dict(
VALID_MANIFEST,
name="z2m",
requires=[{"app": "mosquitto", "on_install": ""}],
)
path = _write_app(tmp_path, "z2m", payload)
with pytest.raises(ManifestError, match="non-empty string"):
load_manifest(path)
def test_internet_optional_defaults_non_viable(tmp_path):
path = _write_app(tmp_path, "fileshare", VALID_MANIFEST)
m = load_manifest(path)
assert m.internet.viable is False
assert m.internet.port is None
def test_internet_viable_parsed(tmp_path):
payload = dict(VALID_MANIFEST, internet={"viable": True, "port": 445})
path = _write_app(tmp_path, "fileshare", payload)
m = load_manifest(path)
assert m.internet.viable is True
assert m.internet.port == 445
def test_internet_non_viable_ignores_port(tmp_path):
# A non-viable app doesn't need a port at all — and if one is present
# anyway (e.g. a stale field left behind), it's simply ignored rather
# than validated, since it has no effect either way.
payload = dict(VALID_MANIFEST, internet={"viable": False, "port": 9999})
path = _write_app(tmp_path, "fileshare", payload)
m = load_manifest(path)
assert m.internet.viable is False
assert m.internet.port is None
def test_internet_viable_requires_port(tmp_path):
payload = dict(VALID_MANIFEST, internet={"viable": True})
path = _write_app(tmp_path, "fileshare", payload)
with pytest.raises(ManifestError, match="internet.port must be an integer"):
load_manifest(path)
def test_internet_port_must_be_declared(tmp_path):
payload = dict(VALID_MANIFEST, internet={"viable": True, "port": 9999})
path = _write_app(tmp_path, "fileshare", payload)
with pytest.raises(ManifestError, match="must be one of the declared ports"):
load_manifest(path)
def test_internet_non_object_rejected(tmp_path):
payload = dict(VALID_MANIFEST, internet=True)
path = _write_app(tmp_path, "fileshare", payload)
with pytest.raises(ManifestError, match="internet must be an object"):
load_manifest(path)
def test_internet_port_must_be_int_not_bool(tmp_path):
payload = dict(VALID_MANIFEST, internet={"viable": True, "port": True})
path = _write_app(tmp_path, "fileshare", payload)
with pytest.raises(ManifestError, match="internet.port must be an integer"):
load_manifest(path)

View file

@ -1,74 +0,0 @@
"""Tests for furtka.passwd — stdlib-only password hashing.
The primary contract: hash/verify roundtrips cleanly, AND the verifier
accepts the werkzeug hash format that 26.11 / 26.12 boxes wrote to
``users.json``. Losing that backward compat would lock out existing
admins after a 26.13+ upgrade.
"""
from __future__ import annotations
from furtka import passwd
def test_hash_roundtrip():
h = passwd.hash_password("hunter2")
assert passwd.verify_password("hunter2", h)
assert not passwd.verify_password("wrong", h)
def test_hash_is_salted():
# Two separate hashes of the same password must diverge.
a = passwd.hash_password("same-pw")
b = passwd.hash_password("same-pw")
assert a != b
assert passwd.verify_password("same-pw", a)
assert passwd.verify_password("same-pw", b)
def test_generated_hash_format():
# Shape is pbkdf2:<hash>:<iter>$<salt>$<hex>
h = passwd.hash_password("x")
parts = h.split("$", 2)
assert len(parts) == 3
method, salt, digest = parts
assert method.startswith("pbkdf2:sha256:")
assert salt
# digest is hex of pbkdf2_hmac sha256 → 64 hex chars
assert len(digest) == 64
assert all(c in "0123456789abcdef" for c in digest)
def test_verify_werkzeug_scrypt_hash():
"""Known werkzeug scrypt hash generated by 26.11 / 26.12 boxes.
Captured live off a .196 test VM after its auth bootstrap:
username=daniel, password=test-admin-pw1
Hash format: scrypt:32768:8:1$<salt>$<hex>
If this regresses, every existing box that upgraded via 26.11 and
set a password gets locked out on the next upgrade.
"""
known = (
"scrypt:32768:8:1$yWZUqJodowt9ieI1$"
"2d1059b3564da7492b4aa3c2be7fff6fef06085e5e1bfd52e897948c58246b7a"
"9603400355b7264f61c4436eba7bf8c947adec3d7a76be03b50efb4227e15a80"
)
assert passwd.verify_password("test-admin-pw1", known)
assert not passwd.verify_password("wrong-password", known)
def test_verify_rejects_malformed_hashes():
# Empty / missing delimiters / unknown method / bad int — all False.
assert not passwd.verify_password("x", "")
assert not passwd.verify_password("x", "nothingspecial")
assert not passwd.verify_password("x", "pbkdf2:sha256:600000") # no $salt$digest
assert not passwd.verify_password("x", "pbkdf2$salt$digest") # missing hash + iter
assert not passwd.verify_password("x", "bcrypt:12$salt$digest") # unsupported algo
assert not passwd.verify_password("x", "pbkdf2:sha256:abc$salt$digest") # bad iter int
def test_verify_rejects_nonstring_inputs():
# Defensive: users.json can be corrupted or have nulls.
assert not passwd.verify_password(None, "pbkdf2:sha256:1000$salt$digest") # type: ignore[arg-type]
assert not passwd.verify_password("x", None) # type: ignore[arg-type]
assert not passwd.verify_password("x", 12345) # type: ignore[arg-type]

View file

@ -133,149 +133,3 @@ def test_reconcile_isolates_missing_docker_binary(tmp_path, monkeypatch):
error = next(a for a in actions if a.kind == "error")
assert error.target == "fileshare"
assert "docker" in error.detail
# --- Topo ordering + on_start hooks ----------------------------------------
PROVIDER_MANIFEST = dict(
VALID_MANIFEST,
name="mosquitto",
volumes=["data"],
)
CONSUMER_MANIFEST = dict(
VALID_MANIFEST,
name="zigbee2mqtt",
volumes=["state"],
requires=[
{
"app": "mosquitto",
"on_start": "hooks/ensure-user.sh",
}
],
)
def test_reconcile_topo_orders_providers_before_consumers(tmp_path, fake_docker, monkeypatch):
# Consumer comes alphabetically AFTER provider here, but the explicit dep
# also needs to win when the order was reversed. Add an alpha-first
# consumer name to make this load-bearing.
consumer = dict(CONSUMER_MANIFEST, name="alpha", requires=[{"app": "mosquitto"}])
_make_app(tmp_path, "mosquitto", PROVIDER_MANIFEST)
_make_app(tmp_path, "alpha", consumer)
reconciler.reconcile(tmp_path)
up_order = [project for _, project in fake_docker["compose_up"]]
assert up_order == ["mosquitto", "alpha"]
def test_reconcile_fires_on_start_before_compose_up(tmp_path, fake_docker, monkeypatch):
provider = _make_app(tmp_path, "mosquitto", PROVIDER_MANIFEST)
(provider / "hooks").mkdir()
(provider / "hooks" / "ensure-user.sh").write_bytes(b"#!/bin/sh\necho ok\n")
_make_app(tmp_path, "zigbee2mqtt", CONSUMER_MANIFEST)
hook_calls: list[str] = []
def fake_exec_script(app_dir, project, service, script_path, *, env, timeout):
hook_calls.append(f"{project}:{script_path.name}")
return ""
monkeypatch.setattr(dockerops, "compose_image_tags", lambda a, p: {"mosquitto": "img"})
monkeypatch.setattr(dockerops, "compose_exec_script", fake_exec_script)
actions = reconciler.reconcile(tmp_path)
# Hook fired against mosquitto exactly once.
assert hook_calls == ["mosquitto:ensure-user.sh"]
# Hook action appears before consumer's compose_up.
kinds = [(a.kind, a.target) for a in actions]
hook_idx = kinds.index(("hook", "zigbee2mqtt:mosquitto:on_start"))
up_idx = kinds.index(("compose_up", "zigbee2mqtt"))
assert hook_idx < up_idx
# And the provider's compose_up happened first.
assert fake_docker["compose_up"][0][1] == "mosquitto"
def test_reconcile_on_start_hook_receives_consumer_env(tmp_path, fake_docker, monkeypatch):
# The on_start hook must be able to re-establish provider state with the
# SAME credentials the consumer already holds, so the consumer's stored
# .env is injected (namespaced) alongside FURTKA_CONSUMER_APP/_VERSION.
provider = _make_app(tmp_path, "mosquitto", PROVIDER_MANIFEST)
(provider / "hooks").mkdir()
(provider / "hooks" / "ensure-user.sh").write_bytes(b"#!/bin/sh\n")
consumer = _make_app(tmp_path, "zigbee2mqtt", CONSUMER_MANIFEST)
# A real, stored credential plus a junk lowercase key that must be dropped.
(consumer / ".env").write_text("MQTT_PASS=s3cret-from-install\nnot_a_setting=x\n")
seen_env: dict[str, str] = {}
def fake_exec_script(app_dir, project, service, script_path, *, env, timeout):
seen_env.update(env)
return ""
monkeypatch.setattr(dockerops, "compose_image_tags", lambda a, p: {"mosquitto": "img"})
monkeypatch.setattr(dockerops, "compose_exec_script", fake_exec_script)
reconciler.reconcile(tmp_path)
assert seen_env["FURTKA_CONSUMER_APP"] == "zigbee2mqtt"
assert seen_env["FURTKA_CONSUMER_ENV_MQTT_PASS"] == "s3cret-from-install"
# Lowercase / non-UPPER_SNAKE keys are not exported.
assert not any(k.endswith("not_a_setting") for k in seen_env)
def test_reconcile_on_start_failure_skips_consumer_compose_up(tmp_path, fake_docker, monkeypatch):
provider = _make_app(tmp_path, "mosquitto", PROVIDER_MANIFEST)
(provider / "hooks").mkdir()
(provider / "hooks" / "ensure-user.sh").write_bytes(b"")
_make_app(tmp_path, "zigbee2mqtt", CONSUMER_MANIFEST)
# Unrelated third app: must still come up despite the consumer's hook fail.
_make_app(tmp_path, "lonely", dict(VALID_MANIFEST, name="lonely", volumes=["data"]))
def boom(*a, **k):
raise dockerops.DockerError("hook returned 1")
monkeypatch.setattr(dockerops, "compose_image_tags", lambda a, p: {"mosquitto": "img"})
monkeypatch.setattr(dockerops, "compose_exec_script", boom)
actions = reconciler.reconcile(tmp_path)
assert reconciler.has_errors(actions)
error_actions = [a for a in actions if a.kind == "error"]
assert len(error_actions) == 1
assert error_actions[0].target == "zigbee2mqtt"
assert "on_start(mosquitto)" in error_actions[0].detail
# Provider AND unrelated app came up; consumer did NOT.
up_projects = {p for _, p in fake_docker["compose_up"]}
assert "mosquitto" in up_projects
assert "lonely" in up_projects
assert "zigbee2mqtt" not in up_projects
def test_reconcile_dry_run_emits_hook_action_without_executing(tmp_path, fake_docker, monkeypatch):
provider = _make_app(tmp_path, "mosquitto", PROVIDER_MANIFEST)
(provider / "hooks").mkdir()
(provider / "hooks" / "ensure-user.sh").write_bytes(b"")
_make_app(tmp_path, "zigbee2mqtt", CONSUMER_MANIFEST)
called = []
monkeypatch.setattr(dockerops, "compose_exec_script", lambda *a, **k: called.append(1) or "")
actions = reconciler.reconcile(tmp_path, dry_run=True)
assert called == []
hook_actions = [a for a in actions if a.kind == "hook"]
assert any(a.target == "zigbee2mqtt:mosquitto:on_start" for a in hook_actions)
def test_reconcile_missing_provider_still_isolated(tmp_path, fake_docker, monkeypatch):
"""Consumer requires an app that isn't installed — per-app error, others continue."""
_make_app(tmp_path, "zigbee2mqtt", CONSUMER_MANIFEST)
_make_app(tmp_path, "lonely", dict(VALID_MANIFEST, name="lonely", volumes=["data"]))
actions = reconciler.reconcile(tmp_path)
assert reconciler.has_errors(actions)
errors = [a for a in actions if a.kind == "error"]
assert len(errors) == 1
assert errors[0].target == "zigbee2mqtt"
# `lonely` still got its compose_up.
assert any(p == "lonely" for _, p in fake_docker["compose_up"])

View file

@ -1,108 +0,0 @@
"""Tests for the catalog > bundled resolver."""
from __future__ import annotations
import json
from pathlib import Path
import pytest
def _manifest(name: str = "fileshare") -> dict:
return {
"name": name,
"display_name": "Fileshare",
"version": "0.1.0",
"description": "x",
"volumes": [],
"ports": [],
"icon": "icon.svg",
}
@pytest.fixture
def sources_mod(tmp_path, monkeypatch):
monkeypatch.setenv("FURTKA_CATALOG_DIR", str(tmp_path / "catalog"))
monkeypatch.setenv("FURTKA_BUNDLED_APPS_DIR", str(tmp_path / "bundled"))
import importlib
from furtka import paths as p
from furtka import sources as s
importlib.reload(p)
importlib.reload(s)
return s
def _seed_app(root: Path, name: str, manifest: dict | None = None) -> Path:
folder = root / name
folder.mkdir(parents=True)
(folder / "manifest.json").write_text(json.dumps(manifest or _manifest(name)))
return folder
def test_resolve_app_name_returns_none_when_absent(sources_mod):
assert sources_mod.resolve_app_name("nope") is None
def test_resolve_app_name_prefers_catalog_over_bundled(sources_mod, tmp_path):
_seed_app(tmp_path / "catalog" / "apps", "fileshare")
_seed_app(tmp_path / "bundled", "fileshare")
result = sources_mod.resolve_app_name("fileshare")
assert result is not None
assert result.origin == "catalog"
assert result.path.parent.name == "apps"
assert result.path.parent.parent.name == "catalog"
def test_resolve_app_name_falls_back_to_bundled(sources_mod, tmp_path):
_seed_app(tmp_path / "bundled", "fileshare")
result = sources_mod.resolve_app_name("fileshare")
assert result is not None
assert result.origin == "bundled"
def test_resolve_app_name_ignores_folder_without_manifest(sources_mod, tmp_path):
# Empty folder is not a valid app even if the name matches.
(tmp_path / "catalog" / "apps" / "fileshare").mkdir(parents=True)
_seed_app(tmp_path / "bundled", "fileshare")
result = sources_mod.resolve_app_name("fileshare")
# Catalog entry without manifest is skipped; bundled wins.
assert result.origin == "bundled"
def test_list_available_unions_catalog_and_bundled(sources_mod, tmp_path):
_seed_app(tmp_path / "catalog" / "apps", "fileshare")
_seed_app(tmp_path / "bundled", "otherapp")
names = {s.path.name: s.origin for s in sources_mod.list_available()}
assert names == {"fileshare": "catalog", "otherapp": "bundled"}
def test_list_available_catalog_wins_on_collision(sources_mod, tmp_path):
_seed_app(tmp_path / "catalog" / "apps", "fileshare")
_seed_app(tmp_path / "bundled", "fileshare")
entries = sources_mod.list_available()
assert len(entries) == 1
assert entries[0].origin == "catalog"
def test_list_available_empty_when_neither_exists(sources_mod):
assert sources_mod.list_available() == []
def test_list_available_skips_non_dirs_and_no_manifest(sources_mod, tmp_path):
# A plain file in catalog/apps and an empty dir in bundled — both ignored.
cat_root = tmp_path / "catalog" / "apps"
cat_root.mkdir(parents=True)
(cat_root / "not-a-dir.txt").write_text("x")
(tmp_path / "bundled" / "emptyapp").mkdir(parents=True)
_seed_app(tmp_path / "bundled", "realapp")
entries = sources_mod.list_available()
assert [e.path.name for e in entries] == ["realapp"]

View file

@ -24,9 +24,6 @@ def updater(tmp_path, monkeypatch):
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"))
hostname_file = tmp_path / "etc_hostname"
hostname_file.write_text("testbox\n")
monkeypatch.setenv("FURTKA_HOSTNAME_FILE", str(hostname_file))
(tmp_path / "etc_systemd_system").mkdir()
# Reload the module so the path constants pick up the env vars.
import importlib
@ -209,99 +206,6 @@ def test_refresh_caddyfile_noops_if_source_missing(updater, tmp_path):
assert updater._refresh_caddyfile(tmp_path / "does-not-exist") is False
def test_refresh_caddyfile_substitutes_hostname_placeholder(updater, tmp_path):
# Self-update rewrites the shipped Caddyfile against the box's real
# hostname, same substitution the installer does on first boot. Without
# this the named-hostname :443 block ships with a literal
# `__FURTKA_HOSTNAME__` and Caddy refuses to load the config.
src = tmp_path / "src"
src.write_text("__FURTKA_HOSTNAME__.local, __FURTKA_HOSTNAME__ {\n\ttls internal\n}\n")
assert updater._refresh_caddyfile(src) is True
live = updater._CADDYFILE_LIVE.read_text()
assert "testbox.local, testbox {" in live
assert "__FURTKA_HOSTNAME__" not in live
# Second call with the same source is a no-op — rendered content matches.
assert updater._refresh_caddyfile(src) is False
def test_health_check_treats_4xx_as_healthy(updater, monkeypatch):
"""26.11+ auth makes /api/apps return 401 on unauth requests. If the
health check treated that as "down", every pre-auth → auth upgrade
auto-rolls back. Server responding at all is enough signal for the
health check."""
import urllib.error
calls = {"n": 0}
class _FakeResp:
def __init__(self, code):
self.status = code
def __enter__(self):
return self
def __exit__(self, *a):
return False
def raising_401(url, timeout):
calls["n"] += 1
raise urllib.error.HTTPError(url, 401, "Unauthorized", {}, None)
monkeypatch.setattr("urllib.request.urlopen", raising_401)
assert updater._health_check("http://127.0.0.1:7000/api/apps", deadline_s=2.0) is True
# One call was enough — early exit on 4xx, no retry loop.
assert calls["n"] == 1
def test_health_check_rejects_5xx(updater, monkeypatch):
"""500s mean the server is up but broken — that's NOT healthy.
Distinguishes auth refusals (4xx = healthy) from real runtime
errors (5xx = unhealthy, roll back)."""
import urllib.error
def raising_500(url, timeout):
raise urllib.error.HTTPError(url, 500, "Internal Server Error", {}, None)
monkeypatch.setattr("urllib.request.urlopen", raising_500)
assert updater._health_check("http://127.0.0.1:7000/api/apps", deadline_s=1.5) is False
def test_health_check_retries_on_connection_refused(updater, monkeypatch):
"""While furtka-api is still starting, urlopen raises URLError.
The loop must keep polling until the server comes up or deadline."""
import urllib.error
calls = {"n": 0}
def flaky(url, timeout):
calls["n"] += 1
if calls["n"] < 3:
raise urllib.error.URLError("connection refused")
class _Resp:
status = 200
def __enter__(self):
return self
def __exit__(self, *a):
return False
return _Resp()
monkeypatch.setattr("urllib.request.urlopen", flaky)
assert updater._health_check("http://127.0.0.1:7000/api/apps", deadline_s=10.0) is True
assert calls["n"] == 3
def test_current_hostname_falls_back_when_file_missing(updater, monkeypatch, tmp_path):
monkeypatch.setenv("FURTKA_HOSTNAME_FILE", str(tmp_path / "missing"))
import importlib
importlib.reload(updater)
assert updater._current_hostname() == "furtka"
def test_link_new_units_only_links_missing(updater, tmp_path, monkeypatch):
unit_dir = tmp_path / "assets_systemd"
unit_dir.mkdir()
@ -316,28 +220,17 @@ def test_link_new_units_only_links_missing(updater, tmp_path, monkeypatch):
linked = updater._link_new_units(unit_dir)
assert linked == ["furtka-bar.timer"]
# Three calls for the newly-linked timer: link + enable + start. The
# already-linked service is untouched. `enable` alone only arms the
# timer for the next boot; `start` makes it fire on the running box
# (26.20 left furtka-welcome.timer dead until reboot without it).
assert len(seen) == 3
# Only one systemctl link call — for the new timer, not the existing service.
assert len(seen) == 1
assert seen[0][:2] == ["systemctl", "link"]
# Linked via the `current` symlink, not the versioned dir being scanned,
# so the link survives the next symlink flip / rollback.
assert seen[0][2] == str(updater.current_symlink() / "assets" / "systemd" / "furtka-bar.timer")
assert seen[1] == ["systemctl", "enable", "furtka-bar.timer"]
assert seen[2] == ["systemctl", "start", "furtka-bar.timer"]
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. tarfile lives
# in furtka._release_common after the extraction refactor, so we patch
# that module — updater._extract_tarball delegates there.
from furtka import _release_common as _rc
# cleanly on older runtimes. Capture the kwarg via a stub.
calls = []
real_open = _rc.tarfile.open # capture before monkeypatching
real_open = updater.tarfile.open # capture before monkeypatching
class _Recorder:
def __init__(self, tarball):
@ -362,7 +255,7 @@ def test_extract_tarball_uses_data_filter_when_available(tmp_path, updater, monk
tar = tmp_path / "t.tar.gz"
_make_release_tarball(tar, "26.9-alpha")
monkeypatch.setattr(_rc.tarfile, "open", lambda *a, **kw: _Recorder(tar))
monkeypatch.setattr(updater.tarfile, "open", lambda *a, **kw: _Recorder(tar))
dest = tmp_path / "dest"
updater._extract_tarball(tar, dest)
@ -463,107 +356,3 @@ def test_check_update_raises_when_no_releases_published(updater, monkeypatch):
monkeypatch.setattr(updater, "_forgejo_api", lambda path: [])
with pytest.raises(updater.UpdateError, match="no releases"):
updater.check_update()
def test_apply_update_rolls_back_on_oserror_and_restores_caddyfile(tmp_path, updater, monkeypatch):
"""A non-UpdateError after the symlink flip (disk full writing the
Caddyfile) used to leave the new version live with no rollback."""
versions = updater.versions_dir()
versions.mkdir(parents=True)
(versions / "26.0-alpha").mkdir()
current = updater.current_symlink()
current.symlink_to(versions / "26.0-alpha")
caddyfile = tmp_path / "etc_caddy" / "Caddyfile"
caddyfile.parent.mkdir()
caddyfile.write_text("# old routes\n")
tar = tmp_path / "t.tar.gz"
_make_tarball(tar, "26.1-alpha")
def full_disk(_source):
caddyfile.write_text("# half-written new routes\n")
raise OSError(28, "No space left on device")
monkeypatch.setattr(updater, "_refresh_caddyfile", full_disk)
monkeypatch.setattr(updater, "_run", lambda cmd: None)
import subprocess
monkeypatch.setattr(subprocess, "run", lambda *a, **kw: None)
with pytest.raises(updater.UpdateError, match="rolled back"):
updater.apply_update(tar, "26.1-alpha")
assert current.resolve() == (versions / "26.0-alpha").resolve()
assert caddyfile.read_text() == "# old routes\n"
assert updater.read_state()["stage"] == "rolled_back"
def test_swap_symlink_replaces_existing_link(tmp_path, updater):
a, b = tmp_path / "a", tmp_path / "b"
a.mkdir()
b.mkdir()
link = tmp_path / "current"
link.symlink_to(a)
updater._swap_symlink(link, b)
assert link.resolve() == b.resolve()
assert not (tmp_path / "current.new").exists()
def test_run_update_writes_error_stage_on_download_failure(updater, monkeypatch):
class _Check:
update_available = True
latest = "26.1-alpha"
current = "26.0-alpha"
tarball_url = "http://x/t.tar.gz"
sha256_url = "http://x/t.sha256"
def fake_check():
return _Check()
monkeypatch.setattr(updater, "check_update", fake_check)
def boom(url, dest):
raise OSError(28, "No space left on device")
monkeypatch.setattr(updater, "_download", boom)
with pytest.raises(updater.UpdateError, match="No space left"):
updater.run_update()
state = updater.read_state()
assert state["stage"] == "error"
assert "No space left" in state["reason"]
def test_health_check_survives_read_timeout(updater, monkeypatch):
def slow(url, timeout):
raise TimeoutError("timed out")
monkeypatch.setattr("urllib.request.urlopen", slow)
monkeypatch.setattr(updater.time, "sleep", lambda _s: None)
assert updater._health_check("http://127.0.0.1:7000/api/apps", deadline_s=0.05) is False
def test_refresh_caddyfile_migrates_legacy_301_redirect(updater, tmp_path):
from furtka import https
snippet_dir = tmp_path / "etc_caddy" / "furtka.d"
snippet_dir.mkdir(parents=True)
(tmp_path / "etc_caddy" / "furtka-https.d").mkdir()
(tmp_path / "etc_caddy" / "furtka-https.d" / "https.caddyfile").write_text("x\n")
redirect = snippet_dir / "redirect.caddyfile"
redirect.write_text(https.LEGACY_REDIRECT_CONTENT)
src = tmp_path / "Caddyfile.src"
src.write_text("# caddy\n")
updater._refresh_caddyfile(src)
assert redirect.read_text() == https._redirect_snippet_content("testbox")
def test_refresh_caddyfile_leaves_custom_redirect_alone(updater, tmp_path):
snippet_dir = tmp_path / "etc_caddy" / "furtka.d"
snippet_dir.mkdir(parents=True)
(tmp_path / "etc_caddy" / "furtka-https.d").mkdir()
(tmp_path / "etc_caddy" / "furtka-https.d" / "https.caddyfile").write_text("x\n")
redirect = snippet_dir / "redirect.caddyfile"
redirect.write_text("# hand-edited\n")
src = tmp_path / "Caddyfile.src"
src.write_text("# caddy\n")
updater._refresh_caddyfile(src)
assert redirect.read_text() == "# hand-edited\n"

View file

@ -31,10 +31,9 @@ ASSETS = REPO_ROOT / "assets"
# (install target path, asset path under furtka/assets/) — only the files we
# still copy bit-for-bit at install time. Scripts + unit files are no longer
# copied; they're reached via /opt/furtka/current and `systemctl link`. The
# Caddyfile is not in this list because it's written with the hostname
# placeholder substituted — see test_post_install_substitutes_hostname_in_caddyfile.
# copied; they're reached via /opt/furtka/current and `systemctl link`.
ASSET_TARGETS = [
("/etc/caddy/Caddyfile", "Caddyfile"),
("/var/lib/furtka/status.json", "www/status.json"),
]
@ -54,7 +53,7 @@ def install_cmds(tmp_path, monkeypatch):
fake = tmp_path / "payload.tar.gz"
fake.write_bytes(b"not a real tarball")
monkeypatch.setattr(app, "RESOURCE_MANAGER_PAYLOAD", fake)
return app._post_install_commands("testhost", "daniel", "test-admin-pw")
return app._post_install_commands("testhost")
@pytest.mark.parametrize("target,asset_relpath", ASSET_TARGETS)
@ -122,47 +121,18 @@ def test_caddyfile_asset_serves_from_current():
assert "root * /var/lib/furtka" in caddy
def _strip_caddy_comments(text: str) -> str:
"""Remove # comments + blank lines so string-match assertions can
target actual Caddyfile directives, not the leading doc block.
Comment intro is ``#`` at start-of-line or preceded by whitespace."""
out = []
for line in text.splitlines():
stripped = line.split("#", 1)[0].rstrip()
if stripped:
out.append(stripped)
return "\n".join(out)
def test_caddyfile_serves_http_by_default_https_opt_in():
# 26.15-alpha: HTTPS is opt-in. The default Caddyfile has a :80 block
# and imports /etc/caddy/furtka-https.d/*.caddyfile at top level —
# the /settings HTTPS toggle drops the hostname+tls-internal block
# into that dir when the user explicitly enables HTTPS. Default
# Caddyfile therefore contains no `tls internal` directive anywhere;
# if a future refactor puts it back, every fresh install regresses
# to the 26.14-era BAD_SIGNATURE trap. Strip comments first because
# the doc-block DOES mention `tls internal` in prose.
caddy_full = (ASSETS / "Caddyfile").read_text()
caddy = _strip_caddy_comments(caddy_full)
assert ":80 {" in caddy
assert "tls internal" not in caddy
assert "__FURTKA_HOSTNAME__" not in caddy
assert "import /etc/caddy/furtka-https.d/*.caddyfile" in caddy
# Shared routes still live in a named snippet so the HTTPS toggle's
# snippet can import the same routes without duplication.
assert "(furtka_routes)" in caddy
# Default Caddyfile imports it once (inside :80). The HTTPS snippet,
# when written by the toggle, imports it a second time.
assert caddy.count("import furtka_routes") == 1
def test_caddyfile_disables_caddy_auto_redirects():
# Named-hostname :443 block makes Caddy want to add its own HTTP→HTTPS
# redirect. The /settings toggle is the single source of truth, so the
# built-in has to be off — otherwise the toggle and auto_https race.
def test_caddyfile_serves_both_http_and_https():
# :80 stays so users who haven't installed the CA still reach the box;
# :443 uses Caddy's built-in local CA (tls internal) so users who have
# installed it get the green padlock.
caddy = (ASSETS / "Caddyfile").read_text()
assert "auto_https disable_redirects" in caddy
assert ":80 {" in caddy
assert ":443 {" in caddy
assert "tls internal" in caddy
# Shared routes live in a named snippet to avoid drift between the two
# listeners — both site blocks must import it.
assert "(furtka_routes)" in caddy
assert caddy.count("import furtka_routes") == 2
def test_caddyfile_imports_force_redirect_snippet_dir():
@ -176,41 +146,13 @@ def test_caddyfile_imports_force_redirect_snippet_dir():
def test_caddyfile_exposes_root_ca_download():
# /rootCA.crt is the download handle the UI uses. It must map to the
# Caddy local-CA pki path and set a Content-Disposition so the browser
# treats it as a download rather than trying to render it. Path is the
# real one Caddy uses under XDG_DATA_HOME=/var/lib (see caddy.service
# Environment= directive) — not the /var/lib/caddy/.local/share/caddy/
# path Caddy docs show for non-systemd installs.
# treats it as a download rather than trying to render it.
caddy = (ASSETS / "Caddyfile").read_text()
assert "handle /rootCA.crt" in caddy
assert "/var/lib/caddy/pki/authorities/local" in caddy
assert ".local/share/caddy" not in caddy
assert "/var/lib/caddy/.local/share/caddy/pki/authorities/local" in caddy
assert "attachment; filename=furtka-local-rootCA.crt" in caddy
def test_post_install_writes_caddyfile_without_hostname_placeholder(install_cmds):
# 26.15-alpha: the shipped Caddyfile no longer carries the
# __FURTKA_HOSTNAME__ marker — HTTPS + hostname now live in the
# opt-in snippet written by set_force_https(), not in the base
# Caddyfile. Verify the post-install writes the file as-is (no
# substitution expected) and it has the opt-in import glob.
caddyfile_cmd = next((c for c in install_cmds if " > /etc/caddy/Caddyfile" in c), None)
assert caddyfile_cmd is not None
written_full = _extract_written_content(caddyfile_cmd, "/etc/caddy/Caddyfile")
written = _strip_caddy_comments(written_full)
assert "__FURTKA_HOSTNAME__" not in written
assert "import /etc/caddy/furtka-https.d/*.caddyfile" in written
assert "tls internal" not in written
def test_post_install_creates_https_snippet_dir(install_cmds):
# The top-level HTTPS opt-in snippet dir must exist before Caddy's
# first start — its glob import tolerates an empty directory, but
# not a missing one on older Caddy builds. Parallel guarantee to
# test_post_install_creates_furtka_d_snippet_dir below.
matching = [c for c in install_cmds if "/etc/caddy/furtka-https.d" in c and "install -d" in c]
assert matching, "no install -d command creates /etc/caddy/furtka-https.d"
def test_post_install_creates_furtka_d_snippet_dir(install_cmds):
# Pre-existing installs pick up the import path via updater._refresh_caddyfile,
# but fresh installs never run that — this command is the only guarantee
@ -227,25 +169,6 @@ def test_systemd_units_reference_current_paths():
)
def test_welcome_banner_is_timer_driven():
"""The console banner must keep refreshing so the IP fallback is always
on screen. A oneshot with RemainAfterExit=yes never re-runs from a timer
(the unit stays 'active'), and a network-online dependency would delay the
first paint — both regressions the hardware bench hit in 26.19."""
timer = (ASSETS / "systemd" / "furtka-welcome.timer").read_text()
assert "OnUnitActiveSec=" in timer
assert "furtka-welcome.timer" in app._FURTKA_UNITS
service = (ASSETS / "systemd" / "furtka-welcome.service").read_text()
assert "RemainAfterExit" not in service
assert "network-online.target" not in service
def test_welcome_banner_always_prints_fallback_line():
script = (ASSETS / "bin" / "furtka-welcome").read_text()
assert "no IP address yet" in script
assert "fallback" in script
def test_read_asset_raises_for_missing_file():
with pytest.raises(FileNotFoundError):
app._read_asset("does/not/exist.html")
@ -253,37 +176,3 @@ def test_read_asset_raises_for_missing_file():
def test_assets_dir_resolves_to_repo_tree():
assert app._ASSETS_DIR == ASSETS
def test_post_install_writes_users_json_with_hashed_password(install_cmds):
"""The Furtka-admin users.json is created during the chroot post-install.
Without this, a fresh-install box lands at /login in first-run setup
mode and the user has to go through the browser to set a password —
which defeats the "step-1 password works for everything" design. Also
check that the file is chmod 0600 (the PBKDF2 hash is a secret even
if it's slow to crack).
"""
import json as _json
from werkzeug.security import check_password_hash
users_cmd = next((c for c in install_cmds if " > /var/lib/furtka/users.json" in c), None)
assert users_cmd is not None, "no command writes /var/lib/furtka/users.json"
assert "chmod 600" in users_cmd, "users.json must be chmod 0600"
body = _extract_written_content(users_cmd, "/var/lib/furtka/users.json")
parsed = _json.loads(body)
assert "admin" in parsed
assert parsed["admin"]["username"] == "daniel" # matches fixture
# Hash is a real werkzeug hash, not the plaintext password.
assert parsed["admin"]["hash"] != "test-admin-pw"
assert check_password_hash(parsed["admin"]["hash"], "test-admin-pw")
def test_catalog_sync_service_retries_on_failure():
"""First sync at boot can race DHCPv4 (IPv6 makes network-online fire
early); the unit must retry rather than wait for the daily timer."""
body = (ASSETS / "systemd" / "furtka-catalog-sync.service").read_text()
assert "Restart=on-failure" in body
assert "RestartSec=" in body
assert "StartLimitBurst=" in body

View file

@ -1,204 +0,0 @@
"""Tests for furtka.wireguard — tunnel-config write/remove + systemctl toggle.
Same shape as test_https.py: stub the systemctl call so we assert the conf
file is written/removed and that a reload failure rolls the conf file back
to its pre-call state.
"""
import subprocess
from pathlib import Path
import pytest
from furtka import wireguard
WG = {
"private_key": "cHJpdmF0ZS1rZXk=",
"address": "10.8.0.2/24",
"server_public_key": "c2VydmVyLXB1YmxpYy1rZXk=",
"endpoint": "gateway.example.com:51820",
"allowed_ips": "10.8.0.0/24",
}
def test_conf_content_shape():
content = wireguard._conf_content(WG)
assert "[Interface]" in content
assert "PrivateKey = cHJpdmF0ZS1rZXk=" in content
assert "Address = 10.8.0.2/24" in content
assert "[Peer]" in content
assert "PublicKey = c2VydmVyLXB1YmxpYy1rZXk=" in content
assert "Endpoint = gateway.example.com:51820" in content
assert "AllowedIPs = 10.8.0.0/24" in content
assert "PersistentKeepalive = 25" in content
def test_conf_content_omits_preshared_key_when_absent():
# WG has no "preshared_key" key at all — the v14-gateway shape.
content = wireguard._conf_content(WG)
assert "PresharedKey" not in content
def test_conf_content_includes_preshared_key_when_present():
# v15-gateway shape (see furtka-gateway's wgeasy_v15.py) — the server
# enforces this even if the client's own conf omits it, so dropping it
# here would break the handshake silently.
wg = {**WG, "preshared_key": "cHJlc2hhcmVkLWtleQ=="}
content = wireguard._conf_content(wg)
assert "PresharedKey = cHJlc2hhcmVkLWtleQ==" in content
# Must sit between PublicKey and Endpoint, inside [Peer].
peer_section = content.split("[Peer]\n", 1)[1]
assert (
peer_section.index("PublicKey")
< peer_section.index("PresharedKey")
< peer_section.index("Endpoint")
)
def test_enable_requires_wg_config(tmp_path):
with pytest.raises(ValueError, match="wg config is required"):
wireguard.set_tunnel_enabled(True, conf_path=tmp_path / "furtka-gw.conf")
def test_enable_surfaces_clean_error_on_unreadable_conf_dir(tmp_path, monkeypatch):
# Simulated rather than via a 0o000 directory: CI runs as root, which
# ignores directory permissions.
def denied(self, *a, **kw):
raise PermissionError(13, "Permission denied", str(self))
monkeypatch.setattr(Path, "mkdir", denied)
def must_not_reload(action):
raise AssertionError("systemctl must not run when the conf can't be written")
conf_path = tmp_path / "restricted" / "furtka-gw.conf"
with pytest.raises(wireguard.WireguardError, match="could not access"):
wireguard.set_tunnel_enabled(True, wg=WG, conf_path=conf_path, reload=must_not_reload)
def test_default_conf_path_uses_env_override(monkeypatch, tmp_path):
monkeypatch.setenv("FURTKA_WIREGUARD_CONF", str(tmp_path / "custom.conf"))
assert wireguard.default_conf_path() == tmp_path / "custom.conf"
def test_default_conf_path_default_when_unset(monkeypatch):
monkeypatch.delenv("FURTKA_WIREGUARD_CONF", raising=False)
assert wireguard.default_conf_path() == wireguard.DEFAULT_CONF_PATH
def test_enable_writes_conf_and_calls_reload(tmp_path):
conf_path = tmp_path / "furtka-gw.conf"
calls = []
def fake_reload(action):
calls.append(action)
result = wireguard.set_tunnel_enabled(True, wg=WG, conf_path=conf_path, reload=fake_reload)
assert result is True
assert calls == ["enable"]
content = conf_path.read_text()
assert "PrivateKey = cHJpdmF0ZS1rZXk=" in content
assert oct(conf_path.stat().st_mode)[-3:] == "600"
def test_disable_removes_conf_and_calls_reload(tmp_path):
conf_path = tmp_path / "furtka-gw.conf"
conf_path.write_text(wireguard._conf_content(WG))
calls = []
result = wireguard.set_tunnel_enabled(
False, conf_path=conf_path, reload=lambda action: calls.append(action)
)
assert result is False
assert calls == ["disable"]
assert not conf_path.exists()
def test_disable_is_idempotent_when_already_off(tmp_path):
conf_path = tmp_path / "furtka-gw.conf"
result = wireguard.set_tunnel_enabled(False, conf_path=conf_path, reload=lambda action: None)
assert result is False
assert not conf_path.exists()
def test_reload_failure_rolls_back_enable(tmp_path):
conf_path = tmp_path / "furtka-gw.conf"
def failing_reload(action):
raise subprocess.CalledProcessError(1, ["systemctl"], stderr="bad config")
with pytest.raises(wireguard.WireguardError, match="systemctl failed: bad config"):
wireguard.set_tunnel_enabled(True, wg=WG, conf_path=conf_path, reload=failing_reload)
# No conf existed before this call, so none should exist after rollback.
assert not conf_path.exists()
def test_reload_failure_rolls_back_disable(tmp_path):
conf_path = tmp_path / "furtka-gw.conf"
original = wireguard._conf_content(WG)
conf_path.write_text(original)
def failing_reload(action):
raise subprocess.CalledProcessError(1, ["systemctl"], stderr="bad config")
with pytest.raises(wireguard.WireguardError):
wireguard.set_tunnel_enabled(False, conf_path=conf_path, reload=failing_reload)
assert conf_path.read_text() == original
def test_systemctl_missing_raises_and_rolls_back(tmp_path):
conf_path = tmp_path / "furtka-gw.conf"
def missing_systemctl(action):
raise FileNotFoundError(2, "No such file", "systemctl")
with pytest.raises(wireguard.WireguardError, match="systemctl not available"):
wireguard.set_tunnel_enabled(True, wg=WG, conf_path=conf_path, reload=missing_systemctl)
assert not conf_path.exists()
def test_status_not_configured(tmp_path):
s = wireguard.status(conf_path=tmp_path / "does-not-exist.conf")
assert s == {"configured": False, "latest_handshake": None}
def test_status_survives_unreadable_conf_path(tmp_path, monkeypatch):
"""Regression: an inaccessible parent directory raises PermissionError
even for a path that doesn't exist yet — confirmed by actually hitting
this against a real box's /etc/wireguard/ as a non-root user, which
crashed the whole /api/gateway/status request with a raw traceback.
A read-only status check must degrade to "not configured", never 500.
"""
# Simulated rather than via a 0o000 directory: CI runs as root, which
# ignores directory permissions.
def denied(self):
raise PermissionError(13, "Permission denied", str(self))
monkeypatch.setattr(Path, "is_file", denied)
s = wireguard.status(conf_path=tmp_path / "restricted" / "furtka-gw.conf")
assert s == {"configured": False, "latest_handshake": None}
def test_status_configured_reports_handshake(tmp_path):
conf_path = tmp_path / "furtka-gw.conf"
conf_path.write_text(wireguard._conf_content(WG))
s = wireguard.status(conf_path=conf_path, wg_show=lambda: "1234567890")
assert s == {"configured": True, "latest_handshake": "1234567890"}
def test_status_configured_but_wg_tools_missing(tmp_path):
conf_path = tmp_path / "furtka-gw.conf"
conf_path.write_text(wireguard._conf_content(WG))
def missing_wg():
raise FileNotFoundError(2, "No such file", "wg")
s = wireguard.status(conf_path=conf_path, wg_show=missing_wg)
assert s == {"configured": True, "latest_handshake": None}

View file

@ -8,52 +8,13 @@ import os
import re
import subprocess
import sys
import threading
import traceback
from datetime import UTC
from pathlib import Path
from diskprep import release_disk
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"},
@ -78,35 +39,6 @@ settings = {
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}$")
# Accounts that already exist on the installed system (base + the packages
# we install). Picking one of these either fails user creation mid-install
# (root) or makes the admin collide with a service account (caddy, docker…).
RESERVED_USERNAMES = frozenset(
{
"root",
"bin",
"daemon",
"mail",
"ftp",
"http",
"nobody",
"dbus",
"git",
"uuidd",
"avahi",
"caddy",
"docker",
"polkitd",
"rtkit",
"systemd-coredump",
"systemd-journal-remote",
"systemd-network",
"systemd-oom",
"systemd-resolve",
"systemd-timesync",
}
)
# 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
@ -128,24 +60,8 @@ PROGRESS_PHASES = [
PROGRESS_ERROR_MARKERS = ("Traceback (most recent call last)", "archinstall: error:")
# Known failure signatures → a phase label that tells the user what to do
# instead of a bare "open Show details". Checked in order; first hit wins.
PROGRESS_ERROR_HINTS = [
(
"unable to inform the kernel of the change",
"Installation failed — the disk was still in use (old LVM/RAID/swap "
"from a previous system). Reboot the installer and try again",
),
]
def parse_install_progress(log, exit_code=None):
"""Progress dict from the install log.
`exit_code` is archinstall's return code once the process has ended
(None while running or unknown). A non-zero exit is an error even when
the log has no Traceback — OOM kills and signals print nothing.
"""
def parse_install_progress(log):
percent = 2
phase = "Starting up…"
for marker, pct, label in PROGRESS_PHASES:
@ -155,48 +71,29 @@ def parse_install_progress(log, exit_code=None):
if percent >= 100:
status = "done"
elif any(m in log for m in PROGRESS_ERROR_MARKERS) or exit_code not in (None, 0):
elif any(m in log for m in PROGRESS_ERROR_MARKERS):
status = "error"
phase = "Installation failed — open Show details below"
for marker, hint in PROGRESS_ERROR_HINTS:
if marker in log:
phase = hint
break
else:
status = "running"
if percent == 18:
# archinstall waits for NTP time sync with no timeout. Only a hint:
# it usually clears on its own within a minute.
phase = (
"Syncing time and packages — if this takes more than a few "
"minutes, check that the network cable is plugged in"
)
return {"percent": percent, "phase": phase, "status": status}
def validate_step1(form):
errors = []
# Lowercase instead of rejecting "Furtka" / "Daniel" — Linux needs
# lowercase, people type names the way they write them.
values = {
"hostname": form.get("hostname", "").strip().lower(),
"username": form.get("username", "").strip().lower(),
"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(
"The server name can only use letters, digits and hyphens (no spaces, max 63)."
)
errors.append("Hostname must be lowercase letters, digits, hyphens (max 63 chars).")
if not USERNAME_RE.match(values["username"]):
errors.append(
"The username must start with a letter and can only use letters, digits, - and _."
)
elif values["username"] in RESERVED_USERNAMES:
errors.append(f"“{values['username']}” is reserved by the system — please pick another.")
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:
@ -331,13 +228,6 @@ _FURTKA_UNITS = (
"furtka-status.service",
"furtka-status.timer",
"furtka-welcome.service",
# Re-runs the welcome banner every few seconds so the console always
# shows the current IP fallback (or a "no IP yet" hint).
"furtka-welcome.timer",
# 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",
)
@ -419,35 +309,7 @@ def _furtka_json_cmd(hostname):
)
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):
def _post_install_commands(hostname):
# nss-mdns: splice `mdns_minimal [NOTFOUND=return]` before `resolve` on
# the hosts line so `*.local` works from the installed system too. Guarded
# so a re-run (or a future Arch default that already ships mdns) is a
@ -465,70 +327,27 @@ def _post_install_commands(hostname, admin_username, admin_password):
# 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),
),
_write_file_cmd("/etc/caddy/Caddyfile", _read_asset("Caddyfile")),
# 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,
# The installed system inherits the live ISO's networkd config
# (DHCP on every Ethernet + Wi-Fi link) but not its wait-online
# drop-in, so a board with a second, unplugged NIC stalled boot for
# up to 2 minutes before network-online.target. Any one link is
# enough for a home server.
_write_file_cmd(
"/etc/systemd/system/systemd-networkd-wait-online.service.d/any-interface.conf",
"[Service]\nExecStart=\n"
"ExecStart=/usr/lib/systemd/systemd-networkd-wait-online --any\n",
),
# 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(efi_dir=Path("/sys/firmware/efi")):
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.
if not efi_dir.exists():
return "Grub"
# 64-bit CPU behind 32-bit UEFI (Atom Bay Trail / Cherry Trail mini-PCs
# and tablets): Arch ships no ia32 systemd-boot, and archinstall's GRUB
# path hardcodes --target=x86_64-efi. Its Limine path installs both
# BOOTIA32.EFI and BOOTX64.EFI and picks the entry by firmware bitness.
try:
bitness = (efi_dir / "fw_platform_size").read_text().strip()
except OSError:
bitness = "64"
return "Limine" if bitness == "32" else "Systemd-boot"
return "Systemd-boot" if Path("/sys/firmware/efi").exists() else "Grub"
def build_archinstall_config(s):
@ -559,9 +378,6 @@ def build_archinstall_config(s):
"caddy",
"avahi",
"nss-mdns",
# furtka-gateway tunnel — furtka/wireguard.py drives
# wg-quick@furtka-gw.service, which ships in this package.
"wireguard-tools",
# Resource manager runtime — pure-stdlib Python, no pip needed
# because we expose the package via PYTHONPATH in /usr/local/bin/furtka.
"python",
@ -571,7 +387,7 @@ def build_archinstall_config(s):
"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.timer + furtka-status.timer
# custom_commands, so our own furtka-welcome + furtka-status.timer
# units (written in custom_commands) are enabled there instead.
"caddy",
"avahi-daemon",
@ -583,7 +399,7 @@ def build_archinstall_config(s):
# 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"]),
*_post_install_commands(s["hostname"]),
],
"network_config": {"type": "iso"},
"ssh": True,
@ -627,9 +443,7 @@ def write_install_files(s, state_dir):
def spawn_archinstall(config_path, creds_path, log_path):
# Append: install_run() already truncated the log and may have written
# the disk-release lines we want to keep visible above archinstall's own.
log_fh = open(log_path, "ab")
log_fh = open(log_path, "wb")
return subprocess.Popen(
[
"archinstall",
@ -645,16 +459,6 @@ def spawn_archinstall(config_path, creds_path, log_path):
)
@app.before_request
def _stay_on_progress_while_installing():
# While archinstall runs, the wizard pages would let a second browser
# (or Back) edit settings that no longer apply — show the progress.
running = _install_proc is not None and _install_proc.poll() is None
if running and request.endpoint in ("install_step_1", "install_step_2", "install_overview"):
return redirect(url_for("install_log_view"))
return None
@app.route("/")
def home():
return redirect(url_for("install_step_1"))
@ -679,127 +483,64 @@ def install_step_1():
)
# Size/type of the disk picked in step 2, for the confirm page (re-probing
# would re-run smartctl on every disk).
_selected_drive: dict = {}
@app.route("/install/step2", methods=["GET", "POST"])
def install_step_2():
drives = list_scored_devices()
error = ""
if request.method == "POST":
boot_drive = request.form.get("boot_drive", "").strip()
# Only accept a disk we actually offered — never a free-form path.
picked = next((d for d in drives if d["name"] == boot_drive and d["usable"]), None)
if picked:
if boot_drive:
settings["boot_drive"] = boot_drive
_selected_drive.clear()
_selected_drive.update(picked)
return redirect(url_for("install_overview"))
error = "Please pick one of the disks below."
return render_template(
"install/step2.html",
drives=drives,
drives=list_scored_devices(),
selected=settings.get("boot_drive", ""),
error=error,
)
@app.route("/install/overview")
def install_overview():
if not settings["boot_drive"] or not settings["username"]:
return redirect(url_for("install_step_1"))
masked = {**settings, "password": "•" * 8 if settings["password"] else ""}
drive = _selected_drive if _selected_drive.get("name") == settings["boot_drive"] else None
language = LANGUAGES.get(settings["language"], {}).get("label", settings["language"])
return render_template(
"install/overview.html", settings=masked, drive=drive, language_label=language
)
# The running archinstall process, so a second click (double-submit, Back +
# Install again, a second browser) can't start another install on the same
# disk, and so the progress page can see an exit without a Traceback.
_install_proc = None
_install_lock = threading.Lock()
def _install_exit_code():
return None if _install_proc is None else _install_proc.poll()
def _read_install_log():
log = INSTALL_LOG.read_text(errors="replace") if INSTALL_LOG.exists() else ""
code = _install_exit_code()
if code not in (None, 0):
log += f"\n\narchinstall stopped unexpectedly (exit code {code}).\n"
return log, code
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"))
with _install_lock:
if _install_proc is not None and _install_proc.poll() is None:
return redirect(url_for("install_log_view"))
return _start_install()
def _start_install():
global _install_proc
STATE_DIR.mkdir(parents=True, exist_ok=True)
INSTALL_LOG.write_bytes(b"")
try:
config_path, creds_path = write_install_files(settings, STATE_DIR)
except Exception:
# archinstall's disk probe raises on disks it can't parse (broken
# GPT, odd card readers). Put the traceback where the progress page
# finds it instead of a bare "Internal Server Error".
with INSTALL_LOG.open("a") as fh:
fh.write(
f"Could not read the layout of {settings['boot_drive']}.\n" + traceback.format_exc()
)
return redirect(url_for("install_log_view"))
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:
# Free the target disk first: udev auto-activates old LVM/RAID/swap
# on the live ISO, and archinstall can't re-read a partition table
# while those hold the partitions open (first hardware bench, 26.19).
with INSTALL_LOG.open("a") as fh:
fh.write("\n".join(release_disk(settings["boot_drive"])) + "\n\n")
_install_proc = spawn_archinstall(config_path, creds_path, INSTALL_LOG)
spawn_archinstall(config_path, creds_path, INSTALL_LOG)
return redirect(url_for("install_log_view"))
@app.route("/install/log")
def install_log_view():
log, code = _read_install_log()
log = INSTALL_LOG.read_text() if INSTALL_LOG.exists() else ""
return render_template(
"install/log.html",
log=log,
progress=parse_install_progress(log, code),
hostname=settings["hostname"],
progress=parse_install_progress(log),
)
@app.route("/install/log.json")
def install_log_json():
log, code = _read_install_log()
return jsonify(log=log, progress=parse_install_progress(log, code))
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, code = _read_install_log()
if parse_install_progress(log, code)["status"] != "done":
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.

View file

@ -1,125 +0,0 @@
"""Release a target disk before handing it to archinstall.
The live ISO ships lvm2, mdadm, dmraid and cryptsetup (releng defaults), and
udev happily auto-activates whatever it finds on attached disks at boot. A
drive that used to live in another machine — the first hardware bench had an
old Proxmox VE install on its SSD — therefore comes up with device-mapper
volumes (or swap, or md arrays) stacked on top of its partitions. archinstall
then writes the new partition table fine but `BLKRRPART` fails with
Partition(s) 2, 3 on /dev/sda have been written, but we have been unable
to inform the kernel of the change, probably because it/they are in use.
and the install dies at 12 %. Nothing on a fresh VM ever triggers this,
which is why it survived every smoke run.
`release_disk()` tears that stack down leaves-first (swapoff, umount, dmsetup
remove / mdadm --stop), wipes signatures and asks the kernel to re-read the
partition table. Every step is logged so the install log shows what was
done. All commands are best-effort: the point is to leave archinstall a
disk the kernel can re-partition, not to be a full disk-management tool.
"""
import re
import subprocess
_PAIR_RE = re.compile(r'(\w+)="([^"]*)"')
def _run(cmd, run):
try:
result = run(cmd, capture_output=True, text=True)
except FileNotFoundError:
return 127, f"{cmd[0]}: command not found"
out = ((result.stdout or "") + (result.stderr or "")).strip()
return result.returncode, out
def _parse_pairs(output):
"""Parse `lsblk -P` key="value" lines into dicts."""
rows = []
for line in output.splitlines():
row = {}
for match in _PAIR_RE.finditer(line):
row[match.group(1)] = match.group(2)
if row.get("NAME"):
rows.append(row)
return rows
def _children(disk, run):
"""Everything stacked on `disk`, in lsblk tree order, disk itself excluded."""
rc, out = _run(["lsblk", "-lnP", "-o", "NAME,TYPE,MOUNTPOINTS", disk], run)
if rc != 0:
return []
rows = _parse_pairs(out)
seen = set()
children = []
for row in rows:
if row.get("TYPE") == "disk" or row["NAME"] in seen:
continue
seen.add(row["NAME"])
children.append(row)
return children
def release_disk(disk, run=subprocess.run):
"""Free `disk` (e.g. "/dev/sda") of anything that would block a
partition-table reload. Returns the log lines describing what happened."""
if not disk.startswith("/dev/"):
disk = f"/dev/{disk}"
lines = [f"Releasing {disk} before install…"]
children = _children(disk, run)
if not children:
lines.append(" nothing stacked on the disk — good.")
else:
lines.append(" found: " + ", ".join(f"{c['NAME']} ({c['TYPE']})" for c in children))
# Leaves first: mountpoints and swap sit on the outermost devices.
for child in reversed(children):
dev = f"/dev/{child['NAME']}"
mounts = child.get("MOUNTPOINTS", "")
if "[SWAP]" in mounts:
rc, out = _run(["swapoff", dev], run)
lines.append(f" swapoff {dev}: {'ok' if rc == 0 else out}")
elif mounts:
rc, out = _run(["umount", "--all-targets", dev], run)
lines.append(f" umount {dev}: {'ok' if rc == 0 else out}")
# Device-mapper (LVM, thin pools, LUKS) and md stacks. Thin pools need
# their thin LVs gone before the pool goes, and lsblk lists shared nodes
# more than once, so go leaves-first and retry a couple of passes until
# nothing dm/md-shaped is left.
for _ in range(3):
stacked = [c for c in reversed(_children(disk, run)) if c["TYPE"] != "part"]
if not stacked:
break
for child in stacked:
name, typ = child["NAME"], child["TYPE"]
if typ.startswith("raid") or name.startswith("md"):
cmd = ["mdadm", "--stop", f"/dev/{name}"]
else:
cmd = ["dmsetup", "remove", name]
rc, out = _run(cmd, run)
lines.append(f" {' '.join(cmd)}: {'ok' if rc == 0 else out}")
leftovers = [c["NAME"] for c in _children(disk, run) if c["TYPE"] != "part"]
if leftovers:
lines.append(
" WARNING: still in use after cleanup: "
+ ", ".join(leftovers)
+ " — the install may fail to re-read the partition table."
)
# Old signatures (LVM PV headers, RAID superblocks) would otherwise be
# re-activated by udev the moment the kernel re-reads the table.
for child in reversed(_children(disk, run)):
if child["TYPE"] == "part":
rc, out = _run(["wipefs", "-a", f"/dev/{child['NAME']}"], run)
lines.append(f" wipefs /dev/{child['NAME']}: {'ok' if rc == 0 else out}")
rc, out = _run(["wipefs", "-a", disk], run)
lines.append(f" wipefs {disk}: {'ok' if rc == 0 else out}")
rc, out = _run(["blockdev", "--rereadpt", disk], run)
lines.append(f" re-read partition table: {'ok' if rc == 0 else out}")
return lines

View file

@ -1,90 +1,21 @@
import json
import re
import subprocess
from pathlib import Path
# Smallest disk we offer as an install target. The base system plus Docker
# needs ~6 GB; the rest is headroom for images and app data.
MIN_DISK_GB = 16
# MBR (what archinstall lays out on BIOS boxes) can't address past 2 TiB.
MBR_LIMIT_GB = 2048
# Block devices lsblk reports as TYPE=disk that are never install targets:
# compressed RAM swap and the eMMC hardware boot / RPMB areas (4 MB, usually
# read-only) that show up next to mmcblk0 on every eMMC mini-PC and laptop.
_PSEUDO_DISK_RE = re.compile(r"^(zram\d+|mmcblk\d+boot\d+|mmcblk\d+rpmb)$")
_SUBPROCESS_TIMEOUT = 15
def _run(cmd, timeout=_SUBPROCESS_TIMEOUT):
"""subprocess.run with a timeout; None when the tool is missing or hangs."""
try:
return subprocess.run(cmd, capture_output=True, text=True, timeout=timeout)
except (FileNotFoundError, subprocess.TimeoutExpired):
return None
def _parent_disk(device):
result = _run(["lsblk", "-no", "PKNAME", device])
if result is None or result.returncode != 0:
return None
lines = [line.strip() for line in result.stdout.splitlines() if line.strip()]
return lines[0] if lines else None
def _boot_device_from_cmdline(cmdline):
"""Resolve the boot partition from archiso's kernel args, or None."""
args = dict(arg.split("=", 1) for arg in cmdline.split() if "=" in arg)
if args.get("archisodevice", "").startswith("/dev/"):
return args["archisodevice"]
if args.get("archisosearchuuid"):
cmd = ["blkid", "-U", args["archisosearchuuid"]]
elif args.get("archisolabel"):
cmd = ["blkid", "-L", args["archisolabel"]]
else:
return None
result = _run(cmd)
if result is None or result.returncode != 0:
return None
return result.stdout.strip() or None
def _boot_disk_name():
"""Return the parent disk name of the live-ISO boot media (e.g. "sdb"), or None.
On bare metal booted from USB this is the stick we booted from — we want
to filter it out so the user can't accidentally pick it as the install
target. Primary source is the mount behind /run/archiso/bootmnt; when
that's gone (copytoram unmounts it) fall back to the UUID/label archiso
put on the kernel command line.
"""
partition = None
result = _run(["findmnt", "-no", "SOURCE", "/run/archiso/bootmnt"])
if result is not None and result.returncode == 0:
partition = result.stdout.strip() or None
if partition is None:
try:
partition = _boot_device_from_cmdline(Path("/proc/cmdline").read_text())
except OSError:
partition = None
if partition is None:
return None
# A stick written with dd has the ISO filesystem on the whole disk, where
# PKNAME is empty — the device itself is then the disk.
return _parent_disk(partition) or Path(partition).name
def _smart_status(device):
result = _run(["smartctl", "-H", device])
if result is None:
return "unknown"
if "PASSED" in result.stdout:
try:
result = subprocess.run(
["smartctl", "-H", device],
capture_output=True,
)
output = result.stdout.decode()
if "PASSED" in output:
return "passed"
if "FAILED" in result.stdout:
elif "FAILED" in output:
return "failed"
return "unknown"
except Exception as e:
print(f"Error checking SMART status for {device}: {e}")
return "unknown"
_HEALTH_SCORE = {"passed": 10, "failed": 0, "unknown": 5}
@ -94,33 +25,40 @@ _HEALTH_LABEL = {
"unknown": "Status unknown",
}
_TYPE_SCORE = {"NVMe": 15, "SSD": 10, "eMMC": 8, "HDD": 5, "USB": 0}
def get_drive_health(device):
return _HEALTH_SCORE[_smart_status(device)]
def classify_drive(name, tran="", rota=None, removable=False):
"""Human label for a disk: NVMe, SSD, eMMC, HDD or USB.
def get_drive_type_score(device):
name = device.lower()
if "nvme" in name:
return 15
if "ssd" in name:
return 10
return 5
Uses lsblk's transport and rotational flags — the kernel name alone
can't tell an SSD from a spinning disk (both are sdX).
"""
tran = (tran or "").lower()
if tran == "usb" or (removable and not name.startswith("mmcblk")):
return "USB"
if tran == "nvme" or name.startswith("nvme"):
def get_drive_type_label(device):
name = device.lower()
if "nvme" in name:
return "NVMe"
if tran == "mmc" or name.startswith("mmcblk"):
return "eMMC"
if rota is False:
if "ssd" in name:
return "SSD"
return "HDD"
def get_drive_type_score(type_label):
return _TYPE_SCORE.get(type_label, 5)
def parse_size_gb(size_str):
size_str = size_str.strip().upper().replace(",", ".")
if not size_str:
return None
if size_str.endswith("T"):
return float(size_str[:-1]) * 1024
if size_str.endswith("G"):
return float(size_str[:-1])
if size_str.endswith("M"):
return float(size_str[:-1]) / 1024
return None
def get_size_score(size_gb):
@ -133,98 +71,56 @@ def get_size_score(size_gb):
return 10
def format_size(size_bytes):
gb = size_bytes / 1024**3
if gb >= 1024:
return f"{gb / 1024:.1f} TB".replace(".0 ", " ")
if gb >= 10:
return f"{gb:.0f} GB"
return f"{gb:.1f} GB"
def score_device(device, size_gb):
return get_drive_type_score(device) + get_drive_health(device) + get_size_score(size_gb)
def _flag(value):
# util-linux ≥2.37 emits JSON booleans; older builds emit "0"/"1".
if isinstance(value, bool):
return value
return str(value).strip() in ("1", "true")
def parse_lsblk_output(output):
"""Parse `lsblk -dn -o NAME,SIZE,TYPE` output into scored device dicts.
def parse_lsblk_json(output, boot_disk=None, uefi=True):
"""Parse `lsblk -J -d -b -o NAME,SIZE,TYPE,RO,RM,TRAN,ROTA` into device dicts.
Keeps only real, writable, non-empty TYPE=disk devices (drops the live
ISO's loop/rom, zram, eMMC boot areas, empty card-reader slots) and the
USB stick we booted from. Disks too small to install on stay in the list
with `usable=False` and a reason so the user sees why they're greyed out.
Sorted best-first; unusable disks last.
Keeps only TYPE=disk so the live ISO's own squashfs (loop) and the boot
CD-ROM (rom) don't show up as install targets.
"""
try:
entries = json.loads(output or "{}").get("blockdevices", [])
except json.JSONDecodeError:
return []
devices = []
for e in entries:
name = e.get("name") or ""
if e.get("type") != "disk" or not name:
for line in output.strip().split("\n"):
if not line:
continue
if _PSEUDO_DISK_RE.match(name) or (boot_disk and name == boot_disk):
parts = line.split()
if len(parts) < 3:
continue
try:
size_bytes = int(e.get("size") or 0)
except (TypeError, ValueError):
size_bytes = 0
if size_bytes <= 0 or _flag(e.get("ro", False)):
name, size, dev_type = parts[0], parts[1], parts[2]
if dev_type != "disk":
continue
size_gb = size_bytes / 1024**3
type_label = classify_drive(
name,
tran=e.get("tran") or "",
rota=_flag(e.get("rota")) if e.get("rota") is not None else None,
removable=_flag(e.get("rm", False)),
)
device = f"/dev/{name}"
usable = size_gb >= MIN_DISK_GB
status = _smart_status(device) if usable else "unknown"
warning = ""
if not uefi and size_gb > MBR_LIMIT_GB:
warning = (
"This computer starts in legacy (BIOS) mode, which can only use "
"the first 2 TB of this disk. Switch the firmware to UEFI to use all of it."
)
elif type_label == "USB":
warning = (
"This is a USB drive — it works, but an internal disk is faster and more reliable."
)
size_gb = parse_size_gb(size)
status = _smart_status(device)
score = get_drive_type_score(device) + _HEALTH_SCORE[status] + get_size_score(size_gb)
devices.append(
{
"name": device,
"size": format_size(size_bytes),
"size_gb": size_gb,
"type_label": type_label,
"size": size,
"type_label": get_drive_type_label(device),
"health_label": _HEALTH_LABEL[status],
"score": get_drive_type_score(type_label)
+ _HEALTH_SCORE[status]
+ get_size_score(size_gb),
"usable": usable,
"reason": "" if usable else f"Too small — Furtka needs at least {MIN_DISK_GB} GB",
"warning": warning,
"score": score,
}
)
devices.sort(key=lambda d: (d["usable"], d["score"]), reverse=True)
devices.sort(key=lambda d: d["score"], reverse=True)
return devices
def list_scored_devices():
"""Return [{name, size, score, usable, ...}, ...] for all physical disks, best first."""
result = _run(["lsblk", "-J", "-d", "-b", "-o", "NAME,SIZE,TYPE,RO,RM,TRAN,ROTA"])
if result is None or result.returncode != 0:
print(f"Error listing devices: {result.stderr if result else 'lsblk unavailable'}")
return []
return parse_lsblk_json(
result.stdout,
boot_disk=_boot_disk_name(),
uefi=Path("/sys/firmware/efi").exists(),
"""Return [{name, size, score}, ...] for all physical disks, highest score first."""
try:
result = subprocess.run(
["lsblk", "-dn", "-o", "NAME,SIZE,TYPE"],
capture_output=True,
text=True,
check=True,
)
except subprocess.CalledProcessError as e:
print(f"Error listing devices: {e}")
return []
return parse_lsblk_output(result.stdout)
def main():
@ -232,11 +128,11 @@ def main():
if not devices:
print("No storage devices found.")
return
print(f"\n{'Device':<20} {'Size':<10} {'Type':<6} {'Score'}")
print("-" * 46)
print(f"\n{'Device':<20} {'Size':<10} {'Score'}")
print("-" * 40)
for d in devices:
note = "" if d["usable"] else f" ({d['reason']})"
print(f"{d['name']:<20} {d['size']:<10} {d['type_label']:<6} {d['score']}{note}")
print(f"{d['name']:<20} {d['size']:<10} {d['score']}")
print(f"\nBest drive for boot: {devices[0]['name']} (score {devices[0]['score']})")
if __name__ == "__main__":

View file

@ -1 +0,0 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 64 64"><style>svg{color:#c03a28}@media (prefers-color-scheme:dark){svg{color:#ff6b56}}</style><path fill="currentColor" fill-rule="evenodd" d="M5 60V27a27 27 0 0 1 54 0v33Z M19.5 60V26A10 10 0 0 1 29.5 16H46v10H32a2.5 2.5 0 0 0-2.5 2.5V32H46v10H29.5v18Z"/></svg>

Before

Width:  |  Height:  |  Size: 316 B

View file

@ -190,17 +190,9 @@ select:focus {
background: var(--bg-card);
border: 1px solid var(--border);
border-radius: 8px;
flex-wrap: wrap;
cursor: pointer;
transition: border-color 120ms, background 120ms;
}
.drive-disabled { cursor: not-allowed; opacity: 0.6; }
.drive-disabled:hover { border-color: var(--border); }
.drive .drive-note {
flex-basis: 100%;
font-size: 0.82rem;
color: var(--fg-muted);
}
.drive:hover { border-color: color-mix(in srgb, var(--accent) 50%, var(--border)); }
.drive input[type="radio"] {
accent-color: var(--accent);
@ -429,11 +421,3 @@ select:focus {
outline-offset: 3px;
border-radius: 2px;
}
/* "Furtka is ready" — the two steps before the restart button (log.html). */
.next-steps {
margin: 1.5rem 0 1rem;
padding-left: 1.4rem;
line-height: 1.5;
}
.next-steps li { margin-bottom: 0.6rem; }

View file

@ -4,7 +4,6 @@
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>{% block title %}Furtka Installer{% endblock %}</title>
<link rel="icon" type="image/svg+xml" href="{{ url_for('static', filename='favicon.svg') }}">
<link rel="stylesheet" href="{{ url_for('static', filename='style.css') }}">
{% block head_extra %}{% endblock %}
</head>
@ -12,8 +11,12 @@
<header class="site-header">
<div class="container">
<a href="{{ url_for('install_step_1') }}" class="site-title" aria-label="Furtka Installer — restart">
<svg class="gate-mark" width="24" height="24" viewBox="0 0 64 64" aria-hidden="true">
<path fill="currentColor" fill-rule="evenodd" d="M5 60V27a27 27 0 0 1 54 0v33Z M19.5 60V26A10 10 0 0 1 29.5 16H46v10H32a2.5 2.5 0 0 0-2.5 2.5V32H46v10H29.5v18Z"/>
<svg class="gate-mark" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor"
stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
<path d="M4 20 V12 A9 9 0 0 1 20 12 V20"/>
<line x1="12" y1="5" x2="12" y2="20"/>
<line x1="3" y1="20" x2="21" y2="20"/>
<line x1="15" y1="12" x2="15" y2="14.5"/>
</svg>
<span class="wordmark">Furtka<span class="sep">·</span>Installer</span>
</a>
@ -27,7 +30,7 @@
<footer class="site-footer">
<div class="container">
<p class="kicker">Furtka {{ furtka_version }} · AGPL-3.0</p>
<p class="kicker">Furtka 26.4-alpha · AGPL-3.0</p>
<p class="kicker"><a href="https://furtka.org" style="color: inherit; text-decoration: none">furtka.org</a></p>
</div>
</footer>

View file

@ -17,38 +17,16 @@
{% else %}Installing Furtka{% endif %}
</h1>
<p class="lede" id="install-lede">
{% if progress.status == "done" %}Installation finished. Two more steps and Furtka is running from its own disk.
{% elif progress.status == "error" %}{{ progress.phase }}.
{% else %}This takes 5–20 minutes, depending on your internet speed. Don't power off the machine — it's fine to close this page and come back.{% endif %}
{% if progress.status == "done" %}Installation finished. <strong>Remove the installer USB / eject the ISO</strong>, then click Reboot.
{% elif progress.status == "error" %}Something went wrong. Open the details below and share them so we can help.
{% else %}This takes a few minutes. Don't close this page or power off the machine.{% endif %}
</p>
{% if progress.status == "error" %}
<p>Your settings are kept, so you can simply try again. If it fails the same way twice,
open <em>Show details</em> below — the last lines of the log say what went wrong.</p>
<form method="post" action="{{ url_for('install_run') }}"
onsubmit="this.querySelector('button').disabled = true;">
<div class="actions">
<button type="submit" class="btn btn-primary">Try again</button>
<a href="{{ url_for('install_step_2') }}" class="btn btn-link">Pick a different disk</a>
<a href="{{ url_for('install_step_1') }}" class="btn btn-link">Change settings</a>
</div>
</form>
{% endif %}
{% if progress.status == "done" %}
{# The USB-stick step has to be read *before* the click: the machine reboots
3 s after it, and most BIOSes boot the stick before the disk — so the
installer would just come back. Seen on the first hardware bench. #}
<ol class="next-steps">
<li><strong>Pull out the USB stick</strong> (or eject the installer ISO in your VM).
If it stays in, the computer will start this installer again instead of Furtka.</li>
<li>Click the button below. The computer restarts and comes back as
<strong>http://{{ hostname }}.local</strong> in about a minute.</li>
</ol>
<form method="post" action="{{ url_for('install_reboot') }}"
onsubmit="return confirm('USB stick removed? Click OK to restart the computer.');">
onsubmit="return confirm('Have you removed the installer USB / ejected the ISO? Click OK to reboot.');">
<div class="actions">
<button type="submit" class="btn btn-primary">USB stick is out — restart now</button>
<button type="submit" class="btn btn-primary">Reboot now</button>
</div>
</form>
{% endif %}
@ -56,7 +34,7 @@ open <em>Show details</em> below — the last lines of the log say what went wro
<div class="progress" role="progressbar" aria-valuemin="0" aria-valuemax="100" aria-valuenow="{{ progress.percent }}">
<div id="progress-bar" class="progress-bar{% if progress.status == 'error' %} progress-bar-error{% endif %}{% if progress.status == 'done' %} progress-bar-done{% endif %}" style="width: {{ progress.percent }}%;"></div>
</div>
<p class="progress-phase" aria-live="polite"><span id="progress-phase">{{ progress.phase }}</span> · <span id="progress-percent">{{ progress.percent }}</span>%</p>
<p class="progress-phase"><span id="progress-phase">{{ progress.phase }}</span> · <span id="progress-percent">{{ progress.percent }}</span>%</p>
<details class="log-details">
<summary>Show details</summary>
@ -72,7 +50,17 @@ open <em>Show details</em> below — the last lines of the log say what went wro
var phaseEl = document.getElementById('progress-phase');
var percentEl = document.getElementById('progress-percent');
var logEl = document.getElementById('install-log');
var failures = 0;
var headingEl = document.getElementById('install-heading');
var ledeEl = document.getElementById('install-lede');
var HEADINGS = {
done: 'Furtka is ready',
error: 'Installation hit a snag',
};
var LEDES = {
done: 'Installation finished. Remove the USB / eject the installer image, then reboot.',
error: 'Something went wrong. Open the details below and share them so we can help.',
};
function tick() {
fetch('{{ url_for("install_log_json") }}', { cache: 'no-store' })
@ -80,7 +68,6 @@ open <em>Show details</em> below — the last lines of the log say what went wro
.then(function (data) {
var p = data.progress;
bar.style.width = p.percent + '%';
bar.parentNode.setAttribute('aria-valuenow', p.percent);
phaseEl.textContent = p.phase;
percentEl.textContent = p.percent;
if (logEl.textContent !== data.log) {
@ -88,23 +75,21 @@ open <em>Show details</em> below — the last lines of the log say what went wro
logEl.textContent = data.log || '(waiting for install to start)';
if (atBottom) logEl.scrollTop = logEl.scrollHeight;
}
if (p.status === 'done' || p.status === 'error') {
// Reload so the server-rendered Done / error state (with
// its Reboot or Try-again buttons) replaces the
// running-state markup.
if (p.status === 'done') {
// Reload so the server-rendered Done state (with the
// Reboot button) replaces the running-state markup.
window.location.reload();
return;
}
failures = 0;
setTimeout(tick, 3000);
})
.catch(function () {
failures += 1;
if (failures >= 2) {
phaseEl.textContent = 'Lost connection to the installer — retrying…';
if (p.status === 'error') {
bar.classList.add('progress-bar-error');
headingEl.textContent = HEADINGS.error;
ledeEl.textContent = LEDES.error;
return;
}
setTimeout(tick, 3000);
});
})
.catch(function () { setTimeout(tick, 3000); });
}
setTimeout(tick, 3000);
})();

View file

@ -5,27 +5,27 @@
{% block content %}
<h1>Confirm install</h1>
<p class="lede">Check the settings below. Continuing will <strong>erase <code>{{ settings.boot_drive }}</code></strong> and install Furtka on it.</p>
<p class="lede">Review the settings below. Continuing will <strong>wipe <code>{{ settings.boot_drive }}</code></strong> and install Furtka.</p>
<div class="alert alert-warn" role="alert">
Everything currently on this disk will be deleted and can't be recovered.
<div class="alert alert-warn">
This is destructive. All existing data on the selected boot drive will be lost.
</div>
<div class="summary">
<table>
<tr><td>Server name</td><td>{{ settings.hostname }} <span class="hint">(http://{{ settings.hostname }}.local)</span></td></tr>
<tr><td>Username</td><td>{{ settings.username }}</td></tr>
<tr><td>Password</td><td>{{ settings.password }}</td></tr>
<tr><td>Language</td><td>{{ language_label }}</td></tr>
<tr><td>Disk</td><td>{{ settings.boot_drive }}{% if drive %} — {{ drive.size }} {{ drive.type_label }}{% endif %}</td></tr>
{% for k, v in settings.items() %}
<tr>
<td>{{ k.replace('_', ' ') }}</td>
<td>{{ v }}</td>
</tr>
{% endfor %}
</table>
</div>
<form method="post" action="{{ url_for('install_run') }}"
onsubmit="this.querySelector('button').disabled = true; this.querySelector('button').textContent = 'Starting…';">
<form method="post" action="{{ url_for('install_run') }}">
<div class="actions">
<button type="submit" class="btn btn-danger">Erase disk and install</button>
<a href="{{ url_for('install_step_2') }}" class="btn btn-link">← Back</a>
<button type="submit" class="btn btn-danger">Install — wipe drive and proceed</button>
<a href="{{ url_for('install_step_1') }}" class="btn btn-link">← Start over</a>
</div>
</form>
{% endblock %}

View file

@ -6,11 +6,7 @@
{% block content %}
<h1>Rebooting…</h1>
<p class="lede">The machine is restarting. This page will stop responding in a moment — that's expected.</p>
<p><strong>Remove the USB stick now</strong> — if it's still plugged in when the machine reboots, some BIOS setups will boot into this installer again instead of starting Furtka.</p>
<p class="muted">If the installer does come back anyway, your BIOS is set to boot from USB before the disk. Press the one-time boot menu key at startup (often <kbd>F11</kbd>, <kbd>F12</kbd>, or <kbd>Esc</kbd> — it flashes briefly on screen) and pick the internal disk, or change the boot order in BIOS settings.</p>
<p>When the machine comes back up (~1 minute), open Furtka in your browser:</p>
<p><a href="http://{{ hostname }}.local" class="btn btn-primary">http://{{ hostname }}.local</a></p>
<p class="muted">If that address doesn't open (some Android phones and older Windows PCs can't look up <code>.local</code> names),
use the IP address instead: it's shown on the machine's screen, or look for a device called
<strong>{{ hostname }}</strong> in your router's list of connected devices. The address may differ from the one the installer used.</p>
<p class="muted">If that doesn't resolve, your network may not support mDNS — use the IP address shown on the machine's console instead.</p>
{% endblock %}

View file

@ -4,11 +4,11 @@
{% block step_indicator %}<span class="step-indicator">Step 1 of 3</span>{% endblock %}
{% block content %}
<h1>Name &amp; account</h1>
<h1>Account &amp; hostname</h1>
<p class="lede">Set the server name and your administrator account.</p>
{% if errors %}
<div class="alert alert-error" role="alert">
<div class="alert alert-error">
<strong>Please fix the following:</strong>
<ul>
{% for e in errors %}<li>{{ e }}</li>{% endfor %}
@ -18,19 +18,19 @@
<form method="post" action="{{ url_for('install_step_1') }}">
<div class="field">
<label for="hostname">Server name</label>
<input type="text" id="hostname" name="hostname" required value="{{ values.hostname }}" autocomplete="off" autocapitalize="off" spellcheck="false" aria-describedby="hostname-hint" />
<span class="hint" id="hostname-hint">You'll open your server at <strong>http://<span id="hostname-preview">{{ values.hostname or "furtka" }}</span>.local</strong>. Letters, numbers and dashes only.</span>
<label for="hostname">Hostname</label>
<input type="text" id="hostname" name="hostname" required value="{{ values.hostname }}" autocomplete="off" />
<span class="hint">The name your server shows up as on your home network. Lowercase letters, numbers, and dashes only.</span>
</div>
<div class="field">
<label for="username">Admin username</label>
<input type="text" id="username" name="username" required value="{{ values.username }}" autocomplete="username" autocapitalize="off" spellcheck="false" aria-describedby="username-hint" />
<span class="hint" id="username-hint">Your login name on the server. You'll use this to sign in after installation.</span>
<input type="text" id="username" name="username" required value="{{ values.username }}" autocomplete="username" />
<span class="hint">Your login name on the server. You'll use this to sign in after installation.</span>
</div>
<div class="field">
<label for="password">Password</label>
<input type="password" id="password" name="password" required minlength="8" autocomplete="new-password" aria-describedby="password-hint" />
<span class="hint" id="password-hint">At least 8 characters. This one password is used for the web login and for the server itself (SSH / console), so pick a good one.</span>
<input type="password" id="password" name="password" required autocomplete="new-password" />
<span class="hint">At least 8 characters.</span>
</div>
<div class="field">
<label for="password2">Confirm password</label>
@ -38,25 +38,15 @@
</div>
<div class="field">
<label for="language">Language</label>
<select id="language" name="language" aria-describedby="language-hint">
<select id="language" name="language">
{% for code, lang in languages.items() %}
<option value="{{ code }}" {% if code == values.language %}selected{% endif %}>{{ lang.label }}</option>
{% endfor %}
</select>
<span class="hint" id="language-hint">Language and keyboard layout of the installed server. The Furtka pages themselves are in English for now.</span>
</div>
<div class="actions">
<button type="submit" class="btn btn-primary">Next — pick a disk</button>
<button type="submit" class="btn btn-primary">Next — pick boot drive</button>
</div>
</form>
<script>
(function () {
var input = document.getElementById('hostname');
var preview = document.getElementById('hostname-preview');
input.addEventListener('input', function () {
preview.textContent = input.value.trim().toLowerCase() || 'furtka';
});
})();
</script>
{% endblock %}

View file

@ -1,53 +1,39 @@
{% extends "base.html" %}
{% block title %}Step 2 — Disk · Furtka Installer{% endblock %}
{% block title %}Step 2 — Boot drive · Furtka Installer{% endblock %}
{% block step_indicator %}<span class="step-indicator">Step 2 of 3</span>{% endblock %}
{% block content %}
<h1>Pick a disk</h1>
<p class="lede">Choose the disk Furtka will be installed on. <strong>Everything on it will be erased.</strong> We pre-select the one that looks fastest and healthiest.</p>
<h1>Boot drive</h1>
<p class="lede">Pick the disk Furtka will install onto. We pre-select the drive that looks fastest and healthiest.</p>
{% set usable = drives | selectattr("usable") | list %}
{% set pick = selected if (selected and usable | selectattr("name", "equalto", selected) | list) else (usable[0].name if usable else "") %}
{% if error %}
<div class="alert alert-error" role="alert">{{ error }}</div>
{% endif %}
{% if not usable %}
<div class="alert alert-error" role="alert">
{% if drives %}
None of the disks in this computer is big enough for Furtka (it needs at least 16 GB).
{% else %}
No disk found to install onto. Check that the disk is connected, and in the BIOS/UEFI settings
that the SATA mode is "AHCI" rather than "RAID" or "Intel RST" — Linux can't see disks in RAID mode.
{% endif %}
{% if not drives %}
<div class="alert alert-error">
No physical disks detected. The live ISO can't see any install targets — check your BIOS / SATA controller.
</div>
{% endif %}
<form method="post" action="{{ url_for('install_step_2') }}">
<div class="drive-list">
{% for d in drives %}
<label class="drive{% if not d.usable %} drive-disabled{% endif %}">
<label class="drive">
<input type="radio" name="boot_drive" value="{{ d.name }}"
{% if not d.usable %}disabled{% elif d.name == pick %}checked{% endif %} />
{% if (selected and d.name == selected) or (not selected and loop.first) %}checked{% endif %} />
<span class="name">{{ d.name }}</span>
{% if not selected and d.usable and loop.first %}
{% if not selected and loop.first %}
<span class="badge-recommended">Recommended</span>
{% endif %}
<span class="meta">
<span class="chip">{{ d.size }}</span>
<span class="chip">{{ d.type_label }}</span>
{% if d.usable %}<span class="chip">{{ d.health_label }}</span>{% endif %}
<span class="chip">{{ d.health_label }}</span>
</span>
{% if d.reason %}<span class="drive-note">{{ d.reason }}</span>{% endif %}
{% if d.warning %}<span class="drive-note">{{ d.warning }}</span>{% endif %}
</label>
{% endfor %}
</div>
<div class="actions">
<button type="submit" class="btn btn-primary" {% if not usable %}disabled{% endif %}>Next — review</button>
<button type="submit" class="btn btn-primary" {% if not drives %}disabled{% endif %}>Next — review</button>
<a href="{{ url_for('install_step_1') }}" class="btn btn-link">← Back</a>
</div>
</form>

View file

@ -19,47 +19,22 @@ Hosted on `forge-runner-01` (Proxmox VM, Ubuntu 24.04). Hugo runs on the VM;
nginx serves the built output from `/var/www/furtka.org`. TLS is terminated by
an upstream openresty reverse proxy — the VM itself only speaks plain HTTP.
### Auto-deploy on push-to-main (default)
`.forgejo/workflows/deploy-site.yml` fires on every push to `main` that touches
`website/**`. The self-hosted runner *is* forge-runner-01, so the whole deploy
collapses to a local rsync into `/srv/furtka-site/` + `hugo --minify` into
`/var/www/furtka.org/`. No SSH hop, no secrets. Runs in under a minute.
The in-CI script is `website/deploy-ci.sh`. Don't invoke it from your dev box —
it assumes it's already on the target host.
### Manual deploy (fallback)
For out-of-band pushes (feature branch, CI outage), deploy from your dev
machine:
```sh
./website/deploy.sh
```
This rsyncs `website/` to `/srv/furtka-site/` on the VM over SSH and runs
`hugo --minify` into `/var/www/furtka.org`. Same end state as the CI path,
just with an SSH hop.
### Poll counter
`/api/poll/` is a tiny stdlib Python service (`ops/poll/pollsvc.py`,
SQLite, systemd `DynamicUser`) behind nginx on `127.0.0.1:8090`. Install or
update it on the VM with `sudo ops/poll/setup-poll.sh`; the nginx location
and rate limit come from `ops/nginx/` via `setup-vm.sh`. Devices are told
apart by a salted hash of IP + user agent — no cookie — and a repeat vote
from the same device changes the choice instead of adding one.
### First-time VM setup
Only needed once, when provisioning a fresh forge-runner VM:
First time only, on the VM:
```sh
ssh forge-runner
sudo /srv/furtka-site/ops/nginx/setup-vm.sh # or copy the script over first
```
From then on, deploy from your dev machine:
```sh
./website/deploy.sh
```
The script rsyncs `website/` to `/srv/furtka-site/` on the VM and runs
`hugo --minify` into `/var/www/furtka.org`.
## Structure
```
@ -67,21 +42,13 @@ hugo.toml Hugo config (multilingual: en default, de)
content/ Markdown pages
_index.md Home (EN)
_index.de.md Home (DE)
logo.md / .de.md Logo poll (/logo/, /de/logo/) — layout `poll`, votes via /api/poll/
layouts/ Custom inline theme — no external theme or framework
_default/ baseof, single, list
partials/ head, header, footer, gate SVG, lang switcher, mark-m / mark-r (poll)
poll/single.html Logo poll page; strings come from the content front matter
partials/ head, header, footer, gate SVG, lang switcher
index.html Home-only layout with editorial hero
assets/css/main.css Stylesheet (fingerprinted + minified on build)
assets/js/poll.js Vote script for /logo/ — plain fetch, no cookies, no storage
data/logo/{m,r}.json Generated: mark SVGs, lockups, 16 px icons, console banner per proposal
static/img/logo/ Generated: tiles, splash and social card per proposal
tools/logo-assets.py Generator for the two lines above (cairosvg + Pillow + fontTools);
run on the dev box, commit the output. Font: tools/FONT-PROVENANCE.md
static/favicon.svg Gate mark in crimson
deploy.sh Manual rsync + remote Hugo build (over SSH, for off-CI pushes)
deploy-ci.sh Local rsync + Hugo build — runs on forge-runner-01 from CI
deploy.sh Rsync + remote Hugo build
```
## Design

View file

@ -6,10 +6,6 @@
--accent: #c03a28;
--accent-hover: #a0301f;
--border: #e4e3dc;
--accent-glow: rgba(192, 58, 40, 0.2);
--card-bg: rgba(247, 246, 243, 0.72);
--card-border: var(--border);
--scene-opacity: 0.18;
--font-sans:
-apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "Helvetica Neue",
Arial, "Noto Sans", sans-serif;
@ -27,10 +23,6 @@
--accent: #ff6b56;
--accent-hover: #ff8b78;
--border: #232326;
--accent-glow: rgba(255, 107, 86, 0.4);
--card-bg: rgba(23, 23, 26, 0.65);
--card-border: #26262b;
--scene-opacity: 0.34;
}
}
@ -51,25 +43,6 @@ body {
flex-direction: column;
min-height: 100vh;
text-rendering: optimizeLegibility;
isolation: isolate;
}
/* ── Animated background canvas (home only) ─────────────── */
.scene-canvas {
position: fixed;
inset: 0;
width: 100vw;
height: 100vh;
z-index: 0;
pointer-events: none;
}
.site-header,
main.container,
.site-footer {
position: relative;
z-index: 1;
}
.container {
@ -198,36 +171,11 @@ main.container {
.home h1 {
font-family: var(--font-sans);
font-weight: 800;
font-size: clamp(3.5rem, 14vw, 11rem);
line-height: 0.9;
letter-spacing: -0.04em;
font-size: clamp(3.25rem, 10vw, 6.5rem);
line-height: 0.95;
letter-spacing: -0.035em;
margin: 0 0 1.5rem;
color: var(--fg);
background-image: linear-gradient(180deg, var(--fg) 0%, var(--accent) 110%);
-webkit-background-clip: text;
background-clip: text;
-webkit-text-fill-color: transparent;
}
@media (prefers-color-scheme: dark) {
.home h1 {
filter: drop-shadow(0 0 28px var(--accent-glow));
}
.home .lede {
color: #c8c8cc;
}
}
.hero {
min-height: 78vh;
display: flex;
flex-direction: column;
justify-content: center;
padding-block: 4.5rem 3rem;
}
.home .lede {
font-weight: 450;
}
.home .lede {
@ -310,479 +258,3 @@ main.container {
outline-offset: 3px;
border-radius: 2px;
}
/* ── Primary CTA ─────────────────────────────────────────── */
.cta-row { margin-top: 2.5rem; }
.cta {
display: inline-flex;
align-items: center;
gap: 0.55rem;
padding: 1.1rem 2rem;
font-family: var(--font-sans);
font-weight: 600;
font-size: 1.02rem;
letter-spacing: 0.005em;
text-decoration: none;
border-radius: 0.7rem;
transition: transform 180ms, box-shadow 180ms, background 180ms, color 180ms;
}
.cta--primary {
background: linear-gradient(135deg, var(--accent), var(--accent-hover));
color: #fff;
box-shadow: 0 10px 36px var(--accent-glow),
0 0 0 1px color-mix(in srgb, var(--accent) 40%, transparent);
animation: cta-pulse 2.8s ease-in-out infinite;
}
.cta--primary:hover {
transform: translateY(-3px);
box-shadow: 0 18px 52px var(--accent-glow),
0 0 0 1px var(--accent);
animation-play-state: paused;
}
.cta--primary:active { transform: translateY(-1px); }
.cta--primary span { transition: transform 180ms; }
.cta--primary:hover span { transform: translateX(4px); }
@keyframes cta-pulse {
0%, 100% { box-shadow: 0 10px 36px var(--accent-glow),
0 0 0 1px color-mix(in srgb, var(--accent) 40%, transparent); }
50% { box-shadow: 0 14px 48px var(--accent-glow),
0 0 0 1px color-mix(in srgb, var(--accent) 70%, transparent); }
}
@media (prefers-reduced-motion: reduce) {
.cta--primary { animation: none; }
}
.cta-row {
display: flex;
flex-wrap: wrap;
align-items: center;
gap: 0.9rem 1.1rem;
}
.cta--ghost {
color: var(--fg);
border: 1px solid var(--border);
background: var(--card-bg);
-webkit-backdrop-filter: blur(10px);
backdrop-filter: blur(10px);
}
.cta--ghost:hover {
border-color: var(--accent);
color: var(--accent);
transform: translateY(-2px);
}
/* ── How it works (home) ─────────────────────────────────── */
.how-steps {
list-style: none;
counter-reset: how;
margin: 0;
padding: 0;
display: grid;
grid-template-columns: repeat(auto-fit, minmax(14rem, 1fr));
gap: 1rem;
}
.how-step {
counter-increment: how;
position: relative;
background: var(--card-bg);
border: 1px solid var(--card-border);
border-radius: 1rem;
padding: 1.4rem 1.5rem 1.5rem;
-webkit-backdrop-filter: blur(10px);
backdrop-filter: blur(10px);
}
.how-step::before {
content: counter(how, decimal-leading-zero);
display: block;
font-family: var(--font-mono);
font-weight: 600;
font-size: 2.4rem;
line-height: 1;
letter-spacing: -0.04em;
margin-bottom: 0.9rem;
background-image: linear-gradient(135deg, var(--accent), var(--fg-muted));
-webkit-background-clip: text;
background-clip: text;
-webkit-text-fill-color: transparent;
}
.how-step h3 { margin: 0 0 0.35rem; font-size: 1.15rem; letter-spacing: -0.01em; }
.how-step p { margin: 0; color: var(--fg-muted); font-size: 0.98rem; line-height: 1.5; }
.how-step strong { color: var(--fg); font-weight: 600; }
/* ── Download band (home, before the closer) ─────────────── */
.dl-band {
display: flex;
flex-wrap: wrap;
align-items: center;
justify-content: space-between;
gap: 1.25rem 2rem;
margin-block: 4.5rem 0;
padding: 2rem;
border-radius: 1.25rem;
border: 1px solid color-mix(in srgb, var(--accent) 45%, var(--border));
background:
radial-gradient(120% 140% at 0% 0%, color-mix(in srgb, var(--accent) 14%, transparent), transparent 60%),
var(--card-bg);
-webkit-backdrop-filter: blur(10px);
backdrop-filter: blur(10px);
}
.dl-band h2 { margin: 0 0 0.3rem; font-size: 1.5rem; letter-spacing: -0.02em; }
.dl-band p { margin: 0; color: var(--fg-muted); max-width: 26rem; }
/* ── Download page ───────────────────────────────────────── */
.dl-hero { padding-block: 1rem 1rem; }
.dl-hero h1 {
font-size: clamp(2.4rem, 7vw, 4rem);
line-height: 1;
letter-spacing: -0.035em;
margin: 0 0 1rem;
}
.dl-hero .lede {
font-size: clamp(1.05rem, 1.6vw, 1.2rem);
color: var(--fg-muted);
max-width: 36rem;
margin: 0 0 2rem;
}
.dl-card {
padding: 1.75rem;
border-radius: 1.25rem;
border: 1px solid color-mix(in srgb, var(--accent) 45%, var(--border));
background:
radial-gradient(120% 140% at 100% 0%, color-mix(in srgb, var(--accent) 12%, transparent), transparent 60%),
var(--card-bg);
}
.dl-card .cta svg { flex: none; }
.dl-meta {
display: flex;
flex-wrap: wrap;
gap: 0.75rem 2rem;
margin: 1.5rem 0 0;
}
.dl-meta div { min-width: 0; }
.dl-meta dt {
font-size: 0.7rem;
font-weight: 500;
letter-spacing: 0.14em;
text-transform: uppercase;
color: var(--fg-muted);
}
.dl-meta dd { margin: 0.15rem 0 0; overflow-wrap: anywhere; }
.mono { font-family: var(--font-mono); font-size: 0.92em; }
.dl-host { margin: 1.25rem 0 0; font-size: 0.85rem; color: var(--fg-muted); }
.dl-alpha {
margin: 1.5rem 0 0;
padding: 0.9rem 1.1rem;
border-left: 3px solid var(--accent);
background: var(--bg-subtle);
border-radius: 0 0.6rem 0.6rem 0;
font-size: 0.95rem;
max-width: 40rem;
}
.dl-section { margin-top: 3.5rem; }
.dl-needs { margin: 0; padding-left: 1.2rem; max-width: 40rem; }
.dl-needs li { margin-bottom: 0.55rem; }
.dl-needs li::marker { color: var(--accent); }
.dl-steps {
list-style: none;
counter-reset: dl;
margin: 0;
padding: 0;
display: flex;
flex-direction: column;
gap: 1rem;
}
.step-card {
counter-increment: dl;
display: grid;
grid-template-columns: 2.6rem 1fr;
gap: 0 1rem;
padding: 1.3rem 1.4rem;
border: 1px solid var(--card-border);
border-radius: 1rem;
background: var(--card-bg);
}
.step-card::before {
content: counter(dl);
grid-row: span 2;
display: grid;
place-items: center;
width: 2.6rem;
height: 2.6rem;
border-radius: 999px;
background: linear-gradient(135deg, var(--accent), var(--accent-hover));
color: #fff;
font-weight: 700;
}
.step-card h2 { margin: 0.35rem 0 0.4rem; font-size: 1.15rem; letter-spacing: -0.01em; }
.step-body { min-width: 0; color: var(--fg-muted); }
.step-body p { margin: 0 0 0.7rem; }
.step-body p:last-child { margin: 0; }
.step-body strong { color: var(--fg); font-weight: 600; }
.step-body a, .dl-older a, .dl-alpha a { color: var(--accent); }
kbd {
font-family: var(--font-mono);
font-size: 0.85em;
padding: 0.05rem 0.4rem;
border: 1px solid var(--border);
border-bottom-width: 2px;
border-radius: 0.3rem;
background: var(--bg);
color: var(--fg);
}
.download pre {
margin: 0.4rem 0 0;
padding: 0.85rem 1rem;
border-radius: 0.6rem;
background: var(--bg-subtle);
border: 1px solid var(--border);
overflow-x: auto;
font-size: 0.85rem;
line-height: 1.55;
color: var(--fg);
}
.download code { font-family: var(--font-mono); }
.download :not(pre) > code {
padding: 0.05rem 0.3rem;
border-radius: 0.3rem;
background: var(--bg-subtle);
font-size: 0.9em;
}
.dl-hash code { overflow-wrap: anywhere; white-space: pre-wrap; }
.dl-cmds .c { color: var(--fg-muted); }
.dl-muted { color: var(--fg-muted); }
.dl-older { margin-top: 3rem; }
@media (max-width: 30rem) {
.dl-card, .dl-band { padding: 1.35rem; }
.step-card { grid-template-columns: 1fr; }
.step-card::before { grid-row: auto; margin-bottom: 0.6rem; }
}
/* ── Intro paragraph (home, between hero and feature grids) ─ */
.intro {
max-width: 38rem;
margin: 0 0 4rem;
font-size: 1.15rem;
line-height: 1.55;
color: var(--fg);
}
.intro p { margin: 0 0 1rem; }
.intro p:last-child { margin: 0; }
.intro strong { font-weight: 600; }
/* ── Feature sections (home) ─────────────────────────────── */
.feature-section { margin-block: 4rem; }
.section-eyebrow {
font-family: var(--font-sans);
font-weight: 500;
font-size: 0.72rem;
letter-spacing: 0.14em;
text-transform: uppercase;
color: var(--fg-muted);
margin: 0 0 1.25rem;
}
.feature-grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(17rem, 1fr));
gap: 1rem;
}
.feature-card {
background: var(--card-bg);
border: 1px solid var(--card-border);
border-radius: 1rem;
padding: 1.5rem 1.5rem 1.4rem;
-webkit-backdrop-filter: blur(10px);
backdrop-filter: blur(10px);
transition: transform 240ms, border-color 240ms, box-shadow 240ms;
}
.feature-card:hover {
border-color: var(--accent);
box-shadow: 0 10px 32px var(--accent-glow);
transform: translateY(-2px);
}
.feature-card p {
margin: 0;
font-size: 1rem;
line-height: 1.55;
color: var(--fg);
}
.feature-card strong {
font-weight: 600;
color: var(--fg);
}
/* ── Closer prose (home, after feature grids) ────────────── */
.closer {
margin-top: 4rem;
max-width: var(--measure);
}
/* ── Reveal-on-load (hero) and reveal-on-scroll (cards) ──── */
.js .reveal,
.js [data-gsap="card"] {
opacity: 0;
transform: translateY(40px);
will-change: opacity, transform;
}
@media (prefers-reduced-motion: reduce) {
.scene-canvas { display: none; }
.js .reveal,
.js [data-gsap="card"] {
opacity: 1 !important;
transform: none !important;
will-change: auto;
}
}
/* ── Logo poll ───────────────────────────────────────────────── */
.poll-grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(17rem, 1fr));
gap: 1.5rem;
margin-top: 2.5rem;
}
.poll-card {
display: flex;
flex-direction: column;
gap: 0.9rem;
padding: 1.4rem;
border: 1px solid var(--card-border);
border-radius: 1rem;
background: var(--card-bg);
transition: border-color 160ms, box-shadow 160ms;
}
.is-yours { border-color: var(--accent); box-shadow: 0 0 0 3px var(--accent-glow); }
.poll-card h2 { margin: 0; font-size: 1.25rem; letter-spacing: -0.01em; }
.poll-desc { margin: 0; color: var(--fg-muted); font-size: 0.95rem; }
.poll-art { display: grid; place-items: center; padding: 2.2rem 1rem; border-radius: 0.75rem; }
.poll-art--m { background: var(--bg-subtle); }
.poll-art--r { background: #151515; }
.poll-art .pm { height: 9rem; width: auto; }
.pm { display: block; }
.poll-btn {
margin-top: auto;
padding: 0.85rem 1.2rem;
border: 1px solid var(--accent);
border-radius: 0.6rem;
background: var(--accent);
color: #fff;
font: inherit;
font-weight: 600;
cursor: pointer;
transition: background 140ms, transform 140ms;
}
.poll-btn:hover:not(:disabled) { background: var(--accent-hover); transform: translateY(-1px); }
.poll-btn:focus-visible { outline: 2px solid var(--fg); outline-offset: 2px; }
.poll-btn:disabled { cursor: default; opacity: 0.85; }
.is-yours .poll-btn { background: transparent; color: var(--accent); }
.poll-bar { height: 0.45rem; border-radius: 999px; background: var(--bg-subtle); overflow: hidden; display: none; }
.poll-bar i { display: block; height: 100%; width: 0; background: var(--accent); transition: width 500ms ease; }
.poll--voted .poll-bar { display: block; }
.poll-bar--final { display: block; }
.poll-result {
margin: 1.5rem 0 0;
padding: 1rem 1.2rem;
border-left: 3px solid var(--accent);
background: var(--bg-subtle);
border-radius: 0 0.6rem 0.6rem 0;
max-width: 40rem;
}
.poll-result strong { color: var(--accent); }
.poll-count { margin: 0; min-height: 1.4em; font-size: 0.85rem; color: var(--fg-muted); font-variant-numeric: tabular-nums; }
.poll-status { margin: 1.25rem 0 0; min-height: 1.5em; color: var(--fg-muted); }
.poll-note { margin-top: 1rem; max-width: var(--measure); font-size: 0.85rem; color: var(--fg-muted); }
.poll-note a { color: var(--accent); }
/* showcase */
.show { display: flex; flex-direction: column; gap: 3rem; margin-top: 4rem; }
.show-intro { margin: 0; }
.show-row { display: flex; flex-direction: column; gap: 0.6rem; }
.show-h { margin: 0; font-size: 1.35rem; letter-spacing: -0.01em; }
.show-p { margin: 0 0 0.6rem; max-width: var(--measure); color: var(--fg-muted); font-size: 0.95rem; }
.show-pair { display: grid; grid-template-columns: repeat(auto-fit, minmax(17rem, 1fr)); gap: 1.25rem; }
.show-cell {
display: flex; flex-direction: column; gap: 0.9rem;
padding: 1.1rem; border: 1px solid var(--card-border); border-radius: 0.9rem; background: var(--card-bg);
}
.show-cell--m { color: var(--accent); }
.show-cell--r { color: #af6428; }
.show-tag { font-size: 0.7rem; letter-spacing: 0.12em; text-transform: uppercase; color: var(--fg-muted); }
.sizes { display: flex; align-items: flex-end; gap: 1.1rem; flex-wrap: wrap; }
.sizes figure { margin: 0; display: flex; flex-direction: column; align-items: center; gap: 0.35rem; }
.sizes figcaption { font-family: var(--font-mono); font-size: 0.65rem; color: var(--fg-muted); }
.sz { display: block; }
.sz .pm { width: auto; }
.sz-96 .pm { height: 96px; } .sz-48 .pm { height: 48px; } .sz-32 .pm { height: 32px; } .sz-24 .pm { height: 24px; }
.px { image-rendering: pixelated; }
.lock { display: grid; place-items: center; padding: 1rem; border-radius: 0.6rem; background: var(--bg-subtle); }
.lock-h .pm { height: 3rem; width: auto; max-width: 100%; }
.lock-s .pm { height: 7rem; width: auto; max-width: 100%; }
.mock { border: 1px solid var(--border); border-radius: 0.7rem; overflow: hidden; background: var(--bg); color: var(--fg); font-size: 0.8rem; }
.tabbar { background: var(--bg-subtle); padding: 0.55rem 0.65rem; }
.tab { display: inline-flex; align-items: center; gap: 0.45rem; padding: 0.3rem 0.65rem; border: 1px solid var(--border); border-radius: 0.4rem; background: var(--bg); color: var(--fg-muted); }
.urlbar { padding: 0.45rem 0.75rem; border-top: 1px solid var(--border); font-family: var(--font-mono); font-size: 0.72rem; color: var(--fg-muted); }
.uihead { display: flex; align-items: center; gap: 0.5rem; padding: 0.8rem 0.9rem; border-top: 1px solid var(--border); border-bottom: 1px solid var(--border); }
.uihead-mark { display: inline-flex; color: inherit; }
.show-cell--m .uihead-mark { color: var(--accent); }
.show-cell--r .uihead-mark { color: #af6428; }
.uihead-mark .pm { height: 1.25rem; width: auto; }
.uihead-name { font-weight: 600; letter-spacing: 0.13em; text-transform: uppercase; font-size: 0.72rem; color: var(--fg); }
.uihead-nav { margin-left: auto; display: flex; gap: 0.7rem; color: var(--fg-muted); }
.uibody { padding: 0.9rem; display: flex; flex-direction: column; gap: 0.45rem; }
.uibody i { display: block; height: 0.45rem; border-radius: 3px; background: var(--bg-subtle); }
.uibody i.s { width: 45%; }
.tiles { display: flex; gap: 1rem; justify-content: center; padding: 1.2rem; border-radius: 0.6rem; background: var(--bg-subtle); }
.tiles img { width: 6rem; height: 6rem; }
.term {
margin: 0; padding: 0.9rem 1rem; border-radius: 0.6rem; overflow-x: auto;
background: #100f0c; color: #d8d2c2; font-family: var(--font-mono); font-size: 0.55rem; line-height: 1;
-webkit-text-size-adjust: none; text-size-adjust: none;
}
.term i, .term b { font-style: normal; font-weight: 400; color: inherit; }
.show-cell--m .term i { color: #ff6b56; } .show-cell--m .term b { color: #ff6b56; }
.show-cell--r .term i { color: #c9803f; } .show-cell--r .term b { color: #c9803f; }
.shot { display: block; width: 100%; height: auto; border-radius: 0.6rem; }
.grounds { display: grid; grid-template-columns: repeat(2, 1fr); gap: 0.6rem; }
.ground { display: grid; place-items: center; padding: 1.2rem; border-radius: 0.6rem; border: 1px solid var(--border); }
.ground--fg { background: var(--bg-subtle); color: var(--fg); }
.ground .pm { height: 3.2rem; width: auto; }
.poll-final { margin-top: 3.5rem; display: flex; flex-direction: column; gap: 1rem; }
.poll-final-row { display: grid; grid-template-columns: repeat(auto-fit, minmax(15rem, 1fr)); gap: 1.25rem; }
.poll-final-card {
display: flex; flex-direction: column; gap: 0.8rem; align-items: stretch;
padding: 1.1rem; border: 1px solid var(--card-border); border-radius: 0.9rem; background: var(--card-bg);
}
.poll-final-mark { display: grid; place-items: center; padding: 0.8rem; }
.poll-final-mark .pm { height: 3.5rem; width: auto; }
.home-poll {
display: inline-flex; align-items: center; gap: 0.6rem; margin-top: 1.4rem;
padding: 0.55rem 0.95rem; border: 1px solid var(--border); border-radius: 999px;
background: var(--card-bg); color: var(--fg); text-decoration: none; font-size: 0.92rem;
}
.home-poll:hover { border-color: var(--accent); }
.home-poll .pm { height: 1.1rem; width: auto; color: var(--accent); }
.home-poll .pm + .pm { color: #af6428; }
/* Random left/right per visit — data-logo-order is set in head.html before first paint.
Every pair container is a grid/flex, so `order` swaps the columns without touching the DOM. */
[data-logo-order="rm"] .poll-card[data-choice="m"],
[data-logo-order="rm"] .show-cell.show-cell--m,
[data-logo-order="rm"] .poll-final-card[data-choice="m"],
[data-logo-order="mr"] .poll-card[data-choice="r"],
[data-logo-order="mr"] .show-cell.show-cell--r,
[data-logo-order="mr"] .poll-final-card[data-choice="r"] { order: 1; }
@media (prefers-reduced-motion: reduce) { .poll-bar i, .poll-btn { transition: none; } }

View file

@ -1,25 +0,0 @@
(function () {
if (window.matchMedia('(prefers-reduced-motion: reduce)').matches) return;
if (!window.gsap || !window.ScrollTrigger || !window.Lenis) return;
gsap.registerPlugin(ScrollTrigger);
const lenis = new Lenis({ lerp: 0.1, smoothWheel: true });
lenis.on('scroll', ScrollTrigger.update);
gsap.ticker.add((time) => { lenis.raf(time * 1000); });
gsap.ticker.lagSmoothing(0);
// Hero stagger — runs once on load.
gsap.to('.hero .reveal', {
y: 0, opacity: 1, duration: 1.1, ease: 'power3.out', stagger: 0.12
});
// Card reveals — batched so cards in the same row come in together.
ScrollTrigger.batch('[data-gsap="card"]', {
start: 'top 90%',
onEnter: (els) => gsap.to(els, {
y: 0, opacity: 1, scale: 1,
duration: 0.9, ease: 'power3.out', stagger: 0.08, overwrite: true
})
});
})();

View file

@ -1,59 +0,0 @@
// furtka.org logo poll — no cookies, no storage. The server tells us
// what this device already chose (salted hash of IP + user agent).
(function () {
var root = document.querySelector('.poll');
if (!root || !window.fetch) return;
var api = root.dataset.api;
var status = root.querySelector('.poll-status');
var t = status.dataset;
var buttons = root.querySelectorAll('[data-vote]');
var busy = false;
function pct(n, total) { return total ? Math.round((n / total) * 100) : 0; }
function each(sel, fn) { Array.prototype.forEach.call(root.querySelectorAll(sel), fn); }
function render(d) {
var total = d.total || 0;
root.classList.toggle('poll--voted', !!d.yours);
Object.keys(d.counts).forEach(function (k) {
var n = d.counts[k], p = pct(n, total), mine = d.yours === k;
each('[data-choice="' + k + '"]', function (card) {
card.classList.toggle('is-yours', mine);
var bar = card.querySelector('.poll-bar i'); if (bar) bar.style.width = p + '%';
var c = card.querySelector('[data-count]'); if (c) c.textContent = d.yours ? p + ' % · ' + n : '';
});
each('[data-vote="' + k + '"]', function (b) {
b.disabled = mine;
b.textContent = mine ? t.tVoted : (d.yours ? t.tChange : b.dataset.label);
});
});
status.textContent = d.yours ? t.tTotal.replace('{n}', total) : '';
}
function load() {
fetch(api, { headers: { Accept: 'application/json' } })
.then(function (r) { if (!r.ok) throw r; return r.json(); })
.then(render)
.catch(function () { status.textContent = t.tError; });
}
Array.prototype.forEach.call(buttons, function (b) {
b.dataset.label = b.textContent;
b.addEventListener('click', function () {
if (busy) return;
busy = true; b.disabled = true;
fetch(api, {
method: 'POST',
headers: { 'Content-Type': 'application/json', Accept: 'application/json' },
body: JSON.stringify({ choice: b.dataset.vote })
})
.then(function (r) { if (!r.ok) throw r; return r.json(); })
.then(function (d) { render(d); status.scrollIntoView({ block: 'nearest' }); })
.catch(function () { status.textContent = t.tError; b.disabled = false; })
.then(function () { busy = false; });
});
});
status.textContent = '';
load();
})();

View file

@ -1,98 +0,0 @@
(function () {
if (window.matchMedia('(prefers-reduced-motion: reduce)').matches) return;
if (!window.WebGLRenderingContext || !window.THREE) return;
const canvas = document.getElementById('scene');
if (!canvas) return;
const root = document.documentElement;
const readVar = (name) => getComputedStyle(root).getPropertyValue(name).trim();
const readOpacity = () => parseFloat(readVar('--scene-opacity')) || 0.18;
const scene = new THREE.Scene();
const camera = new THREE.PerspectiveCamera(
60, window.innerWidth / window.innerHeight, 0.1, 100
);
const renderer = new THREE.WebGLRenderer({ canvas, antialias: true, alpha: true });
renderer.setSize(window.innerWidth, window.innerHeight, false);
renderer.setPixelRatio(Math.min(window.devicePixelRatio || 1, 2));
const geometry = new THREE.TorusKnotGeometry(2.5, 0.4, 130, 20);
const material = new THREE.MeshPhongMaterial({
color: readVar('--accent') || '#c03a28',
wireframe: true,
transparent: true,
opacity: readOpacity()
});
const core = new THREE.Mesh(geometry, material);
scene.add(core);
scene.add(new THREE.AmbientLight(0xffffff, 0.6));
const dir = new THREE.DirectionalLight(0xffffff, 0.8);
dir.position.set(5, 5, 5);
scene.add(dir);
const BASE_Z = 9;
camera.position.z = BASE_Z;
let scrollY = window.scrollY || 0;
window.addEventListener('scroll', () => {
scrollY = window.scrollY || 0;
}, { passive: true });
let baseOpacity = readOpacity();
let running = true;
function tick() {
if (!running) return;
requestAnimationFrame(tick);
// Continuous slow drift.
core.rotation.y += 0.0015;
core.rotation.z += 0.0006;
// Scroll-driven motion: zoom in, scale up, tilt.
const s = Math.min(scrollY, 2000);
camera.position.z = BASE_Z - s * 0.0022;
const scale = 1 + s * 0.00035;
core.scale.set(scale, scale, scale);
core.rotation.x = s * 0.0008;
// Fade past hero so feature cards stay readable.
const vh = window.innerHeight;
const fadeStart = vh * 0.5;
const fadeEnd = vh * 1.4;
const t = Math.max(0, Math.min(1, (scrollY - fadeStart) / (fadeEnd - fadeStart)));
material.opacity = baseOpacity * (1 - t * 0.92);
renderer.render(scene, camera);
}
tick();
window.addEventListener('resize', () => {
camera.aspect = window.innerWidth / window.innerHeight;
camera.updateProjectionMatrix();
renderer.setSize(window.innerWidth, window.innerHeight, false);
});
document.addEventListener('visibilitychange', () => {
if (document.hidden) {
running = false;
} else if (!running) {
running = true;
tick();
}
});
const mql = window.matchMedia('(prefers-color-scheme: dark)');
const updateTheme = () => {
const accent = readVar('--accent');
if (accent) material.color.set(accent);
baseOpacity = readOpacity();
};
if (mql.addEventListener) {
mql.addEventListener('change', updateTheme);
} else if (mql.addListener) {
mql.addListener(updateTheme);
}
})();

View file

@ -1,19 +0,0 @@
# Vendored JavaScript libraries
These minified bundles are checked into the repo so furtka.org has zero
third-party-CDN dependencies at runtime. Pin date: **2026-04-27**.
| File | Version | Source |
|---|---|---|
| `three.min.js` | r128 | https://cdnjs.cloudflare.com/ajax/libs/three.js/r128/three.min.js |
| `gsap.min.js` | 3.12.2 (core only) | https://cdnjs.cloudflare.com/ajax/libs/gsap/3.12.2/gsap.min.js |
| `ScrollTrigger.min.js` | 3.12.2 | https://cdnjs.cloudflare.com/ajax/libs/gsap/3.12.2/ScrollTrigger.min.js |
| `lenis.min.js` | @studio-freight/lenis 1.0.33 | https://unpkg.com/@studio-freight/lenis@1.0.33/dist/lenis.min.js |
All four expose UMD globals (`THREE`, `gsap`, `ScrollTrigger`, `Lenis`).
None are ES modules, so no `js.Build` step is needed — Hugo just fingerprints them.
GSAP "Club" plugins (SplitText, MorphSVG, etc.) are **not** free for commercial use.
Only `gsap` core + `ScrollTrigger` (both standard MIT-style license) are bundled.
To refresh: re-run `curl -sSfL -o <file> <url>` and bump the pin date here.

File diff suppressed because one or more lines are too long

Some files were not shown because too many files have changed in this diff Show more