Compare commits
No commits in common. "main" and "release-26.4-alpha" have entirely different histories.
main
...
release-26
|
|
@ -12,7 +12,6 @@ on:
|
||||||
- '**/*.md'
|
- '**/*.md'
|
||||||
- 'docs/**'
|
- 'docs/**'
|
||||||
- 'website/**'
|
- 'website/**'
|
||||||
- 'ops/**'
|
|
||||||
- 'CHANGELOG.md'
|
- 'CHANGELOG.md'
|
||||||
- 'RELEASING.md'
|
- 'RELEASING.md'
|
||||||
workflow_dispatch:
|
workflow_dispatch:
|
||||||
|
|
|
||||||
|
|
@ -1,69 +1,28 @@
|
||||||
name: Release
|
name: Release
|
||||||
|
|
||||||
# Tag-triggered: when `git push origin <version>` lands, this builds the
|
# Tag-triggered: when `git push origin <version>` lands, this builds the
|
||||||
# release tarball + the live-installer ISO, and publishes them both to
|
# release tarball and publishes it + the sha256 + release.json to the
|
||||||
# the Forgejo releases page. Boxes POST /api/furtka/update to pull the
|
# Forgejo releases page for that tag. Boxes then POST /api/furtka/update
|
||||||
# tarball; fresh-install users download the ISO from the release page.
|
# to pull from here.
|
||||||
#
|
#
|
||||||
# Runs on the self-hosted runner because iso/build.sh needs privileged
|
# Version tags only (pattern matches CalVer like 26.0-alpha, 26.1, 27.0-beta).
|
||||||
# docker access (mkarchiso wants root + loop mounts), and because the
|
# Documentation / random tags are ignored by the [0-9]* prefix.
|
||||||
# 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.
|
|
||||||
on:
|
on:
|
||||||
push:
|
push:
|
||||||
tags: ['[0-9]*']
|
tags: ['[0-9]*']
|
||||||
|
|
||||||
jobs:
|
jobs:
|
||||||
release:
|
release:
|
||||||
runs-on: self-hosted
|
runs-on: ubuntu-latest
|
||||||
timeout-minutes: 45
|
|
||||||
steps:
|
steps:
|
||||||
- uses: actions/checkout@v4
|
- uses: actions/checkout@v4
|
||||||
with:
|
with:
|
||||||
fetch-depth: 0 # changelog section extraction needs history
|
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
|
- name: Build release tarball
|
||||||
run: ./scripts/build-release-tarball.sh "${GITHUB_REF_NAME}"
|
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
|
- name: Publish to Forgejo releases
|
||||||
env:
|
env:
|
||||||
FORGEJO_TOKEN: ${{ secrets.FORGEJO_RELEASE_TOKEN }}
|
FORGEJO_TOKEN: ${{ secrets.FORGEJO_RELEASE_TOKEN }}
|
||||||
run: ./scripts/publish-release.sh "${GITHUB_REF_NAME}"
|
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
|
|
@ -13,4 +13,3 @@ iso/out/
|
||||||
website/public/
|
website/public/
|
||||||
website/resources/
|
website/resources/
|
||||||
website/.hugo_build.lock
|
website/.hugo_build.lock
|
||||||
website/hugo_stats.json
|
|
||||||
|
|
|
||||||
591
CHANGELOG.md
|
|
@ -7,579 +7,6 @@ This project uses calendar versioning: `YY.N-stage` (e.g. `26.0-alpha` = 2026, r
|
||||||
|
|
||||||
## [Unreleased]
|
## [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
|
## [26.4-alpha] - 2026-04-18
|
||||||
|
|
||||||
### Added
|
### Added
|
||||||
|
|
@ -659,23 +86,7 @@ First tagged snapshot. Pre-alpha — the installer does not yet boot, but the de
|
||||||
- **Containers:** Docker + Compose
|
- **Containers:** Docker + Compose
|
||||||
- **License:** AGPL-3.0
|
- **License:** AGPL-3.0
|
||||||
|
|
||||||
[Unreleased]: https://forgejo.sourcegate.online/daniel/furtka/compare/26.21-alpha...HEAD
|
[Unreleased]: https://forgejo.sourcegate.online/daniel/furtka/compare/26.4-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
|
|
||||||
[26.4-alpha]: https://forgejo.sourcegate.online/daniel/furtka/releases/tag/26.4-alpha
|
[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.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
|
[26.2-alpha]: https://forgejo.sourcegate.online/daniel/furtka/releases/tag/26.2-alpha
|
||||||
|
|
|
||||||
18
README.md
|
|
@ -1,5 +1,3 @@
|
||||||
<p><img src="docs/brand/furtka-lockup.svg" alt="Furtka" height="56"></p>
|
|
||||||
|
|
||||||
# Furtka
|
# Furtka
|
||||||
|
|
||||||
**Open-source home server OS — simple enough for everyone.** · [furtka.org](https://furtka.org)
|
**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 |
|
| 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 |
|
| 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 |
|
| 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 |
|
| UI approach | UI-first | Design the simplest possible UI, then build everything to match |
|
||||||
|
|
||||||
## Landscape (Existing Projects)
|
## 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] Installer webapp prototype — device reader + form → JSON (Robert)
|
||||||
- [x] Arch running on Proxmox, custom image builds in progress (Robert)
|
- [x] Arch running on Proxmox, custom image builds in progress (Robert)
|
||||||
- [x] Competitor analysis — see [docs/competitors.md](docs/competitors.md)
|
- [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] 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] 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] 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] **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] **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] **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] **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.
|
- [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] **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] **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] **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.
|
- [ ] 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
|
- [ ] 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
|
- [ ] First containerized service (Nextcloud?) with auto-SSO + auto-subdomain
|
||||||
- [ ] Competitor hands-on testing on Proxmox — validate findings from docs/competitors.md
|
- [ ] Competitor hands-on testing on Proxmox — validate findings from docs/competitors.md
|
||||||
- [ ] UI mockups / drafts (Robert)
|
- [ ] UI mockups / drafts (Robert)
|
||||||
|
|
|
||||||
|
|
@ -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
|
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**:
|
The release workflow needs one secret set at repo **Settings → Secrets → Actions**:
|
||||||
- `FORGEJO_RELEASE_TOKEN` — a PAT with `write:repository` scope.
|
- `FORGEJO_RELEASE_TOKEN` — a PAT with `write:repository` scope.
|
||||||
|
|
|
||||||
145
apps/README.md
|
|
@ -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/`.
|
|
||||||
|
|
@ -1,13 +1,12 @@
|
||||||
{
|
{
|
||||||
"name": "fileshare",
|
"name": "fileshare",
|
||||||
"display_name": "Network Files",
|
"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": "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.",
|
"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"],
|
"volumes": ["files"],
|
||||||
"ports": [445, 139],
|
"ports": [445, 139],
|
||||||
"icon": "icon.svg",
|
"icon": "icon.svg",
|
||||||
"open_url": "smb://{host}/files",
|
|
||||||
"settings": [
|
"settings": [
|
||||||
{
|
{
|
||||||
"name": "SMB_USER",
|
"name": "SMB_USER",
|
||||||
|
|
|
||||||
|
|
@ -23,8 +23,7 @@
|
||||||
"git",
|
"git",
|
||||||
"htop",
|
"htop",
|
||||||
"curl",
|
"curl",
|
||||||
"openssh",
|
"openssh"
|
||||||
"wireguard-tools"
|
|
||||||
],
|
],
|
||||||
|
|
||||||
"profile": {
|
"profile": {
|
||||||
|
|
|
||||||
|
|
@ -1,35 +1,18 @@
|
||||||
# Serves the Furtka landing page + live JSON on :80 (plain HTTP). HTTPS
|
# Serves the Furtka landing page + live JSON on :80 (plain HTTP) and :443
|
||||||
# is **opt-in** — Caddy doesn't serve :443 until the user clicks the
|
# (HTTPS via Caddy's built-in `tls internal` — locally-issued certs signed
|
||||||
# "Enable HTTPS" toggle on /settings, which drops an import snippet into
|
# by a root CA that Caddy generates on first start and stores under
|
||||||
# /etc/caddy/furtka-https.d/. Default install has NO tls site block →
|
# /var/lib/caddy/.local/share/caddy/pki/authorities/local/). Static pages
|
||||||
# Caddy never generates a self-signed CA / leaf cert → no
|
# are read from /opt/furtka/current/ — updates flip the symlink and
|
||||||
# SEC_ERROR_BAD_SIGNATURE when a user visits https://furtka.local before
|
# everything picks up the new content without a Caddy restart (a
|
||||||
# they've trusted anything. That was the 26.14-era regression this file
|
# `systemctl reload caddy` is still triggered post-swap to flush the
|
||||||
# exists to cure: the old Caddyfile always served :443 with a freshly-
|
# file-server's handle cache). /apps and /api are reverse-proxied to the
|
||||||
# generated cert, and a browser that had ever trusted an older Furtka
|
# resource-manager API (furtka serve, bound to 127.0.0.1:7000).
|
||||||
# box's CA would reject the new one with an unbypassable bad-sig error.
|
|
||||||
#
|
#
|
||||||
# /apps, /api, /login, /logout, / (home), /settings are reverse-proxied
|
# Force-HTTPS: /etc/caddy/furtka.d/*.caddyfile gets imported into the :80
|
||||||
# to the resource-manager API (furtka serve, bound to 127.0.0.1:7000).
|
# block. The /api/furtka/https/force endpoint creates or removes
|
||||||
# Static pages are read from /opt/furtka/current/ — updates flip the
|
# redirect.caddyfile there to toggle the HTTP→HTTPS redirect, then reloads
|
||||||
# symlink and everything picks up the new content without a Caddy
|
# Caddy. Glob imports silently no-op on an empty/missing directory, so the
|
||||||
# restart (a `systemctl reload caddy` is still triggered post-swap to
|
# toggle-off state is "no file present" rather than "empty file".
|
||||||
# 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
|
|
||||||
}
|
|
||||||
|
|
||||||
(furtka_routes) {
|
(furtka_routes) {
|
||||||
handle /api/* {
|
handle /api/* {
|
||||||
reverse_proxy localhost:7000
|
reverse_proxy localhost:7000
|
||||||
|
|
@ -37,26 +20,6 @@
|
||||||
handle /apps* {
|
handle /apps* {
|
||||||
reverse_proxy localhost:7000
|
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
|
# Runtime JSON lives under /var/lib/furtka/ so it survives self-updates
|
||||||
# (which only swap /opt/furtka/current).
|
# (which only swap /opt/furtka/current).
|
||||||
handle /status.json {
|
handle /status.json {
|
||||||
|
|
@ -72,10 +35,10 @@
|
||||||
file_server
|
file_server
|
||||||
}
|
}
|
||||||
# Download the local root CA cert Caddy generated for `tls internal`.
|
# Download the local root CA cert Caddy generated for `tls internal`.
|
||||||
# Public because users need to grab it before they've trusted it.
|
# Available on both :80 and :443 so users can grab it before they've
|
||||||
# The private key next to it stays 0600 / caddy-owned.
|
# trusted it. The private key next to it stays 0600 / caddy-owned.
|
||||||
handle /rootCA.crt {
|
handle /rootCA.crt {
|
||||||
root * /var/lib/caddy/pki/authorities/local
|
root * /var/lib/caddy/.local/share/caddy/pki/authorities/local
|
||||||
rewrite * /root.crt
|
rewrite * /root.crt
|
||||||
file_server
|
file_server
|
||||||
header Content-Type "application/x-x509-ca-cert"
|
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 {
|
:80 {
|
||||||
import /etc/caddy/furtka.d/*.caddyfile
|
import /etc/caddy/furtka.d/*.caddyfile
|
||||||
import furtka_routes
|
import furtka_routes
|
||||||
}
|
}
|
||||||
|
|
||||||
|
:443 {
|
||||||
|
tls internal
|
||||||
|
import furtka_routes
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,47 +1,22 @@
|
||||||
#!/bin/bash
|
#!/bin/bash
|
||||||
# Regenerates /etc/issue on the installed system so the console tells the
|
# 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:
|
# 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
|
set -e
|
||||||
# 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
|
|
||||||
|
|
||||||
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
|
||||||
echo " Furtka is ready."
|
echo " Furtka is ready."
|
||||||
echo
|
echo
|
||||||
echo " Open in a browser on another device on your network:"
|
echo " Open in a browser on another device on your network:"
|
||||||
echo
|
echo
|
||||||
echo " http://${hostname}.local (easy — try this first)"
|
echo " http://${hostname}.local (easy — try this first)"
|
||||||
if ((${#ips[@]})); then
|
if [ -n "$ip" ]; then
|
||||||
for ip in "${ips[@]}"; do
|
|
||||||
echo " http://${ip} (fallback if the first doesn't work)"
|
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
|
fi
|
||||||
echo
|
echo
|
||||||
)
|
} > /etc/issue
|
||||||
|
|
||||||
if [ "$new" != "$(cat "$issue" 2>/dev/null)" ]; then
|
agetty --reload 2>/dev/null || true
|
||||||
printf '%s\n' "$new" > "$issue"
|
|
||||||
agetty --reload 2>/dev/null || true
|
|
||||||
fi
|
|
||||||
|
|
|
||||||
|
|
@ -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
|
|
||||||
|
|
@ -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
|
|
||||||
|
|
@ -1,12 +1,12 @@
|
||||||
[Unit]
|
[Unit]
|
||||||
Description=Furtka console welcome banner
|
Description=Furtka console welcome banner
|
||||||
# No network-online dependency on purpose: paint the banner (with a
|
After=network-online.target
|
||||||
# "no IP yet" fallback) as early as possible; furtka-welcome.timer re-runs
|
Wants=network-online.target
|
||||||
# it every few seconds so the address appears as soon as DHCP delivers.
|
|
||||||
|
|
||||||
[Service]
|
[Service]
|
||||||
Type=oneshot
|
Type=oneshot
|
||||||
ExecStart=/opt/furtka/current/assets/bin/furtka-welcome
|
ExecStart=/opt/furtka/current/assets/bin/furtka-welcome
|
||||||
|
RemainAfterExit=yes
|
||||||
|
|
||||||
[Install]
|
[Install]
|
||||||
WantedBy=multi-user.target
|
WantedBy=multi-user.target
|
||||||
|
|
|
||||||
|
|
@ -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
|
|
||||||
|
|
@ -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 |
|
|
@ -2,7 +2,6 @@
|
||||||
<html lang="en">
|
<html lang="en">
|
||||||
<head>
|
<head>
|
||||||
<meta charset="utf-8">
|
<meta charset="utf-8">
|
||||||
<link rel="icon" type="image/svg+xml" href="/favicon.svg">
|
|
||||||
<title>Install local HTTPS · Furtka</title>
|
<title>Install local HTTPS · Furtka</title>
|
||||||
<meta name="viewport" content="width=device-width,initial-scale=1">
|
<meta name="viewport" content="width=device-width,initial-scale=1">
|
||||||
<link rel="stylesheet" href="/style.css">
|
<link rel="stylesheet" href="/style.css">
|
||||||
|
|
|
||||||
|
|
@ -2,7 +2,6 @@
|
||||||
<html lang="en">
|
<html lang="en">
|
||||||
<head>
|
<head>
|
||||||
<meta charset="utf-8">
|
<meta charset="utf-8">
|
||||||
<link rel="icon" type="image/svg+xml" href="/favicon.svg">
|
|
||||||
<title>Furtka</title>
|
<title>Furtka</title>
|
||||||
<meta name="viewport" content="width=device-width,initial-scale=1">
|
<meta name="viewport" content="width=device-width,initial-scale=1">
|
||||||
<link rel="stylesheet" href="/style.css">
|
<link rel="stylesheet" href="/style.css">
|
||||||
|
|
@ -15,7 +14,6 @@
|
||||||
<a href="/" aria-current="page">Home</a>
|
<a href="/" aria-current="page">Home</a>
|
||||||
<a href="/apps">Apps</a>
|
<a href="/apps">Apps</a>
|
||||||
<a href="/settings/">Settings</a>
|
<a href="/settings/">Settings</a>
|
||||||
<a href="#" id="logout-link" onclick="return doLogout(event)">Logout</a>
|
|
||||||
</div>
|
</div>
|
||||||
</nav>
|
</nav>
|
||||||
<header>
|
<header>
|
||||||
|
|
@ -69,26 +67,6 @@
|
||||||
</main>
|
</main>
|
||||||
|
|
||||||
<script>
|
<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
|
// Hostname + install metadata — written once at install time to
|
||||||
// /var/lib/furtka/furtka.json (see _furtka_json_cmd in the installer).
|
// /var/lib/furtka/furtka.json (see _furtka_json_cmd in the installer).
|
||||||
// Separate from status.json because these facts don't change between
|
// Separate from status.json because these facts don't change between
|
||||||
|
|
@ -114,17 +92,13 @@
|
||||||
}
|
}
|
||||||
|
|
||||||
function primaryAction(app) {
|
function primaryAction(app) {
|
||||||
// open_url is a manifest-declared template with a `{host}`
|
// Only fileshare has a direct "open" link today. Future apps with
|
||||||
// placeholder — substituted against the current browser's
|
// HTTP endpoints would surface a URL here; everything else falls
|
||||||
// hostname so smb://host/files and http://host:3001/ both
|
// back to the /apps manage page.
|
||||||
// follow however the user reached Furtka (furtka.local, raw
|
if (app.name === 'fileshare' && HOSTNAME) {
|
||||||
// IP, a future reverse-proxy hostname). Apps without a
|
return { href: `smb://${HOSTNAME}.local/files`, label: 'Open files' };
|
||||||
// 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 };
|
|
||||||
}
|
}
|
||||||
return { href: '/apps', label: 'Manage →', external: false };
|
return { href: '/apps', label: 'Manage →' };
|
||||||
}
|
}
|
||||||
|
|
||||||
async function renderApps() {
|
async function renderApps() {
|
||||||
|
|
@ -141,9 +115,8 @@
|
||||||
}
|
}
|
||||||
target.innerHTML = apps.map(a => {
|
target.innerHTML = apps.map(a => {
|
||||||
const icon = a.icon_svg || FALLBACK_ICON;
|
const icon = a.icon_svg || FALLBACK_ICON;
|
||||||
const { href, label, external } = primaryAction(a);
|
const { href, label } = primaryAction(a);
|
||||||
const tgt = external ? ' target="_blank" rel="noopener"' : '';
|
return `<a class="app-tile" href="${esc(href)}">
|
||||||
return `<a class="app-tile" href="${esc(href)}"${tgt}>
|
|
||||||
<div class="icon">${icon}</div>
|
<div class="icon">${icon}</div>
|
||||||
<span class="name">${esc(a.display_name || a.name)}</span>
|
<span class="name">${esc(a.display_name || a.name)}</span>
|
||||||
<span class="cta">${esc(label)}</span>
|
<span class="cta">${esc(label)}</span>
|
||||||
|
|
|
||||||
|
|
@ -2,7 +2,6 @@
|
||||||
<html lang="en">
|
<html lang="en">
|
||||||
<head>
|
<head>
|
||||||
<meta charset="utf-8">
|
<meta charset="utf-8">
|
||||||
<link rel="icon" type="image/svg+xml" href="/favicon.svg">
|
|
||||||
<title>Settings · Furtka</title>
|
<title>Settings · Furtka</title>
|
||||||
<meta name="viewport" content="width=device-width,initial-scale=1">
|
<meta name="viewport" content="width=device-width,initial-scale=1">
|
||||||
<link rel="stylesheet" href="/style.css">
|
<link rel="stylesheet" href="/style.css">
|
||||||
|
|
@ -15,7 +14,6 @@
|
||||||
<a href="/">Home</a>
|
<a href="/">Home</a>
|
||||||
<a href="/apps">Apps</a>
|
<a href="/apps">Apps</a>
|
||||||
<a href="/settings/" aria-current="page">Settings</a>
|
<a href="/settings/" aria-current="page">Settings</a>
|
||||||
<a href="#" id="logout-link" onclick="return doLogout(event)">Logout</a>
|
|
||||||
</div>
|
</div>
|
||||||
</nav>
|
</nav>
|
||||||
|
|
||||||
|
|
@ -81,45 +79,6 @@
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</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>
|
<section>
|
||||||
<h2>Appearance</h2>
|
<h2>Appearance</h2>
|
||||||
<div class="card">
|
<div class="card">
|
||||||
|
|
@ -130,28 +89,16 @@
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</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>
|
<section>
|
||||||
<h2>Coming next</h2>
|
<h2>Coming next</h2>
|
||||||
<div class="coming">
|
<div class="coming">
|
||||||
<p class="hint">Controls we're building — follow progress on <a href="https://furtka.org">furtka.org</a>.</p>
|
<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">Change hostname</a>
|
||||||
<a href="https://furtka.org/#planned">Backup</a>
|
<a href="https://furtka.org/#planned">Backup</a>
|
||||||
<a href="https://furtka.org/#planned">User accounts</a>
|
<a href="https://furtka.org/#planned">User accounts</a>
|
||||||
|
<a href="https://furtka.org/#planned">Remote access</a>
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
|
|
@ -161,24 +108,6 @@
|
||||||
</main>
|
</main>
|
||||||
|
|
||||||
<script>
|
<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() {
|
async function refresh() {
|
||||||
try {
|
try {
|
||||||
const r = await fetch('/status.json', { cache: 'no-store' });
|
const r = await fetch('/status.json', { cache: 'no-store' });
|
||||||
|
|
@ -210,7 +139,6 @@
|
||||||
done: 'Update complete — reloading…',
|
done: 'Update complete — reloading…',
|
||||||
rolled_back: 'Update failed, rolled back to the previous version',
|
rolled_back: 'Update failed, rolled back to the previous version',
|
||||||
rolled_back_manual: 'Rolled back manually',
|
rolled_back_manual: 'Rolled back manually',
|
||||||
error: 'Update failed — nothing was changed',
|
|
||||||
};
|
};
|
||||||
|
|
||||||
let pollHandle = null;
|
let pollHandle = null;
|
||||||
|
|
@ -388,140 +316,18 @@
|
||||||
await refreshHttpsStatus();
|
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() {
|
async function pollUpdateState() {
|
||||||
try {
|
try {
|
||||||
const r = await fetch('/update-state.json', { cache: 'no-store' });
|
const r = await fetch('/update-state.json', { cache: 'no-store' });
|
||||||
if (!r.ok) return;
|
if (!r.ok) return;
|
||||||
const s = await r.json();
|
const s = await r.json();
|
||||||
const label = STAGE_LABELS[s.stage] || `Stage: ${s.stage}`;
|
const label = STAGE_LABELS[s.stage] || `Stage: ${s.stage}`;
|
||||||
const failed = s.stage === 'rolled_back' || s.stage === 'error';
|
setStatus(label, s.stage === 'rolled_back');
|
||||||
setStatus(label, failed);
|
|
||||||
if (s.stage === 'done') {
|
if (s.stage === 'done') {
|
||||||
clearInterval(pollHandle);
|
clearInterval(pollHandle);
|
||||||
clearTimeout(fallbackReloadHandle);
|
clearTimeout(fallbackReloadHandle);
|
||||||
setTimeout(() => location.reload(), 5000);
|
setTimeout(() => location.reload(), 5000);
|
||||||
} else if (failed) {
|
} else if (s.stage === 'rolled_back') {
|
||||||
clearInterval(pollHandle);
|
clearInterval(pollHandle);
|
||||||
clearTimeout(fallbackReloadHandle);
|
clearTimeout(fallbackReloadHandle);
|
||||||
if (s.reason) {
|
if (s.reason) {
|
||||||
|
|
@ -534,85 +340,6 @@
|
||||||
/* keep polling; restart blip expected */
|
/* 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>
|
</script>
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
|
|
|
||||||
|
|
@ -21,13 +21,7 @@
|
||||||
--r-pill: 999px;
|
--r-pill: 999px;
|
||||||
|
|
||||||
--shadow-card: 0 1px 2px rgba(0, 0, 0, 0.3);
|
--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);
|
--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) {
|
@media (prefers-color-scheme: light) {
|
||||||
|
|
@ -44,11 +38,6 @@
|
||||||
--warn-fg: #5a2a10;
|
--warn-fg: #5a2a10;
|
||||||
--danger: #c03a28;
|
--danger: #c03a28;
|
||||||
--shadow-card: 0 1px 3px rgba(0, 0, 0, 0.08);
|
--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 {
|
body {
|
||||||
margin: 0;
|
margin: 0;
|
||||||
font-family: system-ui, -apple-system, "Segoe UI", Roboto, sans-serif;
|
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
|
background: var(--bg);
|
||||||
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;
|
|
||||||
color: var(--fg);
|
color: var(--fg);
|
||||||
line-height: 1.5;
|
line-height: 1.5;
|
||||||
-webkit-font-smoothing: antialiased;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Shared page container — both landing and /apps wrap content in
|
/* Shared page container — both landing and /apps wrap content in
|
||||||
|
|
@ -77,16 +59,9 @@ body {
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
justify-content: space-between;
|
justify-content: space-between;
|
||||||
padding: 0.85rem 0;
|
padding-bottom: 1.25rem;
|
||||||
border-bottom: 1px solid var(--border);
|
border-bottom: 1px solid var(--border);
|
||||||
margin-bottom: 2rem;
|
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 {
|
.brand {
|
||||||
font-weight: 700;
|
font-weight: 700;
|
||||||
|
|
@ -98,17 +73,13 @@ body {
|
||||||
align-items: center;
|
align-items: center;
|
||||||
gap: 0.55rem;
|
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 {
|
.brand::before {
|
||||||
content: "";
|
content: "";
|
||||||
width: 1.35rem;
|
width: 0.7rem;
|
||||||
height: 1.35rem;
|
height: 0.7rem;
|
||||||
/* Brand red, not the UI's green accent — the logo keeps its colour.
|
background: var(--accent);
|
||||||
This stylesheet is dark-first; the light theme gets the deeper red. */
|
border-radius: 2px;
|
||||||
background: #ff6b56;
|
transform: rotate(45deg);
|
||||||
-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;
|
|
||||||
}
|
}
|
||||||
.nav-links { display: flex; gap: 0.25rem; }
|
.nav-links { display: flex; gap: 0.25rem; }
|
||||||
.nav-links a {
|
.nav-links a {
|
||||||
|
|
@ -116,52 +87,30 @@ body {
|
||||||
text-decoration: none;
|
text-decoration: none;
|
||||||
font-size: 0.9rem;
|
font-size: 0.9rem;
|
||||||
padding: 0.35rem 0.75rem;
|
padding: 0.35rem 0.75rem;
|
||||||
border-radius: var(--r-pill);
|
border-radius: var(--r-sm);
|
||||||
transition: color 120ms, background 120ms;
|
|
||||||
}
|
}
|
||||||
.nav-links a:hover { color: var(--fg); background: var(--card); }
|
.nav-links a:hover { color: var(--fg); }
|
||||||
.nav-links a[aria-current="page"] {
|
.nav-links a[aria-current="page"] {
|
||||||
color: var(--fg);
|
color: var(--fg);
|
||||||
background: var(--accent-soft);
|
background: var(--accent-soft);
|
||||||
}
|
}
|
||||||
|
|
||||||
/* -- Landing page ---------------------------------------------- */
|
/* -- Landing page ---------------------------------------------- */
|
||||||
header h1 {
|
header h1 { margin: 0 0 0.5rem; font-size: 2.5rem; }
|
||||||
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;
|
|
||||||
}
|
|
||||||
.lead { font-size: 1.25rem; color: var(--muted); margin: 0 0 0.25rem; }
|
.lead { font-size: 1.25rem; color: var(--muted); margin: 0 0 0.25rem; }
|
||||||
.host { color: var(--muted); margin: 0 0 3rem; }
|
.host { color: var(--muted); margin: 0 0 3rem; }
|
||||||
.host code {
|
.host code {
|
||||||
background: var(--accent-soft);
|
background: var(--card);
|
||||||
padding: 0.2rem 0.65rem;
|
padding: 0.15rem 0.5rem;
|
||||||
border-radius: var(--r-pill);
|
border-radius: var(--r-sm);
|
||||||
color: var(--accent);
|
color: var(--accent);
|
||||||
font-size: 0.9em;
|
|
||||||
}
|
}
|
||||||
section h2 {
|
section h2 {
|
||||||
font-size: 0.85rem;
|
font-size: 1.1rem;
|
||||||
text-transform: uppercase;
|
text-transform: uppercase;
|
||||||
letter-spacing: 0.12em;
|
letter-spacing: 0.1em;
|
||||||
color: var(--muted);
|
color: var(--muted);
|
||||||
margin: 2.5rem 0 1rem;
|
margin: 2rem 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);
|
|
||||||
}
|
}
|
||||||
.tiles {
|
.tiles {
|
||||||
display: grid;
|
display: grid;
|
||||||
|
|
@ -171,21 +120,9 @@ section h2::before {
|
||||||
.tile {
|
.tile {
|
||||||
background: var(--card);
|
background: var(--card);
|
||||||
padding: 1.25rem;
|
padding: 1.25rem;
|
||||||
border: 1px solid var(--border);
|
border-radius: var(--r-md);
|
||||||
border-radius: var(--r-lg);
|
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
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 {
|
.tile .label {
|
||||||
font-size: 0.8rem;
|
font-size: 0.8rem;
|
||||||
|
|
@ -193,12 +130,7 @@ section h2::before {
|
||||||
text-transform: uppercase;
|
text-transform: uppercase;
|
||||||
letter-spacing: 0.08em;
|
letter-spacing: 0.08em;
|
||||||
}
|
}
|
||||||
.tile .value {
|
.tile .value { font-size: 1.25rem; margin-top: 0.5rem; }
|
||||||
font-size: 1.35rem;
|
|
||||||
font-weight: 600;
|
|
||||||
margin-top: 0.5rem;
|
|
||||||
font-variant-numeric: tabular-nums;
|
|
||||||
}
|
|
||||||
.updated { font-size: 0.85rem; color: var(--muted); margin-top: 1rem; }
|
.updated { font-size: 0.85rem; color: var(--muted); margin-top: 1rem; }
|
||||||
.soon {
|
.soon {
|
||||||
background: var(--card);
|
background: var(--card);
|
||||||
|
|
@ -236,9 +168,7 @@ h2 {
|
||||||
.app {
|
.app {
|
||||||
background: var(--card);
|
background: var(--card);
|
||||||
padding: 1rem;
|
padding: 1rem;
|
||||||
border: 1px solid var(--border);
|
border-radius: var(--r-md);
|
||||||
border-radius: var(--r-lg);
|
|
||||||
transition: border-color 120ms;
|
|
||||||
margin: 0.5rem 0;
|
margin: 0.5rem 0;
|
||||||
display: flex;
|
display: flex;
|
||||||
justify-content: space-between;
|
justify-content: space-between;
|
||||||
|
|
@ -268,7 +198,7 @@ h2 {
|
||||||
flex-wrap: wrap;
|
flex-wrap: wrap;
|
||||||
justify-content: flex-end;
|
justify-content: flex-end;
|
||||||
}
|
}
|
||||||
button, .btn {
|
button {
|
||||||
background: var(--accent);
|
background: var(--accent);
|
||||||
border: none;
|
border: none;
|
||||||
color: var(--bg);
|
color: var(--bg);
|
||||||
|
|
@ -279,95 +209,16 @@ button, .btn {
|
||||||
white-space: nowrap;
|
white-space: nowrap;
|
||||||
font-size: 0.9rem;
|
font-size: 0.9rem;
|
||||||
font-family: inherit;
|
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.secondary {
|
||||||
button:active:not(:disabled), .btn:active { transform: translateY(1px); }
|
|
||||||
button.secondary, .btn.secondary {
|
|
||||||
background: var(--card);
|
background: var(--card);
|
||||||
color: var(--fg);
|
color: var(--fg);
|
||||||
border: 1px solid var(--border);
|
border: 1px solid var(--border);
|
||||||
}
|
}
|
||||||
button.danger { background: var(--danger); color: #fff; }
|
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: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; }
|
.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 {
|
pre {
|
||||||
background: var(--card);
|
background: var(--card);
|
||||||
padding: 1rem;
|
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 input:focus { outline: 2px solid var(--accent); outline-offset: -1px; }
|
||||||
.field .req { color: var(--danger); margin-left: 0.25rem; }
|
.field .req { color: var(--danger); margin-left: 0.25rem; }
|
||||||
.modal .error,
|
.modal .error {
|
||||||
.login-wrap .error {
|
|
||||||
background: var(--warn);
|
background: var(--warn);
|
||||||
color: var(--warn-fg);
|
color: var(--warn-fg);
|
||||||
padding: 0.5rem 0.75rem;
|
padding: 0.5rem 0.75rem;
|
||||||
|
|
@ -446,25 +296,7 @@ details.log-details[open] > summary { color: var(--fg); }
|
||||||
font-size: 0.9rem;
|
font-size: 0.9rem;
|
||||||
display: none;
|
display: none;
|
||||||
}
|
}
|
||||||
.modal .error.show,
|
.modal .error.show { display: block; }
|
||||||
.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-actions {
|
.modal-actions {
|
||||||
display: flex;
|
display: flex;
|
||||||
justify-content: flex-end;
|
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
|
/* Row of buttons beneath a card — used by the Furtka updates card on
|
||||||
/settings. Left-aligned, wraps on narrow screens. */
|
/settings. Left-aligned, wraps on narrow screens. */
|
||||||
.update-actions,
|
.update-actions {
|
||||||
.power-actions {
|
|
||||||
display: flex;
|
display: flex;
|
||||||
gap: 0.5rem;
|
gap: 0.5rem;
|
||||||
flex-wrap: wrap;
|
flex-wrap: wrap;
|
||||||
|
|
@ -520,8 +351,7 @@ details.log-details[open] > summary { color: var(--fg); }
|
||||||
.card {
|
.card {
|
||||||
background: var(--card);
|
background: var(--card);
|
||||||
padding: 1.25rem;
|
padding: 1.25rem;
|
||||||
border: 1px solid var(--border);
|
border-radius: var(--r-md);
|
||||||
border-radius: var(--r-lg);
|
|
||||||
box-shadow: var(--shadow-card);
|
box-shadow: var(--shadow-card);
|
||||||
}
|
}
|
||||||
.card + .card { margin-top: 1rem; }
|
.card + .card { margin-top: 1rem; }
|
||||||
|
|
@ -535,18 +365,7 @@ details.log-details[open] > summary { color: var(--fg); }
|
||||||
font-size: 0.95rem;
|
font-size: 0.95rem;
|
||||||
}
|
}
|
||||||
.kv dt { color: var(--muted); }
|
.kv dt { color: var(--muted); }
|
||||||
.kv dd {
|
.kv dd { margin: 0; color: var(--fg); font-family: ui-monospace, SFMono-Regular, Menlo, monospace; }
|
||||||
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;
|
|
||||||
}
|
|
||||||
|
|
||||||
.coming {
|
.coming {
|
||||||
display: flex;
|
display: flex;
|
||||||
|
|
@ -576,9 +395,9 @@ details.log-details[open] > summary { color: var(--fg); }
|
||||||
gap: 0.75rem;
|
gap: 0.75rem;
|
||||||
}
|
}
|
||||||
.app-tile {
|
.app-tile {
|
||||||
background: var(--card);
|
background: var(--bg);
|
||||||
border: 1px solid var(--border);
|
border: 1px solid var(--border);
|
||||||
border-radius: var(--r-lg);
|
border-radius: var(--r-md);
|
||||||
padding: 1rem;
|
padding: 1rem;
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
|
|
@ -586,20 +405,12 @@ details.log-details[open] > summary { color: var(--fg); }
|
||||||
align-items: flex-start;
|
align-items: flex-start;
|
||||||
text-decoration: none;
|
text-decoration: none;
|
||||||
color: var(--fg);
|
color: var(--fg);
|
||||||
transition: border-color 120ms, background 120ms, transform 120ms, box-shadow 120ms;
|
transition: border-color 120ms, background 120ms;
|
||||||
}
|
|
||||||
.app-tile:hover {
|
|
||||||
border-color: var(--accent);
|
|
||||||
background: var(--card-hover);
|
|
||||||
transform: translateY(-2px);
|
|
||||||
box-shadow: var(--shadow-lift);
|
|
||||||
}
|
}
|
||||||
|
.app-tile:hover { border-color: var(--accent); background: var(--card-hover); }
|
||||||
.app-tile .icon {
|
.app-tile .icon {
|
||||||
width: 48px;
|
width: 40px;
|
||||||
height: 48px;
|
height: 40px;
|
||||||
padding: 6px;
|
|
||||||
border-radius: var(--r-md);
|
|
||||||
background: var(--accent-soft);
|
|
||||||
color: var(--accent);
|
color: var(--accent);
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
|
|
@ -625,15 +436,3 @@ details.log-details[open] > summary { color: var(--fg); }
|
||||||
color: var(--accent);
|
color: var(--accent);
|
||||||
}
|
}
|
||||||
.app-icon svg { width: 36px; height: 36px; }
|
.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; }
|
|
||||||
}
|
|
||||||
|
|
|
||||||
|
|
@ -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.
|
|
||||||
|
|
@ -1,10 +0,0 @@
|
||||||
▄▄▄▄▄▄▄▄
|
|
||||||
▄██████████▄
|
|
||||||
█████▀▀▀▀▀▀███
|
|
||||||
████▀ ████
|
|
||||||
████ █████████
|
|
||||||
████ ████
|
|
||||||
████ ▄▄▄▄▄████
|
|
||||||
████ █████████
|
|
||||||
████ █████████
|
|
||||||
▀▀▀▀ ▀▀▀▀▀▀▀▀▀
|
|
||||||
|
|
@ -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 |
|
|
@ -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 |
|
|
@ -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 |
|
|
@ -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 |
|
|
@ -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).
|
|
||||||
|
|
@ -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)
|
|
||||||
1342
furtka/api.py
272
furtka/auth.py
|
|
@ -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()
|
|
||||||
|
|
@ -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
|
|
||||||
226
furtka/cli.py
|
|
@ -1,9 +1,8 @@
|
||||||
import argparse
|
import argparse
|
||||||
import json
|
import json
|
||||||
import sys
|
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.paths import apps_dir
|
||||||
from furtka.scanner import scan
|
from furtka.scanner import scan
|
||||||
|
|
||||||
|
|
@ -22,34 +21,9 @@ def _cmd_app_list(args: argparse.Namespace) -> int:
|
||||||
"display_name": r.manifest.display_name,
|
"display_name": r.manifest.display_name,
|
||||||
"version": r.manifest.version,
|
"version": r.manifest.version,
|
||||||
"description": r.manifest.description,
|
"description": r.manifest.description,
|
||||||
"description_long": r.manifest.description_long,
|
|
||||||
"volumes": list(r.manifest.volumes),
|
"volumes": list(r.manifest.volumes),
|
||||||
"ports": list(r.manifest.ports),
|
"ports": list(r.manifest.ports),
|
||||||
"icon": r.manifest.icon,
|
"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
|
if r.manifest
|
||||||
else None,
|
else None,
|
||||||
|
|
@ -71,12 +45,6 @@ def _cmd_app_list(args: argparse.Namespace) -> int:
|
||||||
|
|
||||||
|
|
||||||
def _cmd_app_install(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:
|
try:
|
||||||
src = installer.resolve_source(args.source)
|
src = installer.resolve_source(args.source)
|
||||||
target = installer.install_from(src)
|
target = installer.install_from(src)
|
||||||
|
|
@ -89,78 +57,12 @@ def _cmd_app_install(args: argparse.Namespace) -> int:
|
||||||
print(f" {a.describe()}")
|
print(f" {a.describe()}")
|
||||||
return 1 if reconciler.has_errors(actions) else 0
|
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:
|
def _cmd_app_remove(args: argparse.Namespace) -> int:
|
||||||
target = apps_dir() / args.name
|
target = apps_dir() / args.name
|
||||||
if not target.exists():
|
if not target.exists():
|
||||||
print(f"error: {args.name!r} is not installed", file=sys.stderr)
|
print(f"error: {args.name!r} is not installed", file=sys.stderr)
|
||||||
return 1
|
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:
|
try:
|
||||||
dockerops.compose_down(target, args.name)
|
dockerops.compose_down(target, args.name)
|
||||||
except dockerops.DockerError as e:
|
except dockerops.DockerError as e:
|
||||||
|
|
@ -175,25 +77,6 @@ def _cmd_app_remove(args: argparse.Namespace) -> int:
|
||||||
return 0
|
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:
|
def _cmd_serve(args: argparse.Namespace) -> int:
|
||||||
# Imported lazily so `furtka` startup stays cheap when the user only runs
|
# Imported lazily so `furtka` startup stays cheap when the user only runs
|
||||||
# `app list` or `reconcile` (the common case during tests + scripts).
|
# `app list` or `reconcile` (the common case during tests + scripts).
|
||||||
|
|
@ -266,60 +149,6 @@ def _cmd_rollback(args: argparse.Namespace) -> int:
|
||||||
return 0
|
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:
|
def build_parser() -> argparse.ArgumentParser:
|
||||||
p = argparse.ArgumentParser(prog="furtka", description="Furtka resource manager")
|
p = argparse.ArgumentParser(prog="furtka", description="Furtka resource manager")
|
||||||
sub = p.add_subparsers(dest="command", required=True)
|
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)
|
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 = 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.add_argument("name", help="App name (folder name under /var/lib/furtka/apps/)")
|
||||||
app_remove.set_defaults(func=_cmd_app_remove)
|
app_remove.set_defaults(func=_cmd_app_remove)
|
||||||
|
|
@ -392,50 +212,6 @@ def build_parser() -> argparse.ArgumentParser:
|
||||||
)
|
)
|
||||||
rollback.set_defaults(func=_cmd_rollback)
|
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
|
return p
|
||||||
|
|
||||||
|
|
||||||
|
|
|
||||||
237
furtka/deps.py
|
|
@ -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()))
|
|
||||||
|
|
@ -60,88 +60,6 @@ def compose_pull(app_dir: Path, project: str) -> None:
|
||||||
_run([*_compose_args(app_dir, project), "pull"], cwd=app_dir)
|
_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]:
|
def compose_image_tags(app_dir: Path, project: str) -> dict[str, str]:
|
||||||
"""Return {service_name: image_tag} as declared in the compose file.
|
"""Return {service_name: image_tag} as declared in the compose 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")
|
|
||||||
128
furtka/https.py
|
|
@ -1,30 +1,15 @@
|
||||||
"""Local-CA HTTPS helpers for the `tls internal` setup.
|
"""Local-CA HTTPS helpers for the `tls internal` setup.
|
||||||
|
|
||||||
Caddy generates the local root CA lazily on first start and keeps it under
|
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
|
$XDG_DATA_HOME/caddy/pki/authorities/local/ — on the target that's
|
||||||
sets `XDG_DATA_HOME=/var/lib`, so on the target that resolves to
|
/var/lib/caddy/.local/share/caddy/pki/authorities/local/ (the caddy system
|
||||||
/var/lib/caddy/pki/authorities/local/. The private key stays 0600 /
|
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.
|
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`
|
This module exposes two operations:
|
||||||
site block, so `tls internal` never triggers cert issuance. The
|
- status(): current CA fingerprint + whether force-HTTPS is on
|
||||||
/settings toggle drops a snippet file into /etc/caddy/furtka-https.d/
|
- set_force_https(enabled): write/remove the Caddy import snippet that
|
||||||
that adds the hostname+tls-internal block (plus the redirect snippet
|
redirects HTTP to HTTPS, reload Caddy, roll back on failure.
|
||||||
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.
|
|
||||||
"""
|
"""
|
||||||
|
|
||||||
import base64
|
import base64
|
||||||
|
|
@ -33,17 +18,10 @@ import re
|
||||||
import subprocess
|
import subprocess
|
||||||
from pathlib import Path
|
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")
|
SNIPPET_DIR = Path("/etc/caddy/furtka.d")
|
||||||
REDIRECT_SNIPPET = SNIPPET_DIR / "redirect.caddyfile"
|
REDIRECT_SNIPPET = SNIPPET_DIR / "redirect.caddyfile"
|
||||||
# Pre-26.21 redirect: permanent (browsers cache a 301 indefinitely, so
|
REDIRECT_CONTENT = "redir https://{host}{uri} permanent\n"
|
||||||
# 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")
|
|
||||||
|
|
||||||
_PEM_RE = re.compile(
|
_PEM_RE = re.compile(
|
||||||
r"-----BEGIN CERTIFICATE-----\s*(.+?)\s*-----END CERTIFICATE-----",
|
r"-----BEGIN CERTIFICATE-----\s*(.+?)\s*-----END CERTIFICATE-----",
|
||||||
|
|
@ -55,43 +33,6 @@ class HttpsError(Exception):
|
||||||
"""Recoverable failure from set_force_https — the caller should 5xx."""
|
"""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:
|
def _ca_fingerprint(ca_path: Path) -> str | None:
|
||||||
try:
|
try:
|
||||||
pem = ca_path.read_text()
|
pem = ca_path.read_text()
|
||||||
|
|
@ -113,20 +54,13 @@ def _format_fingerprint(hex_upper: str) -> str:
|
||||||
|
|
||||||
def status(
|
def status(
|
||||||
ca_path: Path = CA_CERT_PATH,
|
ca_path: Path = CA_CERT_PATH,
|
||||||
https_snippet: Path = HTTPS_SNIPPET,
|
snippet: Path = REDIRECT_SNIPPET,
|
||||||
) -> dict:
|
) -> 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)
|
fp = _ca_fingerprint(ca_path)
|
||||||
return {
|
return {
|
||||||
"ca_available": fp is not None,
|
"ca_available": fp is not None,
|
||||||
"fingerprint_sha256": _format_fingerprint(fp) if fp else 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",
|
"ca_download_url": "/rootCA.crt",
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -144,49 +78,29 @@ def set_force_https(
|
||||||
enabled: bool,
|
enabled: bool,
|
||||||
snippet_dir: Path = SNIPPET_DIR,
|
snippet_dir: Path = SNIPPET_DIR,
|
||||||
snippet: Path = REDIRECT_SNIPPET,
|
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,
|
reload_caddy=_default_reload,
|
||||||
) -> bool:
|
) -> bool:
|
||||||
"""Toggle HTTPS by writing or removing two snippets atomically:
|
"""Toggle the HTTP→HTTPS redirect by writing or removing the snippet
|
||||||
|
Caddy imports. Always reloads Caddy. Rolls the snippet state back on
|
||||||
1. The top-level HTTPS hostname+tls-internal block (enables :443
|
reload failure so a broken config can't leave Caddy wedged on the next
|
||||||
listener + Caddy's `tls internal` cert issuance)
|
restart.
|
||||||
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.
|
|
||||||
"""
|
"""
|
||||||
snippet_dir.mkdir(mode=0o755, parents=True, exist_ok=True)
|
snippet_dir.mkdir(mode=0o755, parents=True, exist_ok=True)
|
||||||
https_snippet_dir.mkdir(mode=0o755, parents=True, exist_ok=True)
|
had = snippet.is_file()
|
||||||
|
previous = snippet.read_text() if had else None
|
||||||
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
|
|
||||||
|
|
||||||
if enabled:
|
if enabled:
|
||||||
hostname = _read_hostname(hostname_file)
|
snippet.write_text(REDIRECT_CONTENT)
|
||||||
snippet.write_text(_redirect_snippet_content(hostname))
|
elif had:
|
||||||
https_snippet.write_text(_https_snippet_content(hostname))
|
|
||||||
else:
|
|
||||||
if had_redirect:
|
|
||||||
snippet.unlink()
|
snippet.unlink()
|
||||||
if had_https:
|
|
||||||
https_snippet.unlink()
|
|
||||||
|
|
||||||
try:
|
try:
|
||||||
reload_caddy()
|
reload_caddy()
|
||||||
except subprocess.CalledProcessError as e:
|
except subprocess.CalledProcessError as e:
|
||||||
_revert(snippet, previous_redirect)
|
_revert(snippet, previous)
|
||||||
_revert(https_snippet, previous_https)
|
|
||||||
msg = (e.stderr or e.stdout or "").strip() or f"exit {e.returncode}"
|
msg = (e.stderr or e.stdout or "").strip() or f"exit {e.returncode}"
|
||||||
raise HttpsError(f"caddy reload failed: {msg}") from e
|
raise HttpsError(f"caddy reload failed: {msg}") from e
|
||||||
except FileNotFoundError as e:
|
except FileNotFoundError as e:
|
||||||
_revert(snippet, previous_redirect)
|
_revert(snippet, previous)
|
||||||
_revert(https_snippet, previous_https)
|
|
||||||
raise HttpsError(f"systemctl not available: {e}") from e
|
raise HttpsError(f"systemctl not available: {e}") from e
|
||||||
return enabled
|
return enabled
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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
|
|
||||||
|
|
@ -1,9 +1,8 @@
|
||||||
import shutil
|
import shutil
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
|
||||||
from furtka import sources
|
from furtka.manifest import ManifestError, load_manifest
|
||||||
from furtka.manifest import APP_NAME_RE, Manifest, ManifestError, load_manifest
|
from furtka.paths import apps_dir, bundled_apps_dir
|
||||||
from furtka.paths import apps_dir
|
|
||||||
|
|
||||||
# Values that an app's .env.example may use as obvious "fill me in" markers.
|
# 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
|
# 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.
|
# default that ends up screenshotted on Hacker News.
|
||||||
PLACEHOLDER_SECRETS: frozenset[str] = frozenset({"changeme"})
|
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):
|
class InstallError(RuntimeError):
|
||||||
pass
|
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]:
|
def _placeholder_keys(env_path: Path) -> list[str]:
|
||||||
if not env_path.exists():
|
if not env_path.exists():
|
||||||
return []
|
return []
|
||||||
return _placeholder_values(_read_env(env_path))
|
bad: list[str] = []
|
||||||
|
for raw in env_path.read_text().splitlines():
|
||||||
|
line = raw.strip()
|
||||||
def _is_denied_system_path(resolved: str) -> bool:
|
if not line or line.startswith("#") or "=" not in line:
|
||||||
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":
|
|
||||||
continue
|
continue
|
||||||
value = values.get(s.name, "")
|
key, _, value = line.partition("=")
|
||||||
if not value:
|
value = value.strip().strip('"').strip("'")
|
||||||
continue
|
if value in PLACEHOLDER_SECRETS:
|
||||||
p = Path(value)
|
bad.append(key.strip())
|
||||||
if not p.is_absolute():
|
return bad
|
||||||
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)}")
|
|
||||||
|
|
||||||
|
|
||||||
def _format_env_value(v: str) -> str:
|
def _format_env_value(v: str) -> str:
|
||||||
# Quote values that contain whitespace, quotes, or shell metacharacters so
|
# Quote values that contain whitespace, quotes, or shell metacharacters so
|
||||||
# docker-compose's env substitution reads them back intact. Simple values
|
# docker-compose's env substitution reads them back intact. Simple values
|
||||||
# stay unquoted to keep the file readable when a user SSHes in.
|
# 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\"'$`\\#"):
|
if v == "" or any(c in v for c in " \t\"'$`\\#"):
|
||||||
escaped = v.replace("\\", "\\\\").replace('"', '\\"')
|
escaped = v.replace("\\", "\\\\").replace('"', '\\"')
|
||||||
return f'"{escaped}"'
|
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.
|
"""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
|
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` —
|
it as a bundled app name and look up under /opt/furtka/apps/<name>.
|
||||||
which checks the synced catalog first and falls back to the bundled seed.
|
|
||||||
"""
|
"""
|
||||||
p = Path(source)
|
p = Path(source)
|
||||||
if p.is_dir():
|
if p.is_dir():
|
||||||
return p
|
return p
|
||||||
if "/" in source or source.startswith("."):
|
if "/" in source or source.startswith("."):
|
||||||
raise InstallError(f"{source!r} is not a directory")
|
raise InstallError(f"{source!r} is not a directory")
|
||||||
resolved = sources.resolve_app_name(source)
|
bundled = bundled_apps_dir() / source
|
||||||
if resolved is None:
|
if bundled.is_dir():
|
||||||
raise InstallError(f"{source!r} not found as a path, catalog app, or bundled app")
|
return bundled
|
||||||
return resolved.path
|
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:
|
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:
|
if key not in declared:
|
||||||
raise InstallError(f"{m.name}: unknown setting {key!r}")
|
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)
|
target.mkdir(parents=True, exist_ok=True)
|
||||||
|
|
||||||
for item in src.iterdir():
|
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():
|
if not item.is_file():
|
||||||
continue
|
continue
|
||||||
# Never overwrite an existing user .env — either settings-driven write
|
# Never overwrite an existing user .env — either settings-driven write
|
||||||
# or previous manual edit has authority.
|
# or previous manual edit has authority.
|
||||||
if item.name == ".env" and (target / ".env").exists():
|
if item.name == ".env" and (target / ".env").exists():
|
||||||
continue
|
continue
|
||||||
shutil.copy2(item, dest)
|
shutil.copy2(item, target / item.name)
|
||||||
|
|
||||||
env = target / ".env"
|
env = target / ".env"
|
||||||
env_example = target / ".env.example"
|
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:
|
for s in m.settings:
|
||||||
if s.required and not merged.get(s.name):
|
if s.required and not merged.get(s.name):
|
||||||
raise InstallError(f"{m.name}: setting {s.name!r} is required")
|
raise InstallError(f"{m.name}: setting {s.name!r} is required")
|
||||||
_validate_settings(m, merged)
|
|
||||||
write_env(env, merged)
|
write_env(env, merged)
|
||||||
elif not env.exists() and env_example.exists():
|
elif not env.exists() and env_example.exists():
|
||||||
# First install with no settings and no .env shipped: bootstrap from
|
# 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}`."
|
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
|
return target
|
||||||
|
|
||||||
|
|
||||||
def parse_env_text(text: str) -> dict[str, str]:
|
def _read_env(env_path: Path) -> dict[str, str]:
|
||||||
"""Parse KEY=VALUE lines from a string into a dict. Unquotes quoted values.
|
"""Parse a simple KEY=VALUE .env 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).
|
|
||||||
"""
|
|
||||||
out: dict[str, str] = {}
|
out: dict[str, str] = {}
|
||||||
for raw in text.splitlines():
|
for raw in env_path.read_text().splitlines():
|
||||||
line = raw.strip()
|
line = raw.strip()
|
||||||
if not line or line.startswith("#") or "=" not in line:
|
if not line or line.startswith("#") or "=" not in line:
|
||||||
continue
|
continue
|
||||||
key, _, value = line.partition("=")
|
key, _, value = line.partition("=")
|
||||||
value = value.strip()
|
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("\\\\", "\\")
|
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
|
out[key.strip()] = value
|
||||||
return out
|
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]:
|
def read_env_values(env_path: Path) -> dict[str, str]:
|
||||||
"""Public wrapper — returns {} if the file doesn't exist."""
|
"""Public wrapper — returns {} if the file doesn't exist."""
|
||||||
if not env_path.exists():
|
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
|
apps only. Returns the target folder; caller is expected to run
|
||||||
reconcile to restart the containers.
|
reconcile to restart the containers.
|
||||||
"""
|
"""
|
||||||
target = app_dir(name)
|
target = apps_dir() / name
|
||||||
manifest_path = target / "manifest.json"
|
manifest_path = target / "manifest.json"
|
||||||
if not manifest_path.exists():
|
if not manifest_path.exists():
|
||||||
raise InstallError(f"{name!r} is not installed")
|
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:
|
for s in m.settings:
|
||||||
if s.required and not merged.get(s.name):
|
if s.required and not merged.get(s.name):
|
||||||
raise InstallError(f"{m.name}: setting {s.name!r} is required")
|
raise InstallError(f"{m.name}: setting {s.name!r} is required")
|
||||||
_validate_settings(m, merged)
|
|
||||||
write_env(env, 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
|
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:
|
def remove(name: str) -> Path:
|
||||||
"""Delete /var/lib/furtka/apps/<name>/. Volumes are NOT touched.
|
"""Delete /var/lib/furtka/apps/<name>/. Volumes are NOT touched.
|
||||||
|
|
||||||
Caller is responsible for stopping the compose project first.
|
Caller is responsible for stopping the compose project first.
|
||||||
"""
|
"""
|
||||||
target = app_dir(name)
|
target = apps_dir() / name
|
||||||
if not target.exists():
|
if not target.exists():
|
||||||
raise InstallError(f"{name!r} is not installed")
|
raise InstallError(f"{name!r} is not installed")
|
||||||
shutil.rmtree(target)
|
shutil.rmtree(target)
|
||||||
|
|
|
||||||
|
|
@ -13,9 +13,8 @@ REQUIRED_FIELDS = (
|
||||||
"icon",
|
"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_]*$")
|
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):
|
class ManifestError(Exception):
|
||||||
|
|
@ -32,33 +31,6 @@ class Setting:
|
||||||
default: str | None
|
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)
|
@dataclass(frozen=True)
|
||||||
class Manifest:
|
class Manifest:
|
||||||
name: str
|
name: str
|
||||||
|
|
@ -70,14 +42,6 @@ class Manifest:
|
||||||
icon: str
|
icon: str
|
||||||
description_long: str = ""
|
description_long: str = ""
|
||||||
settings: tuple[Setting, ...] = field(default_factory=tuple)
|
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:
|
def volume_name(self, short: str) -> str:
|
||||||
# Namespace volume names so two apps can each declare e.g. "data"
|
# 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)
|
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:
|
def load_manifest(path: Path, expected_name: str | None = None) -> Manifest:
|
||||||
"""Parse and validate a manifest.json.
|
"""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)}")
|
raise ManifestError(f"{path}: missing required fields: {', '.join(missing)}")
|
||||||
|
|
||||||
name = raw["name"]
|
name = raw["name"]
|
||||||
if not isinstance(name, str) or not APP_NAME_RE.fullmatch(name):
|
if not isinstance(name, str) or not name:
|
||||||
raise ManifestError(
|
raise ManifestError(f"{path}: name must be a non-empty string")
|
||||||
f"{path}: name must be lowercase letters, digits, '-' or '_' (got {name!r})"
|
|
||||||
)
|
|
||||||
if expected_name is not None and name != expected_name:
|
if expected_name is not None and name != expected_name:
|
||||||
raise ManifestError(f"{path}: name {name!r} must equal {expected_name!r}")
|
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")
|
raise ManifestError(f"{path}: ports must be a list of integers")
|
||||||
|
|
||||||
settings = _parse_settings(raw.get("settings"), path)
|
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(
|
return Manifest(
|
||||||
name=name,
|
name=name,
|
||||||
|
|
@ -245,7 +137,4 @@ def load_manifest(path: Path, expected_name: str | None = None) -> Manifest:
|
||||||
icon=str(raw["icon"]),
|
icon=str(raw["icon"]),
|
||||||
description_long=str(raw.get("description_long", "")),
|
description_long=str(raw.get("description_long", "")),
|
||||||
settings=settings,
|
settings=settings,
|
||||||
open_url=open_url_raw,
|
|
||||||
requires=requires,
|
|
||||||
internet=internet,
|
|
||||||
)
|
)
|
||||||
|
|
|
||||||
|
|
@ -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)
|
|
||||||
|
|
@ -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
|
# 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).
|
# flow (symlink swap wouldn't move the bundled-app tree along with the code).
|
||||||
DEFAULT_BUNDLED_APPS_DIR = Path("/opt/furtka/current/apps")
|
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:
|
def apps_dir() -> Path:
|
||||||
|
|
@ -40,27 +15,3 @@ def apps_dir() -> Path:
|
||||||
|
|
||||||
def bundled_apps_dir() -> Path:
|
def bundled_apps_dir() -> Path:
|
||||||
return Path(os.environ.get("FURTKA_BUNDLED_APPS_DIR", DEFAULT_BUNDLED_APPS_DIR))
|
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))
|
|
||||||
|
|
|
||||||
|
|
@ -1,21 +1,13 @@
|
||||||
from dataclasses import dataclass
|
from dataclasses import dataclass
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
|
||||||
from furtka import deps, dockerops, installer
|
from furtka import dockerops
|
||||||
from furtka.manifest import SETTING_NAME_RE, ManifestError, load_manifest
|
|
||||||
from furtka.scanner import scan
|
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)
|
@dataclass(frozen=True)
|
||||||
class Action:
|
class Action:
|
||||||
kind: str # "ensure_volume" | "compose_up" | "hook" | "skip" | "error"
|
kind: str # "ensure_volume" | "compose_up" | "skip"
|
||||||
target: str
|
target: str
|
||||||
detail: str = ""
|
detail: str = ""
|
||||||
|
|
||||||
|
|
@ -28,20 +20,13 @@ class Action:
|
||||||
def reconcile(apps_root: Path, dry_run: bool = False) -> list[Action]:
|
def reconcile(apps_root: Path, dry_run: bool = False) -> list[Action]:
|
||||||
"""Walk the apps tree and bring docker into the desired state.
|
"""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
|
Failures during one app's reconcile (Docker errors, missing binary, …) are
|
||||||
captured as Action(kind='error', …) and do NOT abort the whole sweep — the
|
captured as Action(kind='error', …) and do NOT abort the whole sweep — the
|
||||||
other apps still get reconciled. Callers inspect the returned actions to
|
other apps still get reconciled. Callers inspect the returned actions to
|
||||||
decide overall success.
|
decide overall success.
|
||||||
"""
|
"""
|
||||||
actions: list[Action] = []
|
actions: list[Action] = []
|
||||||
results = scan(apps_root)
|
for result in scan(apps_root):
|
||||||
for result in deps.installed_topo_order(results):
|
|
||||||
if not result.ok:
|
if not result.ok:
|
||||||
actions.append(Action("skip", result.path.name, result.error or ""))
|
actions.append(Action("skip", result.path.name, result.error or ""))
|
||||||
continue
|
continue
|
||||||
|
|
@ -52,31 +37,6 @@ def reconcile(apps_root: Path, dry_run: bool = False) -> list[Action]:
|
||||||
actions.append(Action("ensure_volume", full))
|
actions.append(Action("ensure_volume", full))
|
||||||
if not dry_run:
|
if not dry_run:
|
||||||
dockerops.ensure_volume(full)
|
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))
|
actions.append(Action("compose_up", m.name))
|
||||||
if not dry_run:
|
if not dry_run:
|
||||||
dockerops.compose_up(result.path, m.name)
|
dockerops.compose_up(result.path, m.name)
|
||||||
|
|
@ -88,56 +48,5 @@ def reconcile(apps_root: Path, dry_run: bool = False) -> list[Action]:
|
||||||
return actions
|
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:
|
def has_errors(actions: list[Action]) -> bool:
|
||||||
return any(a.kind == "error" for a in actions)
|
return any(a.kind == "error" for a in actions)
|
||||||
|
|
|
||||||
|
|
@ -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)]
|
|
||||||
|
|
@ -29,19 +29,18 @@ the updater at a tmpdir.
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
import fcntl
|
import fcntl
|
||||||
import http.client
|
import hashlib
|
||||||
import json
|
import json
|
||||||
import os
|
import os
|
||||||
import shutil
|
import shutil
|
||||||
import subprocess
|
import subprocess
|
||||||
|
import tarfile
|
||||||
import time
|
import time
|
||||||
import urllib.error
|
import urllib.error
|
||||||
import urllib.request
|
import urllib.request
|
||||||
from dataclasses import dataclass
|
from dataclasses import dataclass
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
|
||||||
from furtka import _release_common as _rc
|
|
||||||
|
|
||||||
FORGEJO_HOST = os.environ.get("FURTKA_FORGEJO_HOST", "forgejo.sourcegate.online")
|
FORGEJO_HOST = os.environ.get("FURTKA_FORGEJO_HOST", "forgejo.sourcegate.online")
|
||||||
FORGEJO_REPO = os.environ.get("FURTKA_FORGEJO_REPO", "daniel/furtka")
|
FORGEJO_REPO = os.environ.get("FURTKA_FORGEJO_REPO", "daniel/furtka")
|
||||||
_FURTKA_ROOT = Path(os.environ.get("FURTKA_ROOT", "/opt/furtka"))
|
_FURTKA_ROOT = Path(os.environ.get("FURTKA_ROOT", "/opt/furtka"))
|
||||||
|
|
@ -50,12 +49,7 @@ _CADDYFILE_LIVE = Path(os.environ.get("FURTKA_CADDYFILE_PATH", "/etc/caddy/Caddy
|
||||||
_CADDY_SNIPPET_DIR = Path(
|
_CADDY_SNIPPET_DIR = Path(
|
||||||
os.environ.get("FURTKA_CADDY_SNIPPET_DIR", str(_CADDYFILE_LIVE.parent / "furtka.d"))
|
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"))
|
_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):
|
class UpdateError(RuntimeError):
|
||||||
|
|
@ -99,11 +93,37 @@ def read_current_version() -> str:
|
||||||
return "dev"
|
return "dev"
|
||||||
|
|
||||||
|
|
||||||
def _forgejo_api(path: str) -> dict | list:
|
def _forgejo_api(path: str) -> dict:
|
||||||
return _rc.forgejo_api(FORGEJO_HOST, FORGEJO_REPO, path, error_cls=UpdateError)
|
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:
|
def check_update() -> UpdateCheck:
|
||||||
|
|
@ -143,117 +163,78 @@ def check_update() -> UpdateCheck:
|
||||||
|
|
||||||
|
|
||||||
def _download(url: str, dest: Path) -> None:
|
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:
|
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:
|
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:
|
def _extract_tarball(tarball: Path, dest: Path) -> str:
|
||||||
return _rc.extract_tarball(tarball, dest, error_cls=UpdateError)
|
"""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:
|
||||||
def _current_hostname() -> str:
|
# defensive: refuse entries that would escape dest
|
||||||
"""Read the box's hostname from /etc/hostname, falling back to 'furtka'.
|
for member in tf.getmembers():
|
||||||
|
if member.name.startswith(("/", "..")) or ".." in Path(member.name).parts:
|
||||||
Used to substitute the __FURTKA_HOSTNAME__ marker in the shipped Caddyfile
|
raise UpdateError(f"refusing tarball entry {member.name!r}")
|
||||||
so Caddy's `tls internal` sees a real name to issue a leaf cert for.
|
# 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:
|
try:
|
||||||
name = _HOSTNAME_FILE.read_text().strip()
|
tf.extractall(dest, filter="data")
|
||||||
except (FileNotFoundError, PermissionError, OSError):
|
except TypeError:
|
||||||
return "furtka"
|
tf.extractall(dest)
|
||||||
return name or "furtka"
|
version_file = dest / "VERSION"
|
||||||
|
if not version_file.is_file():
|
||||||
|
raise UpdateError("tarball has no VERSION file at root")
|
||||||
def _maybe_migrate_preserve_https() -> None:
|
return version_file.read_text().strip()
|
||||||
"""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()))
|
|
||||||
|
|
||||||
|
|
||||||
def _refresh_caddyfile(source: Path) -> bool:
|
def _refresh_caddyfile(source: Path) -> bool:
|
||||||
"""Copy the shipped Caddyfile to /etc/caddy/ iff it differs. Returns True
|
"""Copy the shipped Caddyfile to /etc/caddy/ iff it differs. Returns True
|
||||||
if the file changed (so caddy needs more than a bare reload).
|
if 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 not source.is_file():
|
if not source.is_file():
|
||||||
return False
|
return False
|
||||||
# Snippet dirs for the /api/furtka/https/force toggle. Pre-HTTPS
|
# Snippet dir for the /api/furtka/https/force toggle. Pre-HTTPS installs
|
||||||
# installs don't have them; ensure both so the Caddyfile's glob
|
# don't have this dir; ensure it so the Caddyfile's glob import can't
|
||||||
# imports can't trip an older Caddy on missing paths during the
|
# trip an older Caddy on a missing path during the first reload.
|
||||||
# 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.
|
|
||||||
_CADDY_SNIPPET_DIR.mkdir(mode=0o755, parents=True, exist_ok=True)
|
_CADDY_SNIPPET_DIR.mkdir(mode=0o755, parents=True, exist_ok=True)
|
||||||
_CADDY_HTTPS_SNIPPET_DIR.mkdir(mode=0o755, parents=True, exist_ok=True)
|
if _CADDYFILE_LIVE.is_file() and source.read_bytes() == _CADDYFILE_LIVE.read_bytes():
|
||||||
# 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():
|
|
||||||
return False
|
return False
|
||||||
_CADDYFILE_LIVE.parent.mkdir(parents=True, exist_ok=True)
|
_CADDYFILE_LIVE.parent.mkdir(parents=True, exist_ok=True)
|
||||||
_CADDYFILE_LIVE.write_text(rendered)
|
shutil.copy(source, _CADDYFILE_LIVE)
|
||||||
return True
|
return True
|
||||||
|
|
||||||
|
|
||||||
def _link_new_units(unit_dir: Path) -> list[str]:
|
def _link_new_units(unit_dir: Path) -> list[str]:
|
||||||
"""`systemctl link` any unit file in unit_dir that isn't already symlinked
|
"""`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.
|
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.
|
|
||||||
"""
|
|
||||||
if not unit_dir.is_dir():
|
if not unit_dir.is_dir():
|
||||||
return []
|
return []
|
||||||
linked = []
|
linked = []
|
||||||
|
|
@ -263,10 +244,7 @@ def _link_new_units(unit_dir: Path) -> list[str]:
|
||||||
target = _SYSTEMD_DIR / unit_file.name
|
target = _SYSTEMD_DIR / unit_file.name
|
||||||
if target.exists() or target.is_symlink():
|
if target.exists() or target.is_symlink():
|
||||||
continue
|
continue
|
||||||
_run(["systemctl", "link", str(current_symlink() / "assets" / "systemd" / unit_file.name)])
|
_run(["systemctl", "link", str(unit_file)])
|
||||||
if unit_file.suffix == ".timer":
|
|
||||||
_run(["systemctl", "enable", unit_file.name])
|
|
||||||
_run(["systemctl", "start", unit_file.name])
|
|
||||||
linked.append(unit_file.name)
|
linked.append(unit_file.name)
|
||||||
return linked
|
return linked
|
||||||
|
|
||||||
|
|
@ -281,10 +259,9 @@ def write_state(stage: str, **extra) -> None:
|
||||||
|
|
||||||
def read_state() -> dict:
|
def read_state() -> dict:
|
||||||
try:
|
try:
|
||||||
data = json.loads(state_path().read_text())
|
return json.loads(state_path().read_text())
|
||||||
except (OSError, UnicodeDecodeError, json.JSONDecodeError):
|
except (FileNotFoundError, json.JSONDecodeError):
|
||||||
return {}
|
return {}
|
||||||
return data if isinstance(data, dict) else {}
|
|
||||||
|
|
||||||
|
|
||||||
def acquire_lock():
|
def acquire_lock():
|
||||||
|
|
@ -308,56 +285,18 @@ def _run(cmd: list[str]) -> None:
|
||||||
|
|
||||||
|
|
||||||
def _health_check(url: str, deadline_s: float = 30.0) -> bool:
|
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
|
end = time.time() + deadline_s
|
||||||
while time.time() < end:
|
while time.time() < end:
|
||||||
try:
|
try:
|
||||||
with urllib.request.urlopen(url, timeout=3) as resp:
|
with urllib.request.urlopen(url, timeout=3) as resp:
|
||||||
# Any 2xx/3xx → alive. urllib follows redirects by
|
if resp.status == 200:
|
||||||
# default, so a 302 → /login resolves to /login's 200.
|
|
||||||
if resp.status < 500:
|
|
||||||
return True
|
return True
|
||||||
except urllib.error.HTTPError as e:
|
except urllib.error.URLError:
|
||||||
# 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.
|
|
||||||
pass
|
pass
|
||||||
time.sleep(1)
|
time.sleep(1)
|
||||||
return False
|
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]:
|
def prepare_update(check: UpdateCheck, download_dir: Path | None = None) -> tuple[Path, str]:
|
||||||
"""Download + verify the tarball. Returns (tarball_path, version)."""
|
"""Download + verify the tarball. Returns (tarball_path, version)."""
|
||||||
if not check.tarball_url or not check.sha256_url:
|
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)
|
target.chmod(0o755)
|
||||||
|
|
||||||
write_state("swapping", latest=version)
|
write_state("swapping", latest=version)
|
||||||
previous = os.readlink(current) if current.is_symlink() else None
|
previous = None
|
||||||
old_caddyfile = _CADDYFILE_LIVE.read_text() if _CADDYFILE_LIVE.is_file() else None
|
if current.is_symlink():
|
||||||
|
previous = os.readlink(current)
|
||||||
|
current.unlink()
|
||||||
try:
|
try:
|
||||||
_swap_symlink(current, target)
|
current.symlink_to(target)
|
||||||
except OSError as e:
|
except OSError as e:
|
||||||
|
if previous:
|
||||||
|
current.symlink_to(previous)
|
||||||
raise UpdateError(f"symlink swap failed: {e}") from e
|
raise UpdateError(f"symlink swap failed: {e}") from e
|
||||||
|
|
||||||
write_state("restarting", latest=version)
|
write_state("restarting", latest=version)
|
||||||
|
|
@ -422,41 +365,27 @@ def apply_update(tarball: Path, version: str) -> None:
|
||||||
_run(["systemctl", "daemon-reload"])
|
_run(["systemctl", "daemon-reload"])
|
||||||
_run(["systemctl", "try-restart", "furtka-reconcile.service"])
|
_run(["systemctl", "try-restart", "furtka-reconcile.service"])
|
||||||
_run(["systemctl", "restart", "furtka-api.service"])
|
_run(["systemctl", "restart", "furtka-api.service"])
|
||||||
except (UpdateError, OSError) as e:
|
except UpdateError as e:
|
||||||
# OSError: e.g. disk full or EACCES writing the Caddyfile — the new
|
_rollback(previous, version, f"service restart failed: {e}")
|
||||||
# version is already live, so this must roll back too.
|
raise
|
||||||
_rollback(previous, version, f"service restart failed: {e}", old_caddyfile)
|
|
||||||
raise UpdateError(f"service restart failed: {e} — rolled back") from e
|
|
||||||
|
|
||||||
write_state("verifying", latest=version)
|
write_state("verifying", latest=version)
|
||||||
ok = _health_check("http://127.0.0.1:7000/api/apps", deadline_s=30.0)
|
ok = _health_check("http://127.0.0.1:7000/api/apps", deadline_s=30.0)
|
||||||
if not ok:
|
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")
|
raise UpdateError("new version failed health check — rolled back")
|
||||||
|
|
||||||
write_state("done", version=version)
|
write_state("done", version=version)
|
||||||
|
|
||||||
|
|
||||||
def _rollback(
|
def _rollback(previous_target: str | None, failed_version: str, reason: str) -> None:
|
||||||
previous_target: str | None,
|
|
||||||
failed_version: str,
|
|
||||||
reason: str,
|
|
||||||
old_caddyfile: str | None = None,
|
|
||||||
) -> None:
|
|
||||||
current = current_symlink()
|
current = current_symlink()
|
||||||
if previous_target:
|
if previous_target:
|
||||||
_swap_symlink(current, previous_target)
|
if current.is_symlink():
|
||||||
# The new release may have shipped Caddy routes the old code
|
current.unlink()
|
||||||
# doesn't serve; put the previous Caddyfile back before reloading.
|
current.symlink_to(previous_target)
|
||||||
if old_caddyfile is not None:
|
|
||||||
try:
|
|
||||||
_CADDYFILE_LIVE.write_text(old_caddyfile)
|
|
||||||
except OSError:
|
|
||||||
pass
|
|
||||||
# Best-effort restart on the previous target — if it fails too the
|
# 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.
|
# 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)
|
subprocess.run(["systemctl", "restart", "furtka-api.service"], check=False)
|
||||||
write_state(
|
write_state(
|
||||||
"rolled_back",
|
"rolled_back",
|
||||||
|
|
@ -473,7 +402,6 @@ def run_update() -> UpdateCheck:
|
||||||
UpdateError on any failure; the state file records the stage.
|
UpdateError on any failure; the state file records the stage.
|
||||||
"""
|
"""
|
||||||
with acquire_lock():
|
with acquire_lock():
|
||||||
try:
|
|
||||||
check = check_update()
|
check = check_update()
|
||||||
if not check.update_available:
|
if not check.update_available:
|
||||||
write_state("done", version=check.current, note="already up to date")
|
write_state("done", version=check.current, note="already up to date")
|
||||||
|
|
@ -481,17 +409,6 @@ def run_update() -> UpdateCheck:
|
||||||
tarball, version = prepare_update(check)
|
tarball, version = prepare_update(check)
|
||||||
apply_update(tarball, version)
|
apply_update(tarball, version)
|
||||||
return check
|
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:
|
def rollback() -> str:
|
||||||
|
|
@ -511,7 +428,8 @@ def rollback() -> str:
|
||||||
raise UpdateError("no other version slots available to roll back to")
|
raise UpdateError("no other version slots available to roll back to")
|
||||||
target_name = candidates[0]
|
target_name = candidates[0]
|
||||||
target = versions_dir() / target_name
|
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", "daemon-reload"], check=False)
|
||||||
subprocess.run(["systemctl", "restart", "furtka-api.service"], check=False)
|
subprocess.run(["systemctl", "restart", "furtka-api.service"], check=False)
|
||||||
write_state("rolled_back_manual", restored_to=target_name)
|
write_state("rolled_back_manual", restored_to=target_name)
|
||||||
|
|
|
||||||
|
|
@ -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}
|
|
||||||
|
|
@ -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/`:
|
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 file | Effect |
|
||||||
|----------------------------------------------|----------------------------------------------------------------------------------|
|
|-------------------------------------------|----------------------------------------------------------------------------------|
|
||||||
| `overlay/packages.extra` | Appended to the package list. Adds `python`, `python-flask`, `avahi`, `nss-mdns` |
|
| `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/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/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/systemd/system/` | Contains `furtka-webinstaller.service` + a symlink into `multi-user.target.wants/` so it auto-starts on boot |
|
||||||
| `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/` |
|
|
||||||
|
|
||||||
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.
|
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.
|
mDNS (`proksi.local`) via avahi is installed but not yet wired. First milestone is just "boot → browser → wizard at raw IP". Naming comes next.
|
||||||
|
|
||||||
### 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.
|
|
||||||
|
|
||||||
## Test flow
|
## 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)
|
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
|
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
|
## 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:
|
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.
|
- **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.
|
- **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`.
|
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
|
## 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".
|
- **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`.
|
||||||
|
|
|
||||||
60
iso/build.sh
|
|
@ -27,11 +27,7 @@ fi
|
||||||
# ---- inside the container from here on ----
|
# ---- inside the container from here on ----
|
||||||
|
|
||||||
echo "==> Syncing pacman, installing archiso"
|
echo "==> Syncing pacman, installing archiso"
|
||||||
pacman -Syu --noconfirm --needed archiso curl zstd
|
pacman -Syu --noconfirm --needed archiso
|
||||||
|
|
||||||
# archinstall build the webinstaller is validated against (see pin step below).
|
|
||||||
ARCHINSTALL_PIN="4.4-1"
|
|
||||||
ARCHINSTALL_SHA256="6cb5da75c108ef2f5d25a7a20baf6c52cc927d5e06890dd10182e6eb4be815cb"
|
|
||||||
|
|
||||||
PROFILE_SRC="/usr/share/archiso/configs/releng"
|
PROFILE_SRC="/usr/share/archiso/configs/releng"
|
||||||
PROFILE_WORK="/tmp/furtka-profile"
|
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"
|
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/"
|
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)"
|
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
|
# releng ships menu entries labelled "Arch Linux install medium" across three
|
||||||
# bootloader configs (BIOS syslinux, GRUB, systemd-boot for UEFI). Rewrite to
|
# bootloader configs (BIOS syslinux, GRUB, systemd-boot for UEFI). Rewrite to
|
||||||
# our brand. Done with sed (not a static overlay) so upstream archiso file
|
# 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.
|
# next to webinstaller/app.py so _resolve_assets_dir() finds it at runtime.
|
||||||
cp -a "$REPO_ROOT/assets" "$PROFILE_WORK/airootfs/opt/furtka/assets"
|
cp -a "$REPO_ROOT/assets" "$PROFILE_WORK/airootfs/opt/furtka/assets"
|
||||||
rm -rf "$PROFILE_WORK/airootfs/opt/furtka/__pycache__"
|
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
|
# Pack the resource manager (furtka/ Python package + bundled apps/) as a
|
||||||
# tarball that webinstaller hands to archinstall via custom_commands. Lives at
|
# 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/"
|
cp -a "$REPO_ROOT/assets" "$PAYLOAD_STAGE/"
|
||||||
find "$PAYLOAD_STAGE" -type d -name __pycache__ -exec rm -rf {} +
|
find "$PAYLOAD_STAGE" -type d -name __pycache__ -exec rm -rf {} +
|
||||||
# VERSION at tarball root: the installer reads it to choose the versions/<ver>/
|
# 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
|
# directory name and /opt/furtka/current/VERSION reports it at runtime.
|
||||||
# value we wrote into /opt/furtka/VERSION for the live wizard footer above.
|
grep -E '^version = ' "$REPO_ROOT/pyproject.toml" | head -1 \
|
||||||
echo "$ISO_VERSION" > "$PAYLOAD_STAGE/VERSION"
|
| sed 's/.*= "\(.*\)"/\1/' > "$PAYLOAD_STAGE/VERSION"
|
||||||
tar -czf "$PROFILE_WORK/airootfs/opt/furtka-resource-manager.tar.gz" \
|
tar -czf "$PROFILE_WORK/airootfs/opt/furtka-resource-manager.tar.gz" \
|
||||||
-C "$PAYLOAD_STAGE" .
|
-C "$PAYLOAD_STAGE" .
|
||||||
rm -rf "$PAYLOAD_STAGE"
|
rm -rf "$PAYLOAD_STAGE"
|
||||||
|
|
|
||||||
|
|
@ -2,6 +2,5 @@
|
||||||
Furtka Live Installer starting…
|
Furtka Live Installer starting…
|
||||||
|
|
||||||
Once ready, open http://proksi.local:5000 on another device
|
Once ready, open http://proksi.local:5000 on another device
|
||||||
on your network. The exact URL (and an IP fallback) will
|
on your network. The exact URL will appear below.
|
||||||
appear here in a few seconds.
|
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1,12 +1,12 @@
|
||||||
[Unit]
|
[Unit]
|
||||||
Description=Write Furtka /etc/issue with current IP for the console welcome
|
Description=Write Furtka /etc/issue with current IP for the console welcome
|
||||||
# No network-online dependency on purpose: the first run should paint the
|
After=network-online.target
|
||||||
# banner (with a "no IP yet" fallback) as early as possible; the timer
|
Wants=network-online.target
|
||||||
# re-runs it until an address shows up.
|
|
||||||
|
|
||||||
[Service]
|
[Service]
|
||||||
Type=oneshot
|
Type=oneshot
|
||||||
ExecStart=/usr/local/bin/furtka-update-issue
|
ExecStart=/usr/local/bin/furtka-update-issue
|
||||||
|
RemainAfterExit=yes
|
||||||
|
|
||||||
[Install]
|
[Install]
|
||||||
WantedBy=multi-user.target
|
WantedBy=multi-user.target
|
||||||
|
|
|
||||||
|
|
@ -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
|
|
||||||
|
|
@ -1 +0,0 @@
|
||||||
../furtka-issue.timer
|
|
||||||
|
|
@ -2,53 +2,22 @@
|
||||||
# Regenerates /etc/issue so the live-ISO console tells the user which URL
|
# 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
|
# 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
|
# preferred URL and the raw IP as a fallback for networks where mDNS
|
||||||
# doesn't work.
|
# doesn't work. Reload at the end nudges agetty to redraw.
|
||||||
#
|
set -e
|
||||||
# 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
|
|
||||||
|
|
||||||
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
|
||||||
echo " Open Furtka in a browser on another device on your network:"
|
echo " Open Furtka in a browser on another device on your network:"
|
||||||
echo
|
echo
|
||||||
echo " http://proksi.local:5000 (easy — try this first)"
|
echo " http://proksi.local:5000 (easy — try this first)"
|
||||||
if ((${#ips[@]})); then
|
if [ -n "$ip" ]; then
|
||||||
for ip in "${ips[@]}"; do
|
|
||||||
echo " http://${ip}:5000 (fallback if the first doesn't work)"
|
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
|
fi
|
||||||
echo
|
echo
|
||||||
echo " Then follow the wizard to install Furtka on this machine."
|
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
|
||||||
echo " Need a shell? Log in as root (no password)."
|
} > /etc/issue
|
||||||
echo
|
|
||||||
)
|
|
||||||
|
|
||||||
if [ "$new" != "$(cat "$issue" 2>/dev/null)" ]; then
|
agetty --reload 2>/dev/null || true
|
||||||
printf '%s\n' "$new" > "$issue"
|
|
||||||
agetty --reload 2>/dev/null || true
|
|
||||||
fi
|
|
||||||
|
|
|
||||||
|
Before Width: | Height: | Size: 4.3 KiB |
|
|
@ -8,43 +8,10 @@ server {
|
||||||
|
|
||||||
charset utf-8;
|
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 / {
|
location / {
|
||||||
try_files $uri $uri/ $uri.html =404;
|
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 {
|
location = /favicon.svg {
|
||||||
access_log off;
|
access_log off;
|
||||||
log_not_found off;
|
log_not_found off;
|
||||||
|
|
|
||||||
|
|
@ -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;
|
|
||||||
|
|
@ -16,7 +16,6 @@ install -d -o "$OWNER" -g "$OWNER" -m 0755 "$WEBROOT"
|
||||||
install -d -o "$OWNER" -g "$OWNER" -m 0755 "$SRCROOT"
|
install -d -o "$OWNER" -g "$OWNER" -m 0755 "$SRCROOT"
|
||||||
|
|
||||||
cp "$(dirname "$0")/furtka.org.conf" "$SITE_CONF"
|
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"
|
ln -sfn "$SITE_CONF" "$SITE_LINK"
|
||||||
|
|
||||||
# Drop the Ubuntu default site so it doesn't shadow us on :80.
|
# Drop the Ubuntu default site so it doesn't shadow us on :80.
|
||||||
|
|
|
||||||
|
|
@ -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
|
|
||||||
|
|
@ -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()
|
|
||||||
|
|
@ -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"
|
|
||||||
|
|
@ -1,6 +1,6 @@
|
||||||
[project]
|
[project]
|
||||||
name = "furtka"
|
name = "furtka"
|
||||||
version = "26.21-alpha"
|
version = "26.4-alpha"
|
||||||
description = "Open-source home server OS — simple enough for everyone."
|
description = "Open-source home server OS — simple enough for everyone."
|
||||||
requires-python = ">=3.11"
|
requires-python = ">=3.11"
|
||||||
readme = "README.md"
|
readme = "README.md"
|
||||||
|
|
|
||||||
|
|
@ -99,29 +99,4 @@ upload_asset "$TARBALL"
|
||||||
upload_asset "$SHA_FILE"
|
upload_asset "$SHA_FILE"
|
||||||
upload_asset "$RELEASE_JSON"
|
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"
|
echo "Release $VERSION published: https://$HOST/$REPO/releases/tag/$VERSION"
|
||||||
|
|
|
||||||
|
|
@ -50,22 +50,9 @@ SHORT_SHA="${SHA:0:12}"
|
||||||
API="https://${PVE_TEST_HOST}:8006/api2/json"
|
API="https://${PVE_TEST_HOST}:8006/api2/json"
|
||||||
|
|
||||||
api() {
|
api() {
|
||||||
# Wrapper so that on non-2xx we print the PVE response body to stderr
|
curl --silent --show-error --fail-with-body -k \
|
||||||
# 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 \
|
|
||||||
--header "Authorization: PVEAPIToken=${PVE_TEST_TOKEN}" \
|
--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.
|
# PVE returns {"data": <payload>}; grab .data into a python expression.
|
||||||
|
|
@ -153,19 +140,14 @@ MAC_LOWER="$(echo "$MAC" | tr 'A-Z' 'a-z')"
|
||||||
IP=""
|
IP=""
|
||||||
deadline=$((SECONDS + 150))
|
deadline=$((SECONDS + 150))
|
||||||
while (( SECONDS < deadline )); do
|
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
|
if command -v arp-scan >/dev/null 2>&1; then
|
||||||
SCAN="$(sudo arp-scan --localnet --quiet --ignoredups 2>/dev/null || true)"
|
IP="$(sudo arp-scan --localnet --quiet --ignoredups 2>/dev/null \
|
||||||
IP="$(awk -v m="$MAC_LOWER" 'tolower($2) == m { print $1; exit }' <<<"$SCAN")"
|
| awk -v m="$MAC_LOWER" 'tolower($2) == m { print $1; exit }')"
|
||||||
fi
|
fi
|
||||||
if [[ -z "$IP" ]] && command -v nmap >/dev/null 2>&1; then
|
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
|
sudo nmap -sn -T4 192.168.178.0/24 >/dev/null 2>&1 || true
|
||||||
NEIGH="$(ip neigh show)"
|
IP="$(ip neigh show \
|
||||||
IP="$(awk -v m="$MAC_LOWER" 'tolower($5) == m && $1 ~ /^[0-9]/ { print $1; exit }' <<<"$NEIGH")"
|
| awk -v m="$MAC_LOWER" 'tolower($5) == m && $1 ~ /^[0-9]/ { print $1; exit }')"
|
||||||
fi
|
fi
|
||||||
[[ -n "$IP" ]] && break
|
[[ -n "$IP" ]] && break
|
||||||
sleep 5
|
sleep 5
|
||||||
|
|
|
||||||
1367
tests/test_api.py
|
|
@ -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"]
|
assert pkg in cfg["packages"]
|
||||||
# Packaged units go in `services` (enabled before custom_commands runs);
|
# 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
|
# 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": [],
|
"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"
|
|
||||||
|
|
|
||||||
|
|
@ -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)
|
|
||||||
|
|
@ -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")
|
|
||||||
|
|
@ -32,21 +32,9 @@ def test_app_list_json_with_one_app(tmp_path, monkeypatch, capsys):
|
||||||
"display_name": "Network Files",
|
"display_name": "Network Files",
|
||||||
"version": "0.1.0",
|
"version": "0.1.0",
|
||||||
"description": "SMB",
|
"description": "SMB",
|
||||||
"description_long": "Long description here.",
|
|
||||||
"volumes": ["files"],
|
"volumes": ["files"],
|
||||||
"ports": [445],
|
"ports": [445],
|
||||||
"icon": "icon.svg",
|
"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)
|
data = json.loads(capsys.readouterr().out)
|
||||||
assert len(data) == 1
|
assert len(data) == 1
|
||||||
assert data[0]["ok"] is True
|
assert data[0]["ok"] is True
|
||||||
m = data[0]["manifest"]
|
assert data[0]["manifest"]["name"] == "fileshare"
|
||||||
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
|
|
||||||
|
|
||||||
|
|
||||||
def test_reconcile_dry_run_empty(tmp_path, monkeypatch, capsys):
|
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
|
assert rc == 0
|
||||||
out = capsys.readouterr().out
|
out = capsys.readouterr().out
|
||||||
assert "0 actions" in 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()
|
|
||||||
|
|
|
||||||
|
|
@ -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"
|
|
||||||
|
|
@ -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"]
|
|
||||||
|
|
@ -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)
|
|
||||||
|
|
@ -1,63 +1,47 @@
|
||||||
import json
|
|
||||||
|
|
||||||
import drives
|
|
||||||
import pytest
|
|
||||||
from drives import (
|
from drives import (
|
||||||
MIN_DISK_GB,
|
get_drive_type_label,
|
||||||
_boot_device_from_cmdline,
|
|
||||||
classify_drive,
|
|
||||||
format_size,
|
|
||||||
get_drive_type_score,
|
get_drive_type_score,
|
||||||
get_size_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):
|
def test_parse_size_gb_gigabytes():
|
||||||
"""Build lsblk -J output. Each entry: (name, size_gb, type, extra-dict)."""
|
assert parse_size_gb("500G") == 500.0
|
||||||
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})
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.fixture(autouse=True)
|
def test_parse_size_gb_megabytes():
|
||||||
def _smart_passed(monkeypatch):
|
assert parse_size_gb("2048M") == 2.0
|
||||||
monkeypatch.setattr(drives, "_smart_status", lambda _: "passed")
|
|
||||||
|
|
||||||
|
|
||||||
def _names(devices):
|
def test_parse_size_gb_european_comma_decimal():
|
||||||
return [d["name"] for d in devices]
|
assert parse_size_gb("1,5T") == 1.5 * 1024
|
||||||
|
|
||||||
|
|
||||||
def test_classify_uses_transport_and_rotation():
|
def test_parse_size_gb_empty_returns_none():
|
||||||
assert classify_drive("nvme0n1", tran="nvme", rota=False) == "NVMe"
|
assert parse_size_gb("") is None
|
||||||
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_type_scores_rank_internal_over_usb():
|
def test_parse_size_gb_unknown_unit_returns_none():
|
||||||
assert get_drive_type_score("NVMe") > get_drive_type_score("SSD")
|
assert parse_size_gb("500K") is None
|
||||||
assert get_drive_type_score("SSD") > get_drive_type_score("HDD")
|
|
||||||
assert get_drive_type_score("HDD") > get_drive_type_score("USB")
|
|
||||||
|
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():
|
def test_size_score_bands():
|
||||||
|
|
@ -67,145 +51,47 @@ def test_size_score_bands():
|
||||||
assert get_size_score(1024) == 10
|
assert get_size_score(1024) == 10
|
||||||
|
|
||||||
|
|
||||||
def test_format_size():
|
def test_score_device_sums_type_and_size(monkeypatch):
|
||||||
assert format_size(500 * GB) == "500 GB"
|
import drives
|
||||||
assert format_size(1024 * GB) == "1 TB"
|
|
||||||
assert format_size(int(1.5 * 1024 * GB)) == "1.5 TB"
|
monkeypatch.setattr(drives, "get_drive_health", lambda _: 10)
|
||||||
assert format_size(int(7.5 * GB)) == "7.5 GB"
|
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():
|
def test_parse_lsblk_drops_loop_and_rom(monkeypatch):
|
||||||
out = _lsblk(
|
import drives
|
||||||
("loop0", 2.5, "loop"),
|
|
||||||
("sr0", 1, "rom"),
|
monkeypatch.setattr(drives, "_smart_status", lambda _: "passed")
|
||||||
("zram0", 4, "disk"),
|
output = "loop0 2.5G loop\nsr0 1024M rom\nsda 500G disk\nnvme0n1 1T disk\n"
|
||||||
("mmcblk0boot0", 0.004, "disk"),
|
devices = parse_lsblk_output(output)
|
||||||
("mmcblk0boot1", 0.004, "disk"),
|
names = [d["name"] for d in devices]
|
||||||
("mmcblk0rpmb", 0.004, "disk"),
|
assert names == ["/dev/nvme0n1", "/dev/sda"]
|
||||||
("mmcblk0", 64, "disk", {"tran": "", "rota": False}),
|
|
||||||
)
|
|
||||||
assert _names(parse_lsblk_json(out)) == ["/dev/mmcblk0"]
|
|
||||||
|
|
||||||
|
|
||||||
def test_drops_empty_card_reader_slots_and_read_only_disks():
|
def test_parse_lsblk_attaches_human_labels(monkeypatch):
|
||||||
out = _lsblk(
|
import drives
|
||||||
("sdc", 0, "disk", {"rm": True, "tran": "usb"}),
|
|
||||||
("sdd", 500, "disk", {"ro": True}),
|
monkeypatch.setattr(drives, "_smart_status", lambda _: "passed")
|
||||||
("sda", 500, "disk"),
|
output = "nvme0n1 1T disk\n"
|
||||||
)
|
[dev] = parse_lsblk_output(output)
|
||||||
assert _names(parse_lsblk_json(out)) == ["/dev/sda"]
|
assert dev["type_label"] == "NVMe"
|
||||||
|
assert dev["health_label"] == "Healthy"
|
||||||
|
|
||||||
|
|
||||||
def test_ssd_beats_bigger_hdd():
|
def test_parse_lsblk_surfaces_smart_warning(monkeypatch):
|
||||||
# Regression: sdX SSDs used to be labelled HDD, so the 2 TB spinning disk
|
import drives
|
||||||
# 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_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")
|
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"
|
assert dev["health_label"] == "SMART warning"
|
||||||
|
|
||||||
|
|
||||||
def test_accepts_old_util_linux_string_flags():
|
def test_drive_type_label_nvme_ssd_hdd():
|
||||||
out = json.dumps(
|
assert get_drive_type_label("/dev/nvme0n1") == "NVMe"
|
||||||
{
|
assert get_drive_type_label("/dev/ssd0") == "SSD"
|
||||||
"blockdevices": [
|
assert get_drive_type_label("/dev/sda") == "HDD"
|
||||||
{
|
|
||||||
"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_handles_empty_and_garbage_output():
|
def test_parse_lsblk_handles_empty_output():
|
||||||
assert parse_lsblk_json("") == []
|
assert parse_lsblk_output("") == []
|
||||||
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"
|
|
||||||
|
|
|
||||||
|
|
@ -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
|
|
||||||
|
|
@ -1,15 +1,11 @@
|
||||||
"""Tests for furtka.https — fingerprint extraction + HTTPS toggle.
|
"""Tests for furtka.https — fingerprint extraction + force-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)
|
|
||||||
|
|
||||||
The fingerprint case uses a throwaway self-signed EC cert with a known
|
The fingerprint case uses a throwaway self-signed EC cert with a known
|
||||||
reference fingerprint (computed once via `openssl x509 -fingerprint
|
reference fingerprint (computed once via `openssl x509 -fingerprint
|
||||||
-sha256 -noout`) so we verify the PEM → DER → SHA256 path without a
|
-sha256 -noout`) so we verify the PEM → DER → SHA256 path without a
|
||||||
runtime subprocess dependency. The toggle cases stub the caddy reload
|
runtime subprocess dependency. The toggle cases stub the caddy reload
|
||||||
so we assert both snippet files are written / removed together and that
|
so we assert the snippet file is written / removed and that reload
|
||||||
reload failures roll BOTH state back.
|
failures roll state back.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
import subprocess
|
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):
|
def test_ca_fingerprint_matches_openssl(tmp_path):
|
||||||
cert = tmp_path / "root.crt"
|
cert = tmp_path / "root.crt"
|
||||||
cert.write_text(_TEST_CERT_PEM)
|
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):
|
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 == {
|
assert s == {
|
||||||
"ca_available": False,
|
"ca_available": False,
|
||||||
"fingerprint_sha256": None,
|
"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 = tmp_path / "root.crt"
|
||||||
ca.write_text(_TEST_CERT_PEM)
|
ca.write_text(_TEST_CERT_PEM)
|
||||||
https_snip = tmp_path / "https.caddyfile"
|
snippet = tmp_path / "redirect.caddyfile"
|
||||||
https_snip.write_text("furtka.local, furtka {\n\ttls internal\n\timport furtka_routes\n}\n")
|
snippet.write_text(https.REDIRECT_CONTENT)
|
||||||
s = https.status(ca_path=ca, https_snippet=https_snip)
|
s = https.status(ca_path=ca, snippet=snippet)
|
||||||
assert s["ca_available"] is True
|
assert s["ca_available"] is True
|
||||||
assert s["fingerprint_sha256"] == _TEST_CERT_FP_SHA256
|
assert s["fingerprint_sha256"] == _TEST_CERT_FP_SHA256
|
||||||
assert s["force_https"] is True
|
assert s["force_https"] is True
|
||||||
|
|
||||||
|
|
||||||
def test_status_force_reflects_https_snippet_not_redirect(tmp_path):
|
def test_set_force_enable_writes_snippet_and_reloads(tmp_path):
|
||||||
"""Authoritative signal for "HTTPS is on" is the listener snippet —
|
snippet_dir = tmp_path / "furtka.d"
|
||||||
a lone redirect without a :443 listener wouldn't actually serve
|
snippet = snippet_dir / "redirect.caddyfile"
|
||||||
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)
|
|
||||||
calls = []
|
calls = []
|
||||||
|
|
||||||
def fake_reload():
|
def fake_reload():
|
||||||
calls.append("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 result is True
|
||||||
assert p["snippet"].read_text() == https._redirect_snippet_content("testbox")
|
assert snippet.read_text() == https.REDIRECT_CONTENT
|
||||||
written = p["https_snippet"].read_text()
|
|
||||||
assert "testbox.local, testbox" in written
|
|
||||||
assert "tls internal" in written
|
|
||||||
assert "import furtka_routes" in written
|
|
||||||
assert calls == ["reload"]
|
assert calls == ["reload"]
|
||||||
|
|
||||||
|
|
||||||
def test_set_force_uses_fallback_hostname_when_file_missing(tmp_path):
|
def test_set_force_disable_removes_snippet(tmp_path):
|
||||||
# No /etc/hostname → fall back to 'furtka' so Caddy gets a parseable
|
snippet_dir = tmp_path / "furtka.d"
|
||||||
# block instead of an empty hostname that would fail config load.
|
snippet_dir.mkdir()
|
||||||
p = _paths(tmp_path)
|
snippet = snippet_dir / "redirect.caddyfile"
|
||||||
result = https.set_force_https(True, reload_caddy=lambda: None, **p)
|
snippet.write_text(https.REDIRECT_CONTENT)
|
||||||
assert result is True
|
|
||||||
assert "furtka.local, furtka" in p["https_snippet"].read_text()
|
|
||||||
|
|
||||||
|
result = https.set_force_https(
|
||||||
def test_set_force_disable_removes_both_snippets(tmp_path):
|
False, snippet_dir=snippet_dir, snippet=snippet, reload_caddy=lambda: None
|
||||||
_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)
|
|
||||||
assert result is False
|
assert result is False
|
||||||
assert not p["snippet"].exists()
|
assert not snippet.exists()
|
||||||
assert not p["https_snippet"].exists()
|
|
||||||
|
|
||||||
|
|
||||||
def test_set_force_disable_is_idempotent_when_already_off(tmp_path):
|
def test_set_force_disable_is_idempotent_when_already_off(tmp_path):
|
||||||
p = _paths(tmp_path)
|
snippet_dir = tmp_path / "furtka.d"
|
||||||
result = https.set_force_https(False, reload_caddy=lambda: None, **p)
|
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 result is False
|
||||||
assert not p["snippet"].exists()
|
assert not snippet.exists()
|
||||||
assert not p["https_snippet"].exists()
|
|
||||||
|
|
||||||
|
|
||||||
def test_reload_failure_rolls_back_enable(tmp_path):
|
def test_reload_failure_rolls_back_enable(tmp_path):
|
||||||
_prepare_hostname(tmp_path)
|
snippet_dir = tmp_path / "furtka.d"
|
||||||
p = _paths(tmp_path)
|
snippet = snippet_dir / "redirect.caddyfile"
|
||||||
|
|
||||||
def failing_reload():
|
def failing_reload():
|
||||||
raise subprocess.CalledProcessError(1, ["systemctl"], stderr="bad config")
|
raise subprocess.CalledProcessError(1, ["systemctl"], stderr="bad config")
|
||||||
|
|
||||||
with pytest.raises(https.HttpsError, match="caddy reload failed: bad config"):
|
with pytest.raises(https.HttpsError, match="caddy reload failed: bad config"):
|
||||||
https.set_force_https(True, reload_caddy=failing_reload, **p)
|
https.set_force_https(
|
||||||
# Rollback: since neither snippet existed before, neither exists after.
|
True, snippet_dir=snippet_dir, snippet=snippet, reload_caddy=failing_reload
|
||||||
assert not p["snippet"].exists()
|
)
|
||||||
assert not p["https_snippet"].exists()
|
# 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):
|
def test_reload_failure_rolls_back_disable(tmp_path):
|
||||||
_prepare_hostname(tmp_path)
|
snippet_dir = tmp_path / "furtka.d"
|
||||||
p = _paths(tmp_path)
|
snippet_dir.mkdir()
|
||||||
p["snippet_dir"].mkdir()
|
snippet = snippet_dir / "redirect.caddyfile"
|
||||||
p["https_snippet_dir"].mkdir()
|
original = "redir https://{host}{uri} permanent\n# marker\n"
|
||||||
original_redirect = "redir https://{host}{uri} permanent\n# marker\n"
|
snippet.write_text(original)
|
||||||
original_https = "# old https block\nfurtka.local { tls internal }\n"
|
|
||||||
p["snippet"].write_text(original_redirect)
|
|
||||||
p["https_snippet"].write_text(original_https)
|
|
||||||
|
|
||||||
def failing_reload():
|
def failing_reload():
|
||||||
raise subprocess.CalledProcessError(1, ["systemctl"], stderr="bad config")
|
raise subprocess.CalledProcessError(1, ["systemctl"], stderr="bad config")
|
||||||
|
|
||||||
with pytest.raises(https.HttpsError):
|
with pytest.raises(https.HttpsError):
|
||||||
https.set_force_https(False, reload_caddy=failing_reload, **p)
|
https.set_force_https(
|
||||||
# Rollback: both snippets are restored to their exact prior contents.
|
False, snippet_dir=snippet_dir, snippet=snippet, reload_caddy=failing_reload
|
||||||
assert p["snippet"].read_text() == original_redirect
|
)
|
||||||
assert p["https_snippet"].read_text() == original_https
|
# Rollback: snippet is restored to its exact prior contents.
|
||||||
|
assert snippet.read_text() == original
|
||||||
|
|
||||||
|
|
||||||
def test_systemctl_missing_raises_and_rolls_back(tmp_path):
|
def test_systemctl_missing_raises_and_rolls_back(tmp_path):
|
||||||
_prepare_hostname(tmp_path)
|
snippet_dir = tmp_path / "furtka.d"
|
||||||
p = _paths(tmp_path)
|
snippet = snippet_dir / "redirect.caddyfile"
|
||||||
|
|
||||||
def missing_systemctl():
|
def missing_systemctl():
|
||||||
raise FileNotFoundError(2, "No such file", "systemctl")
|
raise FileNotFoundError(2, "No such file", "systemctl")
|
||||||
|
|
||||||
with pytest.raises(https.HttpsError, match="systemctl not available"):
|
with pytest.raises(https.HttpsError, match="systemctl not available"):
|
||||||
https.set_force_https(True, reload_caddy=missing_systemctl, **p)
|
https.set_force_https(
|
||||||
assert not p["snippet"].exists()
|
True, snippet_dir=snippet_dir, snippet=snippet, reload_caddy=missing_systemctl
|
||||||
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"
|
|
||||||
)
|
)
|
||||||
|
assert not snippet.exists()
|
||||||
|
|
||||||
|
|
||||||
def test_https_snippet_content_has_tls_internal_and_routes(tmp_path):
|
def test_redirect_snippet_content_is_caddy_redir_directive():
|
||||||
# Lock the shape of the opt-in HTTPS listener block. Caddy parses
|
# Lock the exact directive. A regression here silently stops the
|
||||||
# this verbatim — changing the shape without updating the test
|
# redirect from taking effect even though the file-swap looks fine.
|
||||||
# risks shipping a silently-broken Caddyfile import.
|
assert https.REDIRECT_CONTENT.strip() == "redir https://{host}{uri} permanent"
|
||||||
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")
|
|
||||||
|
|
|
||||||
|
|
@ -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() == {}
|
|
||||||
|
|
@ -73,31 +73,6 @@ def test_install_from_copies_files(tmp_path, fake_dirs):
|
||||||
assert (target / ".env").read_text() == "A=1"
|
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):
|
def test_install_from_preserves_existing_env(tmp_path, fake_dirs):
|
||||||
src = _write_app_source(tmp_path, "fileshare", VALID_MANIFEST, env_example="A=new")
|
src = _write_app_source(tmp_path, "fileshare", VALID_MANIFEST, env_example="A=new")
|
||||||
target = apps_dir() / "fileshare"
|
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": ""})
|
write_env(p, {"A": "plain", "B": "has space", "C": 'has "quote"', "D": ""})
|
||||||
values = read_env_values(p)
|
values = read_env_values(p)
|
||||||
assert values == {"A": "plain", "B": "has space", "C": 'has "quote"', "D": ""}
|
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)
|
|
||||||
|
|
|
||||||
|
|
@ -95,21 +95,6 @@ def test_settings_optional_default_empty(tmp_path):
|
||||||
m = load_manifest(path)
|
m = load_manifest(path)
|
||||||
assert m.settings == ()
|
assert m.settings == ()
|
||||||
assert m.description_long == ""
|
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):
|
def test_settings_parsed(tmp_path):
|
||||||
|
|
@ -155,27 +140,6 @@ def test_settings_reject_unknown_type(tmp_path):
|
||||||
load_manifest(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):
|
def test_settings_reject_duplicate_name(tmp_path):
|
||||||
bad = dict(
|
bad = dict(
|
||||||
VALID_MANIFEST,
|
VALID_MANIFEST,
|
||||||
|
|
@ -191,158 +155,3 @@ def test_settings_non_list_rejected(tmp_path):
|
||||||
path = _write_app(tmp_path, "fileshare", bad)
|
path = _write_app(tmp_path, "fileshare", bad)
|
||||||
with pytest.raises(ManifestError, match="settings must be a list"):
|
with pytest.raises(ManifestError, match="settings must be a list"):
|
||||||
load_manifest(path)
|
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)
|
|
||||||
|
|
|
||||||
|
|
@ -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]
|
|
||||||
|
|
@ -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")
|
error = next(a for a in actions if a.kind == "error")
|
||||||
assert error.target == "fileshare"
|
assert error.target == "fileshare"
|
||||||
assert "docker" in error.detail
|
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"])
|
|
||||||
|
|
|
||||||
|
|
@ -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"]
|
|
||||||
|
|
@ -24,9 +24,6 @@ def updater(tmp_path, monkeypatch):
|
||||||
monkeypatch.setenv("FURTKA_LOCK_PATH", str(tmp_path / "update.lock"))
|
monkeypatch.setenv("FURTKA_LOCK_PATH", str(tmp_path / "update.lock"))
|
||||||
monkeypatch.setenv("FURTKA_CADDYFILE_PATH", str(tmp_path / "etc_caddy" / "Caddyfile"))
|
monkeypatch.setenv("FURTKA_CADDYFILE_PATH", str(tmp_path / "etc_caddy" / "Caddyfile"))
|
||||||
monkeypatch.setenv("FURTKA_SYSTEMD_DIR", str(tmp_path / "etc_systemd_system"))
|
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()
|
(tmp_path / "etc_systemd_system").mkdir()
|
||||||
# Reload the module so the path constants pick up the env vars.
|
# Reload the module so the path constants pick up the env vars.
|
||||||
import importlib
|
import importlib
|
||||||
|
|
@ -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
|
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):
|
def test_link_new_units_only_links_missing(updater, tmp_path, monkeypatch):
|
||||||
unit_dir = tmp_path / "assets_systemd"
|
unit_dir = tmp_path / "assets_systemd"
|
||||||
unit_dir.mkdir()
|
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)
|
linked = updater._link_new_units(unit_dir)
|
||||||
assert linked == ["furtka-bar.timer"]
|
assert linked == ["furtka-bar.timer"]
|
||||||
# Three calls for the newly-linked timer: link + enable + start. The
|
# Only one systemctl link call — for the new timer, not the existing service.
|
||||||
# already-linked service is untouched. `enable` alone only arms the
|
assert len(seen) == 1
|
||||||
# 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
|
|
||||||
assert seen[0][:2] == ["systemctl", "link"]
|
assert seen[0][:2] == ["systemctl", "link"]
|
||||||
# Linked via the `current` symlink, not the versioned dir being scanned,
|
assert seen[0][2].endswith("furtka-bar.timer")
|
||||||
# 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"]
|
|
||||||
|
|
||||||
|
|
||||||
def test_extract_tarball_uses_data_filter_when_available(tmp_path, updater, monkeypatch):
|
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
|
# 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
|
# cleanly on older runtimes. Capture the kwarg via a stub.
|
||||||
# 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
|
|
||||||
|
|
||||||
calls = []
|
calls = []
|
||||||
real_open = _rc.tarfile.open # capture before monkeypatching
|
real_open = updater.tarfile.open # capture before monkeypatching
|
||||||
|
|
||||||
class _Recorder:
|
class _Recorder:
|
||||||
def __init__(self, tarball):
|
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"
|
tar = tmp_path / "t.tar.gz"
|
||||||
_make_release_tarball(tar, "26.9-alpha")
|
_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"
|
dest = tmp_path / "dest"
|
||||||
updater._extract_tarball(tar, 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: [])
|
monkeypatch.setattr(updater, "_forgejo_api", lambda path: [])
|
||||||
with pytest.raises(updater.UpdateError, match="no releases"):
|
with pytest.raises(updater.UpdateError, match="no releases"):
|
||||||
updater.check_update()
|
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"
|
|
||||||
|
|
|
||||||
|
|
@ -31,10 +31,9 @@ ASSETS = REPO_ROOT / "assets"
|
||||||
|
|
||||||
# (install target path, asset path under furtka/assets/) — only the files we
|
# (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
|
# 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
|
# copied; they're reached via /opt/furtka/current and `systemctl link`.
|
||||||
# Caddyfile is not in this list because it's written with the hostname
|
|
||||||
# placeholder substituted — see test_post_install_substitutes_hostname_in_caddyfile.
|
|
||||||
ASSET_TARGETS = [
|
ASSET_TARGETS = [
|
||||||
|
("/etc/caddy/Caddyfile", "Caddyfile"),
|
||||||
("/var/lib/furtka/status.json", "www/status.json"),
|
("/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 = tmp_path / "payload.tar.gz"
|
||||||
fake.write_bytes(b"not a real tarball")
|
fake.write_bytes(b"not a real tarball")
|
||||||
monkeypatch.setattr(app, "RESOURCE_MANAGER_PAYLOAD", fake)
|
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)
|
@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
|
assert "root * /var/lib/furtka" in caddy
|
||||||
|
|
||||||
|
|
||||||
def _strip_caddy_comments(text: str) -> str:
|
def test_caddyfile_serves_both_http_and_https():
|
||||||
"""Remove # comments + blank lines so string-match assertions can
|
# :80 stays so users who haven't installed the CA still reach the box;
|
||||||
target actual Caddyfile directives, not the leading doc block.
|
# :443 uses Caddy's built-in local CA (tls internal) so users who have
|
||||||
Comment intro is ``#`` at start-of-line or preceded by whitespace."""
|
# installed it get the green padlock.
|
||||||
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.
|
|
||||||
caddy = (ASSETS / "Caddyfile").read_text()
|
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():
|
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():
|
def test_caddyfile_exposes_root_ca_download():
|
||||||
# /rootCA.crt is the download handle the UI uses. It must map to the
|
# /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
|
# 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
|
# treats it as a download rather than trying to render it.
|
||||||
# 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.
|
|
||||||
caddy = (ASSETS / "Caddyfile").read_text()
|
caddy = (ASSETS / "Caddyfile").read_text()
|
||||||
assert "handle /rootCA.crt" in caddy
|
assert "handle /rootCA.crt" in caddy
|
||||||
assert "/var/lib/caddy/pki/authorities/local" in caddy
|
assert "/var/lib/caddy/.local/share/caddy/pki/authorities/local" in caddy
|
||||||
assert ".local/share/caddy" not in caddy
|
|
||||||
assert "attachment; filename=furtka-local-rootCA.crt" 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):
|
def test_post_install_creates_furtka_d_snippet_dir(install_cmds):
|
||||||
# Pre-existing installs pick up the import path via updater._refresh_caddyfile,
|
# Pre-existing installs pick up the import path via updater._refresh_caddyfile,
|
||||||
# but fresh installs never run that — this command is the only guarantee
|
# 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():
|
def test_read_asset_raises_for_missing_file():
|
||||||
with pytest.raises(FileNotFoundError):
|
with pytest.raises(FileNotFoundError):
|
||||||
app._read_asset("does/not/exist.html")
|
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():
|
def test_assets_dir_resolves_to_repo_tree():
|
||||||
assert app._ASSETS_DIR == ASSETS
|
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
|
|
||||||
|
|
|
||||||
|
|
@ -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}
|
|
||||||
|
|
@ -8,52 +8,13 @@ import os
|
||||||
import re
|
import re
|
||||||
import subprocess
|
import subprocess
|
||||||
import sys
|
import sys
|
||||||
import threading
|
|
||||||
import traceback
|
|
||||||
from datetime import UTC
|
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
|
||||||
from diskprep import release_disk
|
|
||||||
from drives import list_scored_devices
|
from drives import list_scored_devices
|
||||||
from flask import Flask, jsonify, redirect, render_template, request, url_for
|
from flask import Flask, jsonify, redirect, render_template, request, url_for
|
||||||
|
|
||||||
app = Flask(__name__)
|
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 = {
|
LANGUAGES = {
|
||||||
"en": {"locale": "en_US.UTF-8", "label": "English", "keyboard": "us"},
|
"en": {"locale": "en_US.UTF-8", "label": "English", "keyboard": "us"},
|
||||||
"de": {"locale": "de_DE.UTF-8", "label": "Deutsch", "keyboard": "de"},
|
"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])?$")
|
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}$")
|
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
|
# Ordered phase markers for the install progress bar. Each tuple is
|
||||||
# (substring to search for in the archinstall log, progress percent when
|
# (substring to search for in the archinstall log, progress percent when
|
||||||
# reached, user-facing label). Pick the furthest phase whose marker is
|
# 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:")
|
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):
|
||||||
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.
|
|
||||||
"""
|
|
||||||
percent = 2
|
percent = 2
|
||||||
phase = "Starting up…"
|
phase = "Starting up…"
|
||||||
for marker, pct, label in PROGRESS_PHASES:
|
for marker, pct, label in PROGRESS_PHASES:
|
||||||
|
|
@ -155,48 +71,29 @@ def parse_install_progress(log, exit_code=None):
|
||||||
|
|
||||||
if percent >= 100:
|
if percent >= 100:
|
||||||
status = "done"
|
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"
|
status = "error"
|
||||||
phase = "Installation failed — open Show details below"
|
phase = "Installation failed — open Show details below"
|
||||||
for marker, hint in PROGRESS_ERROR_HINTS:
|
|
||||||
if marker in log:
|
|
||||||
phase = hint
|
|
||||||
break
|
|
||||||
else:
|
else:
|
||||||
status = "running"
|
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}
|
return {"percent": percent, "phase": phase, "status": status}
|
||||||
|
|
||||||
|
|
||||||
def validate_step1(form):
|
def validate_step1(form):
|
||||||
errors = []
|
errors = []
|
||||||
# Lowercase instead of rejecting "Furtka" / "Daniel" — Linux needs
|
|
||||||
# lowercase, people type names the way they write them.
|
|
||||||
values = {
|
values = {
|
||||||
"hostname": form.get("hostname", "").strip().lower(),
|
"hostname": form.get("hostname", "").strip(),
|
||||||
"username": form.get("username", "").strip().lower(),
|
"username": form.get("username", "").strip(),
|
||||||
"password": form.get("password", ""),
|
"password": form.get("password", ""),
|
||||||
"language": form.get("language", ""),
|
"language": form.get("language", ""),
|
||||||
}
|
}
|
||||||
password2 = form.get("password2", "")
|
password2 = form.get("password2", "")
|
||||||
|
|
||||||
if not HOSTNAME_RE.match(values["hostname"]):
|
if not HOSTNAME_RE.match(values["hostname"]):
|
||||||
errors.append(
|
errors.append("Hostname must be lowercase letters, digits, hyphens (max 63 chars).")
|
||||||
"The server name can only use letters, digits and hyphens (no spaces, max 63)."
|
|
||||||
)
|
|
||||||
if not USERNAME_RE.match(values["username"]):
|
if not USERNAME_RE.match(values["username"]):
|
||||||
errors.append(
|
errors.append("Username must start with a letter or underscore, lowercase only.")
|
||||||
"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.")
|
|
||||||
if len(values["password"]) < 8:
|
if len(values["password"]) < 8:
|
||||||
errors.append("Password must be at least 8 characters.")
|
errors.append("Password must be at least 8 characters.")
|
||||||
if values["password"] != password2:
|
if values["password"] != password2:
|
||||||
|
|
@ -331,13 +228,6 @@ _FURTKA_UNITS = (
|
||||||
"furtka-status.service",
|
"furtka-status.service",
|
||||||
"furtka-status.timer",
|
"furtka-status.timer",
|
||||||
"furtka-welcome.service",
|
"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):
|
def _post_install_commands(hostname):
|
||||||
"""Write /var/lib/furtka/users.json with the admin account hashed.
|
|
||||||
|
|
||||||
The core furtka-api reads this file on every login attempt; the
|
|
||||||
auth.py module treats `admin.username` + `admin.hash` as the only
|
|
||||||
credential. Hashing happens here in the webinstaller (werkzeug is a
|
|
||||||
flask transitive dep so it's already installed in this environment)
|
|
||||||
— the chroot doesn't need pip. Mode 0600 so nobody but root on the
|
|
||||||
installed box can read the PBKDF2 hash.
|
|
||||||
"""
|
|
||||||
from datetime import datetime
|
|
||||||
|
|
||||||
from werkzeug.security import generate_password_hash
|
|
||||||
|
|
||||||
users = {
|
|
||||||
"admin": {
|
|
||||||
"username": username,
|
|
||||||
"hash": generate_password_hash(password),
|
|
||||||
"created_at": datetime.now(UTC).isoformat(timespec="seconds"),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return _write_file_cmd(
|
|
||||||
"/var/lib/furtka/users.json",
|
|
||||||
json.dumps(users, indent=2) + "\n",
|
|
||||||
mode="600",
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
def _post_install_commands(hostname, admin_username, admin_password):
|
|
||||||
# nss-mdns: splice `mdns_minimal [NOTFOUND=return]` before `resolve` on
|
# nss-mdns: splice `mdns_minimal [NOTFOUND=return]` before `resolve` on
|
||||||
# the hosts line so `*.local` works from the installed system too. Guarded
|
# 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
|
# 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
|
# an empty dir but not a missing one on every Caddy version, so we
|
||||||
# create it up front and stay on the safe side.
|
# create it up front and stay on the safe side.
|
||||||
"install -d -m 0755 -o root -g root /etc/caddy/furtka.d",
|
"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
|
# The Caddyfile lives at /etc/caddy/Caddyfile per Caddy's convention
|
||||||
# (systemd unit points there). Content comes from the shipped asset,
|
# (systemd unit points there). Content comes from the shipped asset,
|
||||||
# which we copy in at install time so updates that change routing
|
# which we copy in at install time so updates that change routing
|
||||||
# need a new release to refresh it.
|
# need a new release to refresh it.
|
||||||
#
|
_write_file_cmd("/etc/caddy/Caddyfile", _read_asset("Caddyfile")),
|
||||||
# __FURTKA_HOSTNAME__ is the placeholder the asset carries in place
|
|
||||||
# of the real hostname — Caddy's `tls internal` needs a named site
|
|
||||||
# block to issue a leaf cert, and the hostname isn't known until
|
|
||||||
# the user fills in the form. Self-updates re-apply the same
|
|
||||||
# substitution against /etc/hostname (see updater._refresh_caddyfile).
|
|
||||||
_write_file_cmd(
|
|
||||||
"/etc/caddy/Caddyfile",
|
|
||||||
_read_asset("Caddyfile").replace("__FURTKA_HOSTNAME__", hostname),
|
|
||||||
),
|
|
||||||
# Initial status.json so Caddy doesn't 404 before furtka-status fires.
|
# 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")),
|
_write_file_cmd("/var/lib/furtka/status.json", _read_asset("www/status.json")),
|
||||||
nss_sed,
|
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>/,
|
# Resource manager bootstrap: extract tarball → versions/<ver>/,
|
||||||
# symlink current, install wrapper, systemctl-link unit files.
|
# symlink current, install wrapper, systemctl-link unit files.
|
||||||
*_resource_manager_commands(),
|
*_resource_manager_commands(),
|
||||||
# furtka.json depends on /opt/furtka/current/VERSION, so it has to
|
# furtka.json depends on /opt/furtka/current/VERSION, so it has to
|
||||||
# run after the resource-manager commands.
|
# run after the resource-manager commands.
|
||||||
_furtka_json_cmd(hostname),
|
_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
|
# systemd-boot is UEFI-only; on BIOS/legacy it trips HardwareIncompatibilityError
|
||||||
# inside archinstall. /sys/firmware/efi exists iff we were booted via UEFI.
|
# inside archinstall. /sys/firmware/efi exists iff we were booted via UEFI.
|
||||||
if not efi_dir.exists():
|
return "Systemd-boot" if Path("/sys/firmware/efi").exists() else "Grub"
|
||||||
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"
|
|
||||||
|
|
||||||
|
|
||||||
def build_archinstall_config(s):
|
def build_archinstall_config(s):
|
||||||
|
|
@ -559,9 +378,6 @@ def build_archinstall_config(s):
|
||||||
"caddy",
|
"caddy",
|
||||||
"avahi",
|
"avahi",
|
||||||
"nss-mdns",
|
"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
|
# Resource manager runtime — pure-stdlib Python, no pip needed
|
||||||
# because we expose the package via PYTHONPATH in /usr/local/bin/furtka.
|
# because we expose the package via PYTHONPATH in /usr/local/bin/furtka.
|
||||||
"python",
|
"python",
|
||||||
|
|
@ -571,7 +387,7 @@ def build_archinstall_config(s):
|
||||||
"docker",
|
"docker",
|
||||||
# Base OS post-install services. Only packaged units go here —
|
# Base OS post-install services. Only packaged units go here —
|
||||||
# archinstall runs `systemctl enable` on this list *before*
|
# 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.
|
# units (written in custom_commands) are enabled there instead.
|
||||||
"caddy",
|
"caddy",
|
||||||
"avahi-daemon",
|
"avahi-daemon",
|
||||||
|
|
@ -583,7 +399,7 @@ def build_archinstall_config(s):
|
||||||
# page, status timer, and welcome banner into place.
|
# page, status timer, and welcome banner into place.
|
||||||
"custom_commands": [
|
"custom_commands": [
|
||||||
f"gpasswd -a {s['username']} docker",
|
f"gpasswd -a {s['username']} docker",
|
||||||
*_post_install_commands(s["hostname"], s["username"], s["password"]),
|
*_post_install_commands(s["hostname"]),
|
||||||
],
|
],
|
||||||
"network_config": {"type": "iso"},
|
"network_config": {"type": "iso"},
|
||||||
"ssh": True,
|
"ssh": True,
|
||||||
|
|
@ -627,9 +443,7 @@ def write_install_files(s, state_dir):
|
||||||
|
|
||||||
|
|
||||||
def spawn_archinstall(config_path, creds_path, log_path):
|
def spawn_archinstall(config_path, creds_path, log_path):
|
||||||
# Append: install_run() already truncated the log and may have written
|
log_fh = open(log_path, "wb")
|
||||||
# the disk-release lines we want to keep visible above archinstall's own.
|
|
||||||
log_fh = open(log_path, "ab")
|
|
||||||
return subprocess.Popen(
|
return subprocess.Popen(
|
||||||
[
|
[
|
||||||
"archinstall",
|
"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("/")
|
@app.route("/")
|
||||||
def home():
|
def home():
|
||||||
return redirect(url_for("install_step_1"))
|
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"])
|
@app.route("/install/step2", methods=["GET", "POST"])
|
||||||
def install_step_2():
|
def install_step_2():
|
||||||
drives = list_scored_devices()
|
|
||||||
error = ""
|
|
||||||
if request.method == "POST":
|
if request.method == "POST":
|
||||||
boot_drive = request.form.get("boot_drive", "").strip()
|
boot_drive = request.form.get("boot_drive", "").strip()
|
||||||
# Only accept a disk we actually offered — never a free-form path.
|
if boot_drive:
|
||||||
picked = next((d for d in drives if d["name"] == boot_drive and d["usable"]), None)
|
|
||||||
if picked:
|
|
||||||
settings["boot_drive"] = boot_drive
|
settings["boot_drive"] = boot_drive
|
||||||
_selected_drive.clear()
|
|
||||||
_selected_drive.update(picked)
|
|
||||||
return redirect(url_for("install_overview"))
|
return redirect(url_for("install_overview"))
|
||||||
error = "Please pick one of the disks below."
|
|
||||||
return render_template(
|
return render_template(
|
||||||
"install/step2.html",
|
"install/step2.html",
|
||||||
drives=drives,
|
drives=list_scored_devices(),
|
||||||
selected=settings.get("boot_drive", ""),
|
selected=settings.get("boot_drive", ""),
|
||||||
error=error,
|
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
@app.route("/install/overview")
|
@app.route("/install/overview")
|
||||||
def 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 ""}
|
masked = {**settings, "password": "•" * 8 if settings["password"] else ""}
|
||||||
drive = _selected_drive if _selected_drive.get("name") == settings["boot_drive"] else None
|
return render_template("install/overview.html", settings=masked)
|
||||||
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
|
|
||||||
|
|
||||||
|
|
||||||
@app.route("/install/run", methods=["POST"])
|
@app.route("/install/run", methods=["POST"])
|
||||||
def install_run():
|
def install_run():
|
||||||
if not settings["boot_drive"] or not settings["username"] or not settings["password"]:
|
if not settings["boot_drive"] or not settings["username"] or not settings["password"]:
|
||||||
return redirect(url_for("install_step_1"))
|
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)
|
config_path, creds_path = write_install_files(settings, STATE_DIR)
|
||||||
except Exception:
|
INSTALL_LOG.write_bytes(b"")
|
||||||
# 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"))
|
|
||||||
if os.environ.get("FURTKA_DRY_RUN") == "1":
|
if os.environ.get("FURTKA_DRY_RUN") == "1":
|
||||||
INSTALL_LOG.write_text(
|
INSTALL_LOG.write_text(
|
||||||
f"DRY RUN: would exec archinstall --config {config_path} "
|
f"DRY RUN: would exec archinstall --config {config_path} "
|
||||||
f"--creds {creds_path} --silent\n"
|
f"--creds {creds_path} --silent\n"
|
||||||
)
|
)
|
||||||
else:
|
else:
|
||||||
# Free the target disk first: udev auto-activates old LVM/RAID/swap
|
spawn_archinstall(config_path, creds_path, INSTALL_LOG)
|
||||||
# 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)
|
|
||||||
return redirect(url_for("install_log_view"))
|
return redirect(url_for("install_log_view"))
|
||||||
|
|
||||||
|
|
||||||
@app.route("/install/log")
|
@app.route("/install/log")
|
||||||
def install_log_view():
|
def install_log_view():
|
||||||
log, code = _read_install_log()
|
log = INSTALL_LOG.read_text() if INSTALL_LOG.exists() else ""
|
||||||
return render_template(
|
return render_template(
|
||||||
"install/log.html",
|
"install/log.html",
|
||||||
log=log,
|
log=log,
|
||||||
progress=parse_install_progress(log, code),
|
progress=parse_install_progress(log),
|
||||||
hostname=settings["hostname"],
|
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
@app.route("/install/log.json")
|
@app.route("/install/log.json")
|
||||||
def install_log_json():
|
def install_log_json():
|
||||||
log, code = _read_install_log()
|
log = INSTALL_LOG.read_text() if INSTALL_LOG.exists() else ""
|
||||||
return jsonify(log=log, progress=parse_install_progress(log, code))
|
return jsonify(log=log, progress=parse_install_progress(log))
|
||||||
|
|
||||||
|
|
||||||
@app.route("/install/reboot", methods=["POST"])
|
@app.route("/install/reboot", methods=["POST"])
|
||||||
def install_reboot():
|
def install_reboot():
|
||||||
# Only allow rebooting once the install has actually finished — we don't
|
# Only allow rebooting once the install has actually finished — we don't
|
||||||
# want a panicked click during install to reboot mid-pacstrap.
|
# want a panicked click during install to reboot mid-pacstrap.
|
||||||
log, code = _read_install_log()
|
log = INSTALL_LOG.read_text() if INSTALL_LOG.exists() else ""
|
||||||
if parse_install_progress(log, code)["status"] != "done":
|
if parse_install_progress(log)["status"] != "done":
|
||||||
return redirect(url_for("install_log_view"))
|
return redirect(url_for("install_log_view"))
|
||||||
# Delay reboot a few seconds so the browser can finish fetching CSS / assets
|
# 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.
|
# for the rebooting page before the Flask server (and network) go away.
|
||||||
|
|
|
||||||
|
|
@ -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
|
|
||||||
|
|
@ -1,90 +1,21 @@
|
||||||
import json
|
|
||||||
import re
|
|
||||||
import subprocess
|
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):
|
def _smart_status(device):
|
||||||
result = _run(["smartctl", "-H", device])
|
try:
|
||||||
if result is None:
|
result = subprocess.run(
|
||||||
return "unknown"
|
["smartctl", "-H", device],
|
||||||
if "PASSED" in result.stdout:
|
capture_output=True,
|
||||||
|
)
|
||||||
|
output = result.stdout.decode()
|
||||||
|
if "PASSED" in output:
|
||||||
return "passed"
|
return "passed"
|
||||||
if "FAILED" in result.stdout:
|
elif "FAILED" in output:
|
||||||
return "failed"
|
return "failed"
|
||||||
return "unknown"
|
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}
|
_HEALTH_SCORE = {"passed": 10, "failed": 0, "unknown": 5}
|
||||||
|
|
@ -94,33 +25,40 @@ _HEALTH_LABEL = {
|
||||||
"unknown": "Status unknown",
|
"unknown": "Status unknown",
|
||||||
}
|
}
|
||||||
|
|
||||||
_TYPE_SCORE = {"NVMe": 15, "SSD": 10, "eMMC": 8, "HDD": 5, "USB": 0}
|
|
||||||
|
|
||||||
|
|
||||||
def get_drive_health(device):
|
def get_drive_health(device):
|
||||||
return _HEALTH_SCORE[_smart_status(device)]
|
return _HEALTH_SCORE[_smart_status(device)]
|
||||||
|
|
||||||
|
|
||||||
def classify_drive(name, tran="", rota=None, removable=False):
|
def get_drive_type_score(device):
|
||||||
"""Human label for a disk: NVMe, SSD, eMMC, HDD or USB.
|
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).
|
def get_drive_type_label(device):
|
||||||
"""
|
name = device.lower()
|
||||||
tran = (tran or "").lower()
|
if "nvme" in name:
|
||||||
if tran == "usb" or (removable and not name.startswith("mmcblk")):
|
|
||||||
return "USB"
|
|
||||||
if tran == "nvme" or name.startswith("nvme"):
|
|
||||||
return "NVMe"
|
return "NVMe"
|
||||||
if tran == "mmc" or name.startswith("mmcblk"):
|
if "ssd" in name:
|
||||||
return "eMMC"
|
|
||||||
if rota is False:
|
|
||||||
return "SSD"
|
return "SSD"
|
||||||
return "HDD"
|
return "HDD"
|
||||||
|
|
||||||
|
|
||||||
def get_drive_type_score(type_label):
|
def parse_size_gb(size_str):
|
||||||
return _TYPE_SCORE.get(type_label, 5)
|
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):
|
def get_size_score(size_gb):
|
||||||
|
|
@ -133,98 +71,56 @@ def get_size_score(size_gb):
|
||||||
return 10
|
return 10
|
||||||
|
|
||||||
|
|
||||||
def format_size(size_bytes):
|
def score_device(device, size_gb):
|
||||||
gb = size_bytes / 1024**3
|
return get_drive_type_score(device) + get_drive_health(device) + get_size_score(size_gb)
|
||||||
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 _flag(value):
|
def parse_lsblk_output(output):
|
||||||
# util-linux ≥2.37 emits JSON booleans; older builds emit "0"/"1".
|
"""Parse `lsblk -dn -o NAME,SIZE,TYPE` output into scored device dicts.
|
||||||
if isinstance(value, bool):
|
|
||||||
return value
|
|
||||||
return str(value).strip() in ("1", "true")
|
|
||||||
|
|
||||||
|
Keeps only TYPE=disk so the live ISO's own squashfs (loop) and the boot
|
||||||
def parse_lsblk_json(output, boot_disk=None, uefi=True):
|
CD-ROM (rom) don't show up as install targets.
|
||||||
"""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.
|
|
||||||
"""
|
"""
|
||||||
try:
|
|
||||||
entries = json.loads(output or "{}").get("blockdevices", [])
|
|
||||||
except json.JSONDecodeError:
|
|
||||||
return []
|
|
||||||
devices = []
|
devices = []
|
||||||
for e in entries:
|
for line in output.strip().split("\n"):
|
||||||
name = e.get("name") or ""
|
if not line:
|
||||||
if e.get("type") != "disk" or not name:
|
|
||||||
continue
|
continue
|
||||||
if _PSEUDO_DISK_RE.match(name) or (boot_disk and name == boot_disk):
|
parts = line.split()
|
||||||
|
if len(parts) < 3:
|
||||||
continue
|
continue
|
||||||
try:
|
name, size, dev_type = parts[0], parts[1], parts[2]
|
||||||
size_bytes = int(e.get("size") or 0)
|
if dev_type != "disk":
|
||||||
except (TypeError, ValueError):
|
|
||||||
size_bytes = 0
|
|
||||||
if size_bytes <= 0 or _flag(e.get("ro", False)):
|
|
||||||
continue
|
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}"
|
device = f"/dev/{name}"
|
||||||
usable = size_gb >= MIN_DISK_GB
|
size_gb = parse_size_gb(size)
|
||||||
status = _smart_status(device) if usable else "unknown"
|
status = _smart_status(device)
|
||||||
warning = ""
|
score = get_drive_type_score(device) + _HEALTH_SCORE[status] + get_size_score(size_gb)
|
||||||
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."
|
|
||||||
)
|
|
||||||
devices.append(
|
devices.append(
|
||||||
{
|
{
|
||||||
"name": device,
|
"name": device,
|
||||||
"size": format_size(size_bytes),
|
"size": size,
|
||||||
"size_gb": size_gb,
|
"type_label": get_drive_type_label(device),
|
||||||
"type_label": type_label,
|
|
||||||
"health_label": _HEALTH_LABEL[status],
|
"health_label": _HEALTH_LABEL[status],
|
||||||
"score": get_drive_type_score(type_label)
|
"score": score,
|
||||||
+ _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,
|
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
devices.sort(key=lambda d: (d["usable"], d["score"]), reverse=True)
|
devices.sort(key=lambda d: d["score"], reverse=True)
|
||||||
return devices
|
return devices
|
||||||
|
|
||||||
|
|
||||||
def list_scored_devices():
|
def list_scored_devices():
|
||||||
"""Return [{name, size, score, usable, ...}, ...] for all physical disks, best first."""
|
"""Return [{name, size, score}, ...] for all physical disks, highest score first."""
|
||||||
result = _run(["lsblk", "-J", "-d", "-b", "-o", "NAME,SIZE,TYPE,RO,RM,TRAN,ROTA"])
|
try:
|
||||||
if result is None or result.returncode != 0:
|
result = subprocess.run(
|
||||||
print(f"Error listing devices: {result.stderr if result else 'lsblk unavailable'}")
|
["lsblk", "-dn", "-o", "NAME,SIZE,TYPE"],
|
||||||
return []
|
capture_output=True,
|
||||||
return parse_lsblk_json(
|
text=True,
|
||||||
result.stdout,
|
check=True,
|
||||||
boot_disk=_boot_disk_name(),
|
|
||||||
uefi=Path("/sys/firmware/efi").exists(),
|
|
||||||
)
|
)
|
||||||
|
except subprocess.CalledProcessError as e:
|
||||||
|
print(f"Error listing devices: {e}")
|
||||||
|
return []
|
||||||
|
return parse_lsblk_output(result.stdout)
|
||||||
|
|
||||||
|
|
||||||
def main():
|
def main():
|
||||||
|
|
@ -232,11 +128,11 @@ def main():
|
||||||
if not devices:
|
if not devices:
|
||||||
print("No storage devices found.")
|
print("No storage devices found.")
|
||||||
return
|
return
|
||||||
print(f"\n{'Device':<20} {'Size':<10} {'Type':<6} {'Score'}")
|
print(f"\n{'Device':<20} {'Size':<10} {'Score'}")
|
||||||
print("-" * 46)
|
print("-" * 40)
|
||||||
for d in devices:
|
for d in devices:
|
||||||
note = "" if d["usable"] else f" ({d['reason']})"
|
print(f"{d['name']:<20} {d['size']:<10} {d['score']}")
|
||||||
print(f"{d['name']:<20} {d['size']:<10} {d['type_label']:<6} {d['score']}{note}")
|
print(f"\nBest drive for boot: {devices[0]['name']} (score {devices[0]['score']})")
|
||||||
|
|
||||||
|
|
||||||
if __name__ == "__main__":
|
if __name__ == "__main__":
|
||||||
|
|
|
||||||
|
|
@ -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 |
|
|
@ -190,17 +190,9 @@ select:focus {
|
||||||
background: var(--bg-card);
|
background: var(--bg-card);
|
||||||
border: 1px solid var(--border);
|
border: 1px solid var(--border);
|
||||||
border-radius: 8px;
|
border-radius: 8px;
|
||||||
flex-wrap: wrap;
|
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
transition: border-color 120ms, background 120ms;
|
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:hover { border-color: color-mix(in srgb, var(--accent) 50%, var(--border)); }
|
||||||
.drive input[type="radio"] {
|
.drive input[type="radio"] {
|
||||||
accent-color: var(--accent);
|
accent-color: var(--accent);
|
||||||
|
|
@ -429,11 +421,3 @@ select:focus {
|
||||||
outline-offset: 3px;
|
outline-offset: 3px;
|
||||||
border-radius: 2px;
|
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; }
|
|
||||||
|
|
|
||||||
|
|
@ -4,7 +4,6 @@
|
||||||
<meta charset="utf-8">
|
<meta charset="utf-8">
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||||
<title>{% block title %}Furtka Installer{% endblock %}</title>
|
<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') }}">
|
<link rel="stylesheet" href="{{ url_for('static', filename='style.css') }}">
|
||||||
{% block head_extra %}{% endblock %}
|
{% block head_extra %}{% endblock %}
|
||||||
</head>
|
</head>
|
||||||
|
|
@ -12,8 +11,12 @@
|
||||||
<header class="site-header">
|
<header class="site-header">
|
||||||
<div class="container">
|
<div class="container">
|
||||||
<a href="{{ url_for('install_step_1') }}" class="site-title" aria-label="Furtka Installer — restart">
|
<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">
|
<svg class="gate-mark" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor"
|
||||||
<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"/>
|
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>
|
</svg>
|
||||||
<span class="wordmark">Furtka<span class="sep">·</span>Installer</span>
|
<span class="wordmark">Furtka<span class="sep">·</span>Installer</span>
|
||||||
</a>
|
</a>
|
||||||
|
|
@ -27,7 +30,7 @@
|
||||||
|
|
||||||
<footer class="site-footer">
|
<footer class="site-footer">
|
||||||
<div class="container">
|
<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>
|
<p class="kicker"><a href="https://furtka.org" style="color: inherit; text-decoration: none">furtka.org</a></p>
|
||||||
</div>
|
</div>
|
||||||
</footer>
|
</footer>
|
||||||
|
|
|
||||||
|
|
@ -17,38 +17,16 @@
|
||||||
{% else %}Installing Furtka{% endif %}
|
{% else %}Installing Furtka{% endif %}
|
||||||
</h1>
|
</h1>
|
||||||
<p class="lede" id="install-lede">
|
<p class="lede" id="install-lede">
|
||||||
{% if progress.status == "done" %}Installation finished. Two more steps and Furtka is running from its own disk.
|
{% if progress.status == "done" %}Installation finished. <strong>Remove the installer USB / eject the ISO</strong>, then click Reboot.
|
||||||
{% elif progress.status == "error" %}{{ progress.phase }}.
|
{% elif progress.status == "error" %}Something went wrong. Open the details below and share them so we can help.
|
||||||
{% 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 %}
|
{% else %}This takes a few minutes. Don't close this page or power off the machine.{% endif %}
|
||||||
</p>
|
</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" %}
|
{% 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') }}"
|
<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">
|
<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>
|
</div>
|
||||||
</form>
|
</form>
|
||||||
{% endif %}
|
{% 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 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 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>
|
</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">
|
<details class="log-details">
|
||||||
<summary>Show details</summary>
|
<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 phaseEl = document.getElementById('progress-phase');
|
||||||
var percentEl = document.getElementById('progress-percent');
|
var percentEl = document.getElementById('progress-percent');
|
||||||
var logEl = document.getElementById('install-log');
|
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() {
|
function tick() {
|
||||||
fetch('{{ url_for("install_log_json") }}', { cache: 'no-store' })
|
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) {
|
.then(function (data) {
|
||||||
var p = data.progress;
|
var p = data.progress;
|
||||||
bar.style.width = p.percent + '%';
|
bar.style.width = p.percent + '%';
|
||||||
bar.parentNode.setAttribute('aria-valuenow', p.percent);
|
|
||||||
phaseEl.textContent = p.phase;
|
phaseEl.textContent = p.phase;
|
||||||
percentEl.textContent = p.percent;
|
percentEl.textContent = p.percent;
|
||||||
if (logEl.textContent !== data.log) {
|
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)';
|
logEl.textContent = data.log || '(waiting for install to start)';
|
||||||
if (atBottom) logEl.scrollTop = logEl.scrollHeight;
|
if (atBottom) logEl.scrollTop = logEl.scrollHeight;
|
||||||
}
|
}
|
||||||
if (p.status === 'done' || p.status === 'error') {
|
if (p.status === 'done') {
|
||||||
// Reload so the server-rendered Done / error state (with
|
// Reload so the server-rendered Done state (with the
|
||||||
// its Reboot or Try-again buttons) replaces the
|
// Reboot button) replaces the running-state markup.
|
||||||
// running-state markup.
|
|
||||||
window.location.reload();
|
window.location.reload();
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
failures = 0;
|
if (p.status === 'error') {
|
||||||
setTimeout(tick, 3000);
|
bar.classList.add('progress-bar-error');
|
||||||
})
|
headingEl.textContent = HEADINGS.error;
|
||||||
.catch(function () {
|
ledeEl.textContent = LEDES.error;
|
||||||
failures += 1;
|
return;
|
||||||
if (failures >= 2) {
|
|
||||||
phaseEl.textContent = 'Lost connection to the installer — retrying…';
|
|
||||||
}
|
}
|
||||||
setTimeout(tick, 3000);
|
setTimeout(tick, 3000);
|
||||||
});
|
})
|
||||||
|
.catch(function () { setTimeout(tick, 3000); });
|
||||||
}
|
}
|
||||||
setTimeout(tick, 3000);
|
setTimeout(tick, 3000);
|
||||||
})();
|
})();
|
||||||
|
|
|
||||||
|
|
@ -5,27 +5,27 @@
|
||||||
|
|
||||||
{% block content %}
|
{% block content %}
|
||||||
<h1>Confirm install</h1>
|
<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">
|
<div class="alert alert-warn">
|
||||||
Everything currently on this disk will be deleted and can't be recovered.
|
This is destructive. All existing data on the selected boot drive will be lost.
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="summary">
|
<div class="summary">
|
||||||
<table>
|
<table>
|
||||||
<tr><td>Server name</td><td>{{ settings.hostname }} <span class="hint">(http://{{ settings.hostname }}.local)</span></td></tr>
|
{% for k, v in settings.items() %}
|
||||||
<tr><td>Username</td><td>{{ settings.username }}</td></tr>
|
<tr>
|
||||||
<tr><td>Password</td><td>{{ settings.password }}</td></tr>
|
<td>{{ k.replace('_', ' ') }}</td>
|
||||||
<tr><td>Language</td><td>{{ language_label }}</td></tr>
|
<td>{{ v }}</td>
|
||||||
<tr><td>Disk</td><td>{{ settings.boot_drive }}{% if drive %} — {{ drive.size }} {{ drive.type_label }}{% endif %}</td></tr>
|
</tr>
|
||||||
|
{% endfor %}
|
||||||
</table>
|
</table>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<form method="post" action="{{ url_for('install_run') }}"
|
<form method="post" action="{{ url_for('install_run') }}">
|
||||||
onsubmit="this.querySelector('button').disabled = true; this.querySelector('button').textContent = 'Starting…';">
|
|
||||||
<div class="actions">
|
<div class="actions">
|
||||||
<button type="submit" class="btn btn-danger">Erase disk and install</button>
|
<button type="submit" class="btn btn-danger">Install — wipe drive and proceed</button>
|
||||||
<a href="{{ url_for('install_step_2') }}" class="btn btn-link">← Back</a>
|
<a href="{{ url_for('install_step_1') }}" class="btn btn-link">← Start over</a>
|
||||||
</div>
|
</div>
|
||||||
</form>
|
</form>
|
||||||
{% endblock %}
|
{% endblock %}
|
||||||
|
|
|
||||||
|
|
@ -6,11 +6,7 @@
|
||||||
{% block content %}
|
{% block content %}
|
||||||
<h1>Rebooting…</h1>
|
<h1>Rebooting…</h1>
|
||||||
<p class="lede">The machine is restarting. This page will stop responding in a moment — that's expected.</p>
|
<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>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><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),
|
<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>
|
||||||
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>
|
|
||||||
{% endblock %}
|
{% endblock %}
|
||||||
|
|
|
||||||
|
|
@ -4,11 +4,11 @@
|
||||||
{% block step_indicator %}<span class="step-indicator">Step 1 of 3</span>{% endblock %}
|
{% block step_indicator %}<span class="step-indicator">Step 1 of 3</span>{% endblock %}
|
||||||
|
|
||||||
{% block content %}
|
{% block content %}
|
||||||
<h1>Name & account</h1>
|
<h1>Account & hostname</h1>
|
||||||
<p class="lede">Set the server name and your administrator account.</p>
|
<p class="lede">Set the server name and your administrator account.</p>
|
||||||
|
|
||||||
{% if errors %}
|
{% if errors %}
|
||||||
<div class="alert alert-error" role="alert">
|
<div class="alert alert-error">
|
||||||
<strong>Please fix the following:</strong>
|
<strong>Please fix the following:</strong>
|
||||||
<ul>
|
<ul>
|
||||||
{% for e in errors %}<li>{{ e }}</li>{% endfor %}
|
{% for e in errors %}<li>{{ e }}</li>{% endfor %}
|
||||||
|
|
@ -18,19 +18,19 @@
|
||||||
|
|
||||||
<form method="post" action="{{ url_for('install_step_1') }}">
|
<form method="post" action="{{ url_for('install_step_1') }}">
|
||||||
<div class="field">
|
<div class="field">
|
||||||
<label for="hostname">Server name</label>
|
<label for="hostname">Hostname</label>
|
||||||
<input type="text" id="hostname" name="hostname" required value="{{ values.hostname }}" autocomplete="off" autocapitalize="off" spellcheck="false" aria-describedby="hostname-hint" />
|
<input type="text" id="hostname" name="hostname" required value="{{ values.hostname }}" autocomplete="off" />
|
||||||
<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>
|
<span class="hint">The name your server shows up as on your home network. Lowercase letters, numbers, and dashes only.</span>
|
||||||
</div>
|
</div>
|
||||||
<div class="field">
|
<div class="field">
|
||||||
<label for="username">Admin username</label>
|
<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" />
|
<input type="text" id="username" name="username" required value="{{ values.username }}" autocomplete="username" />
|
||||||
<span class="hint" id="username-hint">Your login name on the server. You'll use this to sign in after installation.</span>
|
<span class="hint">Your login name on the server. You'll use this to sign in after installation.</span>
|
||||||
</div>
|
</div>
|
||||||
<div class="field">
|
<div class="field">
|
||||||
<label for="password">Password</label>
|
<label for="password">Password</label>
|
||||||
<input type="password" id="password" name="password" required minlength="8" autocomplete="new-password" aria-describedby="password-hint" />
|
<input type="password" id="password" name="password" required autocomplete="new-password" />
|
||||||
<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>
|
<span class="hint">At least 8 characters.</span>
|
||||||
</div>
|
</div>
|
||||||
<div class="field">
|
<div class="field">
|
||||||
<label for="password2">Confirm password</label>
|
<label for="password2">Confirm password</label>
|
||||||
|
|
@ -38,25 +38,15 @@
|
||||||
</div>
|
</div>
|
||||||
<div class="field">
|
<div class="field">
|
||||||
<label for="language">Language</label>
|
<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() %}
|
{% for code, lang in languages.items() %}
|
||||||
<option value="{{ code }}" {% if code == values.language %}selected{% endif %}>{{ lang.label }}</option>
|
<option value="{{ code }}" {% if code == values.language %}selected{% endif %}>{{ lang.label }}</option>
|
||||||
{% endfor %}
|
{% endfor %}
|
||||||
</select>
|
</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>
|
||||||
|
|
||||||
<div class="actions">
|
<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>
|
</div>
|
||||||
</form>
|
</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 %}
|
{% endblock %}
|
||||||
|
|
|
||||||
|
|
@ -1,53 +1,39 @@
|
||||||
{% extends "base.html" %}
|
{% 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 step_indicator %}<span class="step-indicator">Step 2 of 3</span>{% endblock %}
|
||||||
|
|
||||||
{% block content %}
|
{% block content %}
|
||||||
<h1>Pick a disk</h1>
|
<h1>Boot drive</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>
|
<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 %}
|
{% if not drives %}
|
||||||
{% set pick = selected if (selected and usable | selectattr("name", "equalto", selected) | list) else (usable[0].name if usable else "") %}
|
<div class="alert alert-error">
|
||||||
|
No physical disks detected. The live ISO can't see any install targets — check your BIOS / SATA controller.
|
||||||
{% 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 %}
|
|
||||||
</div>
|
</div>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
|
|
||||||
<form method="post" action="{{ url_for('install_step_2') }}">
|
<form method="post" action="{{ url_for('install_step_2') }}">
|
||||||
<div class="drive-list">
|
<div class="drive-list">
|
||||||
{% for d in drives %}
|
{% 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 }}"
|
<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>
|
<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>
|
<span class="badge-recommended">Recommended</span>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
<span class="meta">
|
<span class="meta">
|
||||||
<span class="chip">{{ d.size }}</span>
|
<span class="chip">{{ d.size }}</span>
|
||||||
<span class="chip">{{ d.type_label }}</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>
|
</span>
|
||||||
{% if d.reason %}<span class="drive-note">{{ d.reason }}</span>{% endif %}
|
|
||||||
{% if d.warning %}<span class="drive-note">{{ d.warning }}</span>{% endif %}
|
|
||||||
</label>
|
</label>
|
||||||
{% endfor %}
|
{% endfor %}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="actions">
|
<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>
|
<a href="{{ url_for('install_step_1') }}" class="btn btn-link">← Back</a>
|
||||||
</div>
|
</div>
|
||||||
</form>
|
</form>
|
||||||
|
|
|
||||||
|
|
@ -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
|
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.
|
an upstream openresty reverse proxy — the VM itself only speaks plain HTTP.
|
||||||
|
|
||||||
### Auto-deploy on push-to-main (default)
|
First time only, on the VM:
|
||||||
|
|
||||||
`.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:
|
|
||||||
|
|
||||||
```sh
|
```sh
|
||||||
ssh forge-runner
|
ssh forge-runner
|
||||||
sudo /srv/furtka-site/ops/nginx/setup-vm.sh # or copy the script over first
|
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
|
## Structure
|
||||||
|
|
||||||
```
|
```
|
||||||
|
|
@ -67,21 +42,13 @@ hugo.toml Hugo config (multilingual: en default, de)
|
||||||
content/ Markdown pages
|
content/ Markdown pages
|
||||||
_index.md Home (EN)
|
_index.md Home (EN)
|
||||||
_index.de.md Home (DE)
|
_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
|
layouts/ Custom inline theme — no external theme or framework
|
||||||
_default/ baseof, single, list
|
_default/ baseof, single, list
|
||||||
partials/ head, header, footer, gate SVG, lang switcher, mark-m / mark-r (poll)
|
partials/ head, header, footer, gate SVG, lang switcher
|
||||||
poll/single.html Logo poll page; strings come from the content front matter
|
|
||||||
index.html Home-only layout with editorial hero
|
index.html Home-only layout with editorial hero
|
||||||
assets/css/main.css Stylesheet (fingerprinted + minified on build)
|
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
|
static/favicon.svg Gate mark in crimson
|
||||||
deploy.sh Manual rsync + remote Hugo build (over SSH, for off-CI pushes)
|
deploy.sh Rsync + remote Hugo build
|
||||||
deploy-ci.sh Local rsync + Hugo build — runs on forge-runner-01 from CI
|
|
||||||
```
|
```
|
||||||
|
|
||||||
## Design
|
## Design
|
||||||
|
|
|
||||||
|
|
@ -6,10 +6,6 @@
|
||||||
--accent: #c03a28;
|
--accent: #c03a28;
|
||||||
--accent-hover: #a0301f;
|
--accent-hover: #a0301f;
|
||||||
--border: #e4e3dc;
|
--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:
|
--font-sans:
|
||||||
-apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "Helvetica Neue",
|
-apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "Helvetica Neue",
|
||||||
Arial, "Noto Sans", sans-serif;
|
Arial, "Noto Sans", sans-serif;
|
||||||
|
|
@ -27,10 +23,6 @@
|
||||||
--accent: #ff6b56;
|
--accent: #ff6b56;
|
||||||
--accent-hover: #ff8b78;
|
--accent-hover: #ff8b78;
|
||||||
--border: #232326;
|
--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;
|
flex-direction: column;
|
||||||
min-height: 100vh;
|
min-height: 100vh;
|
||||||
text-rendering: optimizeLegibility;
|
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 {
|
.container {
|
||||||
|
|
@ -198,36 +171,11 @@ main.container {
|
||||||
.home h1 {
|
.home h1 {
|
||||||
font-family: var(--font-sans);
|
font-family: var(--font-sans);
|
||||||
font-weight: 800;
|
font-weight: 800;
|
||||||
font-size: clamp(3.5rem, 14vw, 11rem);
|
font-size: clamp(3.25rem, 10vw, 6.5rem);
|
||||||
line-height: 0.9;
|
line-height: 0.95;
|
||||||
letter-spacing: -0.04em;
|
letter-spacing: -0.035em;
|
||||||
margin: 0 0 1.5rem;
|
margin: 0 0 1.5rem;
|
||||||
color: var(--fg);
|
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 {
|
.home .lede {
|
||||||
|
|
@ -310,479 +258,3 @@ main.container {
|
||||||
outline-offset: 3px;
|
outline-offset: 3px;
|
||||||
border-radius: 2px;
|
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; } }
|
|
||||||
|
|
|
||||||
|
|
@ -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
|
|
||||||
})
|
|
||||||
});
|
|
||||||
})();
|
|
||||||
|
|
@ -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();
|
|
||||||
})();
|
|
||||||
|
|
@ -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);
|
|
||||||
}
|
|
||||||
})();
|
|
||||||
19
website/assets/js/vendor/PROVENANCE.md
vendored
|
|
@ -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.
|
|
||||||