Ships the new path-type setting (the schema extension that unlocks
host bind mounts for Jellyfin / Paperless / Nextcloud / Immich-class
apps), server-side path validation, app-author docs for the new type,
and the remove-USB-stick hint on the installer's reboot screen.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Adds a bold "Remove the USB stick now" line before the reboot, plus a
muted fallback paragraph pointing at the BIOS one-time boot menu keys
(F11/F12/Esc) for when removal isn't enough. Caught on the 2026-04-21
Medion bare-metal test: the box didn't boot the installed system on
first reboot and required manual BIOS boot-order changes, which
non-technical users won't know how to do.
Template-only change. No new CSS, no new code paths — <kbd> uses
browser defaults, <strong> keeps the hierarchy readable.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Covers the path-type declaration in manifest.json, the companion
compose bind-mount pattern (${MEDIA_PATH}:/media:ro), and the full
server-side validation rules the installer applies (absolute, exists,
is-directory, resolve-then-deny-list, traversal caught).
Clarifies the mental split between manifest.volumes (internal state
the app owns) and path settings (user data the container mounts and
usually reads without owning), and recommends :ro as the default for
consumer-only mounts.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Apps can now declare a setting with "type": "path" whose value is an
absolute host filesystem path. Compose bind-mounts it via standard .env
substitution (${MEDIA_PATH}:/media) — no reconciler changes needed.
Unlocks media/data-heavy apps (Jellyfin, later Paperless, Nextcloud,
Immich) that point at existing user data instead of copying it into a
Docker volume.
Install/update refuses values that aren't absolute, don't exist, aren't
directories, or resolve into a system-path deny-list (/, /etc, /root,
/boot, /proc, /sys, /dev, /bin, /sbin, /usr/bin, /usr/sbin,
/var/lib/furtka). Path.resolve() is applied before the deny-list check
so /mnt/../etc traversal is caught too. Error messages surface in the
existing install/edit modal.
UI: path settings render as a text input with a /mnt/… placeholder.
The manifest's `description` field carries the actual hint ("Absoluter
Pfad zu deinem Filme-Ordner, z.B. /mnt/media"). No new form
components, no new API routes.
Tests: 9 new cases for install + update path validation; 1 new case
for manifest schema accepting the path type. 211 total passing.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Three small fixes surfaced by the 26.8 QA pass on fresh VM .161:
- Landing-page app tiles now open external `open_url` links in a new
tab, matching /apps Open-button behaviour. Without this a Kuma click
on the home screen replaced Furtka itself.
- `scripts/publish-release.sh` treats the ISO upload as best-effort;
a Forgejo-proxy 504 no longer kills the whole release after tarball
+ sha + release.json are already uploaded.
- `furtka app list --json` now mirrors /api/apps — includes
`description_long`, `open_url`, and `settings` that the previous
slim projection dropped.
Adds Reboot + Shut down buttons on /settings, backed by a new
POST /api/furtka/power endpoint that kicks a delayed `systemd-run
--on-active=3s systemctl {reboot|poweroff}` so the HTTP response
flushes before the kernel loses network. Both buttons open a native
confirm dialog; after reboot, the page polls /furtka.json until the
box is back and reloads itself.
26.7-alpha was tagged on 5d8ac63 but release.yml never fired for that
tag (Forgejo race with the concurrent main push; re-push of the deleted
tag didn't wake the workflow either). 26.8 supersedes it and carries
the same open_url + Open-button content plus the power actions.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Ships the open_url manifest field + the Open button in /apps and on
the landing page, replacing the fileshare-only hardcoded deep-link
with a generalised {host}-templated URL. Fileshare seed manifest
bumps to 0.1.2; the furtka-apps catalog release that goes with this
adds matching open_url values for fileshare + uptime-kuma.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Rolls the apps-catalog split, the /settings CSS wrap fix, and the version
bump to 26.6-alpha across pyproject + website copy. Core release tarball
still carries apps/fileshare as the offline first-boot seed; the new
daniel/furtka-apps catalog (tagged 26.6-alpha today) is the authoritative
source on boxes that have synced at least once.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
New `furtka catalog sync` pulls the latest daniel/furtka-apps release,
verifies its sha256, extracts under /var/lib/furtka/catalog/, and
atomically swaps into place — so apps can ship without cutting a new
Furtka core release. A daily timer (furtka-catalog-sync.timer, 10 min
post-boot + 24 h with ±6 h jitter) drives the sync; /apps gets a
manual "Sync apps catalog" button that kicks the same code path via a
detached systemd-run unit.
Layout of the new on-box tree:
/var/lib/furtka/catalog/ synced catalog (survives self-updates)
├── VERSION
└── apps/<name>/ ...
/var/lib/furtka/catalog-state.json sync stage + last version, UI-polled
/run/furtka/catalog.lock flock so timer + manual click can't race
Resolver precedence (furtka/sources.py): catalog wins over the bundled
seed (/opt/furtka/current/apps/, carried by the core release for offline
first-boot). Installed apps under /var/lib/furtka/apps/ are never auto-
swapped — user clicks Reinstall to move an existing install onto a
newer catalog version; settings merge-preserved via the existing
installer.install_from path.
New files:
- furtka/_release_common.py — shared Forgejo/tarball primitives lifted
from furtka/updater.py. Both modules now import from here; updater's
behaviour and public API unchanged.
- furtka/catalog.py — check_catalog(), sync_catalog() with staging +
manifest validation + atomic rename. Refuses bad sha256 / broken
manifests and leaves the live catalog intact on any failure path.
- furtka/sources.py — resolve_app_name() / list_available() abstraction
used by installer.resolve_source and api._list_available.
- assets/systemd/furtka-catalog-sync.{service,timer} — oneshot service
+ daily timer. Timer auto-enables on self-update via a one-line
addition to _link_new_units (fresh installs get enabled via the
webinstaller's _FURTKA_UNITS list).
API + UI:
- /api/bundled renamed internally to _list_available; endpoint stays as
a backcompat alias; /api/apps/available is the new canonical name.
Each list entry carries a `source` field ("catalog" | "bundled").
- POST /api/catalog/sync/check + /apply + GET /api/catalog/status.
- /apps page grows a catalog-status row + Sync button; poll loop
mirrors the Furtka self-update flow.
CLI: `furtka catalog sync [--check]` + `furtka catalog status` (both
support --json). Old `furtka app install` / `reconcile` / `update` /
`rollback` surfaces are unchanged.
Test gate: 194/170 baseline + 24 new tests covering catalog sync
(happy path, sha256 mismatch, invalid manifest, lock contention,
preserves-on-failure) + resolver precedence + api renames. ruff
check + format clean.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
The /settings "CA fingerprint (SHA-256)" value is a 95-char colon-
separated hex string with no whitespace, so CSS had no valid break
points and the value pushed past the card's right edge — visible on
the 192.168.178.23 fresh-install test.
.kv is a two-column grid (max-content 1fr); grid items default to
min-width: auto (= content width), which overrides the 1fr track's
width constraint. min-width: 0 lets the track shrink, and
overflow-wrap: anywhere gives the fingerprint valid break points at
any character. The styling stays scoped to .kv dd so card prose isn't
affected.
Verified live on .23 via hot-patch into /opt/furtka/current/assets/
www/style.css + caddy reload.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Format-only diff from `ruff format`. The 26.5-alpha push's CI run failed
on `ruff format --check`; these three files had two-line constructs that
fit on one line at ruff's default line length. No behaviour change.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Rolls the HTTPS handshake fix (#10) and the README realignment into a
tagged release. Also closes the 26.4 follow-up that the wizard footer
version was hand-pinned: webinstaller/app.py now resolves the version
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 "dev"). pyproject.toml and
the website version strings bumped in the same commit so every surface
reports 26.5-alpha consistently.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Closes#10. Two linked bugs in 26.4-alpha's Phase 1 HTTPS made the
force-HTTPS toggle fatal: every SNI handshake on :443 died with
SSL_ERROR_INTERNAL_ERROR_ALERT, so the toggle redirected users from
working HTTP to broken HTTPS.
Root cause 1: bare `:443 { tls internal }` gives Caddy no hostname to
issue a leaf cert for, so /var/lib/caddy/certificates/ stayed empty and
Caddy sent TLS `internal_error` on every handshake. Fix: the :443 block
is now `__FURTKA_HOSTNAME__.local, __FURTKA_HOSTNAME__ { tls internal }`,
with the marker substituted by webinstaller/app.py at install time and
by furtka.updater._refresh_caddyfile on self-update (reads /etc/hostname,
falls back to "furtka"). `auto_https disable_redirects` keeps Caddy's
built-in redirect out of the way of the /settings toggle.
Root cause 2: furtka/https.py and the /rootCA.crt handler both referenced
/var/lib/caddy/.local/share/caddy/pki/authorities/local/ — a path that
doesn't exist. caddy.service sets XDG_DATA_HOME=/var/lib, so Caddy's
storage is /var/lib/caddy/ directly. Fix: both paths corrected.
Verified on the 192.168.178.110 smoke VM by swapping the Caddyfile in,
reloading, handshaking, restoring: TLS 1.3 handshake succeeds, leaf cert
issued under /var/lib/caddy/certificates/local/, /rootCA.crt returns 200.
Tests: new cases assert the Caddyfile ships the hostname placeholder,
the webinstaller substitutes it, _refresh_caddyfile re-substitutes from
/etc/hostname on update, and the asset sets auto_https disable_redirects.
Unit tests still stub the Caddy reload — the real handshake regression
needs a smoke-VM integration test (follow-up, separate from this fix).
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Closes#9. New apps/README.md walks through the four-file contract
(manifest.json, docker-compose.yaml, .env.example, icon.svg) with
the rules enforced by furtka/manifest.py and the SVG sanitiser, using
apps/fileshare as the reference.
Root README: release list now covers 26.1/26.3/26.4 (26.2 stalled on
the jq apt hang). Local HTTPS Phase 1 and the post-build smoke VM on
pollux both flip to [x]; the old proksi.local HTTPS TODO becomes a
Phase 2 entry (dedicated local CA + HTTPS on the live-installer wizard).
iso/README: mDNS is wired — live ISO advertises proksi.local, installed
box defaults to furtka.local (the form's default hostname, not proksi).
HTTPS section notes Caddy tls internal on :443 shipped in 26.4 while
the wizard itself is still HTTP. Overlay table picks up etc/hostname,
etc/issue, furtka-update-issue, and furtka-issue.service.
website/README: auto-deploy via .forgejo/workflows/deploy-site.yml is
the default path now; website/deploy.sh stays as the SSH-hop fallback
for off-CI pushes, and deploy-ci.sh is called out in the structure map.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
When host-networking finally gave arp-scan a real LAN to scan, the
first MAC-match emitted a line, awk hit its `exit` clause, closed the
pipe, and arp-scan died from SIGPIPE (exit 141). With `set -o pipefail`
active, that killed the whole smoke-vm.sh run immediately after
"==> starting VM" — no IP discovery, no curl, no prune.
Fix: capture arp-scan's output into a variable first, then let awk
parse a here-string. Same treatment for the `ip neigh show` fallback.
No pipe, no pipefail cascade.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
pollux (192.168.178.165) wedged at the network level during an
end-to-end install test today — mkinitcpio on a 4 GiB smoke VM +
the cached 1.5 GB ISO + a busy runner container pushed the host into
OOM, taking pveproxy and the SSH path down with it. Recovered by
physical reset.
Smoke VM now defaults to 8192 MiB / 2 vCPU, configurable via
PVE_TEST_VM_MEMORY / PVE_TEST_VM_CORES. Host has 64 GiB, so one
smoke VM at 8 GiB is well within headroom.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Bumps version everywhere user-facing that had drifted from the tag:
- pyproject.toml 26.0 → 26.4
- website/hugo.toml 26.0 → 26.4 (driving furtka.org landing + footer)
- website/content/_index{.md,.de.md} status string
- webinstaller/templates/base.html footer (was hardcoded — noted as
follow-up to read dynamically from pyproject.toml)
Promotes the Unreleased section to 26.4-alpha and folds in today's
additions:
- Local HTTPS via Caddy tls internal + opt-in redirect toggle
- Two self-update UX fixes (Installed-field refresh + 45s reload
fallback)
- Impressum + Datenschutzerklärung on furtka.org
- deploy-site.yml auto-deploy of the Hugo site on push-to-main
- Smoke VM pipeline on .165 Proxmox (build-iso inline smoke step +
workflow_dispatch Smoke latest ISO for cheap re-tests)
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
api() was swallowing Proxmox's error body because callers pipe its
output to /dev/null. With a bare "curl: (22) 403" in the log we can't
tell which permission is missing. Now we capture the response body,
print it to stderr on failure, and only emit it to stdout on success.
No behaviour change on the happy path.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
The delete branch required Datastore.Allocate (or was hitting a
privilege-separated token ACL edge case) and produced 403s on re-runs
against the same commit SHA. Since the ISO bytes are reproducible for
a given SHA — furtka-<sha>.iso is content-addressed — we can just
reuse whatever is already in PVE storage instead of cycling it.
Fixes the "runs-on-same-sha" re-dispatch case without needing any extra
PVE permission, and shaves ~2 min off repeated smoke runs.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
The Forgejo runner container is Alpine with a near-empty base — no
curl, python3, arp-scan, or sudo out of the box. scripts/smoke-vm.sh
needs all four:
- curl: every PVE API call
- python3: JSON parsing of PVE responses
- arp-scan: MAC→IP discovery on the LAN (live ISO has no guest agent)
- sudo: so the same script also works from a dev laptop as non-root
Without this step the smoke job fails immediately on "curl: not found",
regardless of whether the PVE secrets are correctly set.
Added to both build-iso.yml (inline smoke after ISO build) and
smoke-latest.yml (workflow_dispatch retest path).
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
When smoke-vm.sh / PVE setup / secrets change, we want to verify the
fix without waiting for a full 25-min build-iso rebuild (most of which
is the upload-artifact step for a 1.5 GB file).
Adds two things:
1. build-iso.yml grows a "Cache ISO for smoke-latest" step that copies
the freshly built ISO to /data/smoke-cache/latest.iso. /data is
already bind-mounted into the runner container at a matching host
path, so no compose.yml change or runner restart needed.
2. smoke-latest.yml is a workflow_dispatch-only workflow that reads
/data/smoke-cache/latest.iso and runs scripts/smoke-vm.sh against
it. ~2 min end-to-end. Errors cleanly if the cache is empty (build-
iso.yml hasn't populated it yet).
First build-iso run after this merges will populate the cache; from
then on smoke-latest is available for on-demand re-tests.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
The separate smoke-vm job with `needs: build-iso` required round-tripping
the 1.5 GB ISO through actions/upload-artifact + download-artifact. v3
on Forgejo has a known issue where large artifacts stall at 0.0% in the
download step — the smoke run hung today with endless "Total file count:
1 ---- Processed file #0 (0.0%)" output.
Since both jobs run on the same self-hosted runner (host mode, same
workspace available), there was never a real need for the artifact
indirection. Inlining as a step after the artifact upload reuses the
ISO already in iso/out/ and skips the download entirely.
step-level continue-on-error preserves the original guarantee that a
VM-side flake doesn't mark the ISO build red.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Two coupled changes that make sense to land together:
1. Legal pages required under German law
- /imprint/ + /de/impressum/ — §5 DDG disclosure (contact is email
plus Forgejo-Issues as the second quick-contact channel, per ECJ
C-298/07 no phone number required)
- /privacy/ + /de/datenschutz/ — Art. 13 GDPR minimum: server-log
processing (IP, UA, URL, retention ≤30 days), no cookies, no
tracking, no third-party embeds. RLP Landesbeauftragter as the
competent supervisory authority.
- Footer partial linked from every page, localized per language.
- DE versions are legally binding; EN versions are courtesy
translations noting that.
2. Auto-deploy wired up
- New workflow .forgejo/workflows/deploy-site.yml fires on
push-to-main with paths under website/**. Runs on the self-hosted
runner, which *is* forge-runner-01 — so "deploy" is just a local
rsync into /srv/furtka-site and a hugo build into
/var/www/furtka.org. No SSH, no secrets.
- website/deploy-ci.sh is the SSH-free counterpart of deploy.sh,
invoked by the workflow.
- compose.yml bind-mounts /srv/furtka-site and /var/www/furtka.org
into the runner container at matching paths so the workflow can
reach them. Requires a one-time `docker compose up -d` on the
runner host to pick the mounts up.
- deploy.sh is kept for out-of-band manual deploys (testing from a
local branch, CI outage) but gets a header comment pointing at
the CI path as the normal flow.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
After build-iso, a new smoke-vm job uploads the freshly built ISO to
the test Proxmox at 192.168.178.165 via PVE API token, boots it in a
fresh VM (VMID range 9000-9099, MAC derived from commit SHA so the
runner can find the DHCP IP by scanning the LAN), and curls :5000 to
confirm the webinstaller answers HTTP 200. Last 5 smoke VMs + their
ISOs are kept for post-mortem; older ones are purged. continue-on-error
on the smoke job so a VM-side flake doesn't mark the ISO build red.
Shortens the feedback loop on ISO regressions from "next manual VM
test session" (days) to "next push" (minutes) — the 2026-04-15/16 VM
sessions each found real boot-time bugs that unit tests missed.
Docs at docs/smoke-vm.md. Requires Forgejo secrets PVE_TEST_HOST and
PVE_TEST_TOKEN (dedicated smoke@pve!ci PVE token, privilege-separated).
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Caddy now serves both :80 (plain HTTP, unchanged default) and :443 with
tls internal — it generates its own per-box root CA on first start,
stored under /var/lib/caddy/.local/share/caddy/pki/authorities/local/.
Users can download rootCA.crt at /rootCA.crt (served on both listeners)
and install it per-OS via the new /https-install/ guide.
Settings page grows a Local HTTPS card with CA fingerprint, download
button, reachability probe, and an opt-in "force HTTPS" toggle. The
toggle only unhides itself once the current browser already trusts the
cert, so enabling it can't lock the user out of the settings page.
Backend: GET /api/furtka/https/status and POST /api/furtka/https/force
in furtka.https. The force toggle drops a Caddy import snippet into
/etc/caddy/furtka.d/redirect.caddyfile and reloads Caddy; reload
failure rolls the snippet state back so a bad config can't wedge the
next service start.
updater._refresh_caddyfile() ensures /etc/caddy/furtka.d exists before
every reload so 26.3-alpha → 26.4-alpha self-updates don't trip on the
new glob import directive.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Drive upd-current from the /api/furtka/update/check response so a
post-update Check reflects the new installed version without Ctrl+F5,
and arm a 45s fallback location.reload on apply-click so the page still
comes up on the new version when the mid-apply API restart drops the
/update-state.json poll before stage=done is observed.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
End-to-end validation of the per-app container update and the Furtka
self-update ran green on VM 192.168.178.128 this afternoon (26.0-alpha
→ 26.3-alpha → rollback → reboot). Both flows are real — promote the
drafted HTML-comment bullets from _index.md and _index.de.md into the
visible "What works today" list.
The "plain-English Wi-Fi story" was the only one the copy was missing
a truthful on-box outcome for, and it still is (for a moment here
a-few-days-ago, but the update story moved past that).
Matches the commitment in feedback_no_invented_content.md — we only
publish after confirmation.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Roadmap section drifted far enough that "re-tag 26.0-alpha" was still
listed as open while 26.1-alpha and 26.3-alpha are live releases.
Updated:
- Replaced the stale "re-tag 26.0-alpha" line with the actual state:
tag-driven release pipeline is wired, two pre-releases published,
all assets downloadable anonymously.
- Added five new checked items for the work that landed this month:
resource manager + fileshare (validated), on-box UI uplevel (shared
CSS / settings page / icons), versioned layout + per-app container
updates, Phase 2 Furtka self-update (tag → release.yml → /settings
Update now → atomic swap + auto-rollback), plus the broader Forgejo
release pipeline that underpins the update story.
- Kept open items (wizard S3-S7, managed gateway, Authentik, local CA,
Nextcloud first service, UI mockups) as the remaining TODO surface.
No code or test changes; pytest + ruff still green from the last push.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
End-to-end validated the Phase-2 self-update today on a fresh install
(192.168.178.128 → 26.0-alpha → 26.3-alpha): the symlink flip, the
tarball verify, the stage-by-stage progress, and the rollback slots
all work. But two browser-side UX bits are rough:
1. The "Installed" version displayed on /settings doesn't refresh
right after the update; a hard reload shows the new value.
2. The auto-reload that should fire 5s after stage=done missed on
the test — the polling connection likely dropped during the
mid-update API restart.
Neither affects the integrity of the update itself. Landed the notes
in [Unreleased] so the next release cycle picks them up.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
The 26.2-alpha release workflow hung for 15+ minutes on
"apt-get install -y jq" — the runner's apt mirror was unreachable
(or very slow), and the whole publish stalled.
jq was only used for two tiny things: building the release-create
POST body and reading the release id from the response. Both are
one-liners in Python, which is guaranteed-present on the Forgejo
Actions ubuntu-latest runner image. Replaced both uses; removed
the apt-get step from release.yml entirely. Slow mirrors no
longer block tagged releases.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Forgejo's /releases/latest silently skips pre-releases (any release
with a -alpha / -beta / -rc suffix) and 404s when there's no stable
release. During Furtka's alpha stage every tag is a pre-release, so
the Check-for-updates button always 404'd against a perfectly-valid
releases page.
Switch check_update() to GET /releases?limit=1 and take the first
entry. Forgejo returns releases newest-first regardless of kind, so
this works whether the top of the list is pre-release or stable.
Empty list (no releases published yet) now returns a clean
"no releases" UpdateError instead of a raw 404.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Third install-path miss from the versioned-layout refactor: the bundled
apps moved from /opt/furtka/apps (flat) to
/opt/furtka/versions/<ver>/apps/, reached through the /opt/furtka/current
symlink. paths.py was still pointing at the flat path, so _list_bundled
walked a non-existent directory and /api/bundled returned [] — the
fileshare tile never showed up on /apps.
Tests already use FURTKA_BUNDLED_APPS_DIR env override so nothing in
the suite needed to change. Confirmed on the VM: compat symlink
/opt/furtka/apps -> current/apps makes /api/bundled return the
fileshare manifest immediately, no service restart needed since
scanner.py reads the directory on every request.
Locking the path at current/apps rather than leaving the flat fallback
is deliberate — Phase-2 self-updates flip the symlink, and a flat
/opt/furtka/apps wouldn't move with them; bundled apps would freeze
at whatever version installed first.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Two install-path bugs surfaced by SSHing into the hot-fixed test VM:
1. mktemp creates the staging dir with mode 700 by default; the rename
to /opt/furtka/versions/<ver>/ preserved it, and Caddy (running as
the unprivileged `caddy` user) got 403 Forbidden because it couldn't
traverse the version dir. Now the install + self-update both
`chmod 755` after the rename.
2. _furtka_json_cmd was a silent no-op on the 43a39a4 VM — the
base64-encoded body + sed substitution approach layered two sets of
quotes through archinstall's custom_commands eval, and the sed
step either never ran or didn't match. Replaced with a plain
heredoc that interpolates $(date -Iseconds) and $(cat VERSION) at
chroot runtime. Result lands /var/lib/furtka/furtka.json reliably,
which is what the landing page's hostname chip and the settings
page's install-date field depend on.
Both issues confirmed fixed by applying them manually on the VM
(chmod 755 /opt/furtka/versions/26.0-alpha + writing furtka.json by
hand): landing page, /apps, /settings, /furtka.json all now 200 with
correct content.
Tests updated (the chmod 755 gets asserted; the old base64+sed test
gets replaced with a heredoc-shape check; the updater test asserts
0o755 mode on the finished version dir).
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Root cause of today's 403 on a fresh install: assets/ lived inside the
Python package at furtka/assets/, so the resource-manager tarball
extracted to /opt/furtka/versions/<ver>/furtka/assets/. But Caddyfile
has `root * /opt/furtka/current/assets/www`, systemd units point at
/opt/furtka/current/assets/bin/furtka-status, and the install-time
`systemctl link /opt/furtka/current/assets/systemd/*.service` expected
the top-level layout. All three found nothing:
- Caddy → 403 Forbidden (empty/missing document root)
- systemctl link → silent no-op, nothing ever linked into
/etc/systemd/system/
- furtka-api.service + furtka-reconcile.service → "inactive" because
they were never registered
Nothing in the Python package ever imported furtka.assets — these are
shell scripts, HTML/CSS, systemd units, and a Caddyfile, which is
config data, not package data. Promoting assets/ to the repo root
matches how it's referenced everywhere downstream and eliminates the
path mismatch.
Changes:
- git mv furtka/assets assets
- iso/build.sh: tarball-staging step now also `cp -a "$REPO_ROOT/assets"`
so the tarball ships ./assets at its root, and the live-ISO copy
reads from $REPO_ROOT/assets instead of $REPO_ROOT/furtka/assets.
- scripts/build-release-tarball.sh: same for release tarballs.
- webinstaller/app.py: _resolve_assets_dir's dev fallback walks one
level up to REPO_ROOT/assets/.
- tests/test_webinstaller_assets.py: ASSETS constant updated.
Tests still green (150/150) because both paths were fs-level — no
code imports changed. Next ISO build will land assets at the path
everything downstream expects.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Python 3.14 added pathlib.Path.copy() which refuses source==target with
OSError [Errno 22]. archinstall's sync_log_to_install_medium() calls
.copy() on install.log to itself at __exit__ time, because by then the
chroot mountpoint is already torn down and both source and target
resolve to the same /var/log/archinstall/install.log. The install
itself has already succeeded — the crash is in the log-sync cleanup.
Patch is a pre-start sed on the live ISO that replaces the offending
call with `None` (a no-op expression-statement keeping the same
indent level). Lives on furtka-webinstaller.service as ExecStartPre
so it runs before the first install attempt; idempotent, so service
restarts don't re-trigger anything. Never touches the installed
system — only the live ISO's site-packages tree.
Real fix is upstream in archinstall (guard the copy when source
and target resolve equal); this is a workaround until they ship it.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Held as HTML comments in both _index.md and _index.de.md so the next
site update — after the next ISO test confirms the container-image
update flow and the Furtka self-update flow on real hardware — is
just stripping the comment markers, not rewriting the copy from
scratch.
Keeping the live site honest until we can say "this works": the
Phase-1 per-app updater and the Phase-2 self-update pipeline are both
in the tree and in CI, but they haven't booted from an ISO yet. Per
the "only publish facts verifiable from repo or confirmed by user"
convention, they stay off the public page until that test is green.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
archinstall's enum members got renamed from CapitalCase (Ext4) to
ALL_CAPS (EXT4) between when build_disk_config was written and the
version baked into the current Arch live ISO. Result: the install
step fires AttributeError at the archinstall JSON-dump moment,
rendering a Flask 500 right after the Confirm page.
Traceback from the VM:
File "/opt/furtka/app.py", line 128, in build_disk_config
filesystem_type=FilesystemType.Ext4,
AttributeError: type object 'FilesystemType' has no attribute
'Ext4'. Did you mean: 'EXT4'?
Tests pass because build_disk_config is monkeypatched out in CI
(archinstall isn't pip-installable; it only exists on the live ISO).
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Five issues surfaced by the Phase-2 audit before the next ISO rebuild:
P1 (real blockers for a fresh install / self-update):
1. chmod +x furtka/assets/bin/furtka-status, furtka-welcome. They were
mode 644 in git, so the tarball shipped them non-executable and every
ExecStart referencing /opt/furtka/current/assets/bin/furtka-* would
have failed on first boot with Permission denied.
2. apply_update now refreshes /etc/caddy/Caddyfile from the new version
when the content differs, then reloads caddy. Without this, a release
that changes Caddy routes silently stays on the old config.
3. apply_update now systemctl-links any new unit files shipped by the
update, not just the five linked at install time. A future release
that adds furtka-foo.service would otherwise never appear in
/etc/systemd/system/.
P2 (hardening, not blockers today):
4. _resource_manager_commands now aborts the install if the tarball's
VERSION file is empty — otherwise `mv "$staging" /opt/furtka/versions/`
would move the staging dir in as a subdirectory and the symlink
target would be invalid.
5. _extract_tarball passes filter='data' to tarfile.extractall on
Python 3.12+ to catch symlink-escape / setuid / device-node tricks
that the regex path-check can't see. Falls back silently on older
interpreters.
Plus the CHANGELOG [Unreleased] section got filled in with the whole
Phase-1 + Phase-2 + UI-uplevel body so a 26.1-alpha tag cut off main
has meaningful release notes.
Test additions / updates:
- test_refresh_caddyfile_{copies_when_different,noops_if_source_missing}
- test_link_new_units_only_links_missing
- test_extract_tarball_uses_data_filter_when_available
- test_apply_update_happy_path now verifies the Caddyfile gets copied.
- test_resource_manager_extracts_to_versioned_slot verifies the
empty-VERSION guard is present in the install command.
Paths now overridable via FURTKA_CADDYFILE_PATH + FURTKA_SYSTEMD_DIR so
tests can pin a tmpdir for these new fs operations.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Slice 3 of the self-update story, the user-facing piece. The existing
CLI update flow now has a button next to it.
API additions (furtka/api.py):
- POST /api/furtka/update/check — thin wrapper around updater.check_update
- POST /api/furtka/update/apply — pre-checks the lockfile (409 on conflict)
then kicks the updater off via systemd-run as a detached transient unit,
so the update outlives the furtka-api restart it triggers. Returns 202
with the unit name.
- GET /api/furtka/update/status — returns the current update-state.json
UI additions (furtka/assets/www/settings/index.html):
- New "Furtka updates" card above Appearance showing installed +
latest-available versions with Check + Update buttons.
- On apply: starts polling /update-state.json every 2s. That file is
Caddy-served (not API-served) so the mid-update API restart doesn't
interrupt progress reporting. Stage labels get plain-English strings
(Downloading release… / Verifying signature… / etc.). On done: 5s
grace, then location.reload() so the user sees the new version live.
On rolled_back: red status with the reason string.
Tests (tests/test_api.py):
- 5 new tests covering both endpoint return shapes (success, 502 when
updater.check_update raises, 409 when lock held, 202 on dispatch,
status passthrough).
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Slice 2 of the self-update story. Tagging a release on main now
produces a downloadable self-update payload on the Forgejo releases
page, and a running box can pull it down, verify it, atomically swap
to the new version, and health-check the result.
New pieces:
- scripts/build-release-tarball.sh <version> — packages the furtka/
package + bundled apps/ + a root-level VERSION file as
dist/furtka-<version>.tar.gz, plus a .sha256 sidecar and a
release.json metadata blob.
- scripts/publish-release.sh <version> — uses the Forgejo v1 API to
create a release (body pulled from the CHANGELOG section for this
tag, pre-release auto-flagged on -alpha/-beta/-rc) and upload the
three assets sequentially. Needs \$FORGEJO_TOKEN.
- .forgejo/workflows/release.yml — tag-triggered, runs both scripts
with the new \$FORGEJO_RELEASE_TOKEN repo secret.
- furtka/updater.py — check_update, prepare_update, apply_update,
run_update, rollback. Atomic symlink swap, sha256 verify (TOCTOU-
safe: re-hashes on-disk file), health-check post-restart with
auto-rollback on failure, stage-by-stage progress persisted to
/var/lib/furtka/update-state.json so the UI can poll independent
of the (restarting) API process. Path overrides via FURTKA_ROOT /
FURTKA_STATE_DIR / FURTKA_LOCK_PATH so tests pin a tmpdir.
- furtka/cli.py — \`furtka update [--check] [--json]\` and
\`furtka rollback\`.
- tests/test_updater.py — 15 tests: version compare, sha256 verify,
tarball extract (including traversal refusal), lockfile, apply
happy + rollback paths, rollback CLI, check_update with stubbed
Forgejo.
- iso/build.sh — writes VERSION at the tarball root so the install
path matches the self-update path (previously assumed only the
release script did this).
RELEASING.md now points at the automated flow — no more manually
clicking "Create release" on the Forgejo UI.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>