furtka/tests/test_webinstaller_assets.py

230 lines
10 KiB
Python
Raw Normal View History

feat(furtka): serve from /opt/furtka/current, retire /srv/furtka/www/ Slice 1b of the self-update story. The installer now sets up a versioned layout — install extracts the resource-manager tarball to a staging dir, reads the VERSION it contains, moves the dir to /opt/furtka/versions/<ver>/, and creates /opt/furtka/current as a symlink pointing at it. All runtime references (Caddy, wrapper, systemd ExecStart) go through /current, so Phase 2's self-update just flips the symlink atomically. Systemd units move from hand-written files in /etc/systemd/system/ to `systemctl link /opt/furtka/current/assets/systemd/*` — one link per unit, stable across upgrades because the link target is /current. The furtka-status + furtka-welcome units now ExecStart the shipped scripts directly from /opt/furtka/current/assets/bin/, which means we no longer copy those scripts to /usr/local/bin/ at install time. Runtime JSON (status.json, furtka.json, update-state.json) moves to /var/lib/furtka/ so self-updates never clobber it. Caddy serves those three paths from there; everything else from /opt/furtka/current/assets/www/. The __HOSTNAME__ sed-template hack is gone. At install time we write /var/lib/furtka/furtka.json with {hostname, install_date, version}, and the landing page's JS reads it on load to populate the hostname chip and to build the SMB deep-link for the fileshare tile. First paint gets a "—" placeholder and hydrates once fetch completes. Test updates: - test_webinstaller_assets enforces the new command shape (extract-to- staging, ln -sfn /opt/furtka/current, systemctl link per unit, no writes to /srv/furtka/www/). - test_app's legacy "payload present" / "payload absent" tests match the new layout too. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-16 13:15:59 +02:00
"""Asset sourcing + install-flow tests for webinstaller.
Phase-2 slice 1a moved every HTML/CSS/script/unit payload out of inline
Python string constants and into real files under furtka/assets/. Slice 1b
then flipped Caddy to serve from /opt/furtka/current/assets/www/, retired
/srv/furtka/www/, and switched systemd units from hand-written files in
/etc/systemd/system/ to `systemctl link` against the shipped asset tree.
These tests lock the new contract:
- the Caddyfile and the status.json placeholder still land via
_write_file_cmd and match the on-disk asset bit-for-bit,
- the resource-manager bootstrap extracts to /opt/furtka/versions/<ver>/,
creates /opt/furtka/current, and systemctl-links every unit,
- furtka.json is written with the installer's hostname,
- no write ever targets /srv/furtka/www/ (that path is retired).
refactor(webinstaller): extract inline payload constants to furtka/assets/ Slice 1a of the self-update story. Every HTML/CSS/shell-script/systemd- unit payload that used to live as a triple-quoted string constant inside webinstaller/app.py now lives as a real file under furtka/assets/: furtka/assets/Caddyfile furtka/assets/VERSION (new — matches pyproject.toml) furtka/assets/www/{index.html, settings/index.html, style.css, status.json} furtka/assets/bin/{furtka-status, furtka-welcome} furtka/assets/systemd/furtka-{api,reconcile,status,welcome}.service furtka/assets/systemd/furtka-status.timer The installer now pulls each file from disk via _read_asset(). Byte-for- byte identical output at install time — a fresh-ISO install should land the same files in the same places with the same contents, verified by tests/test_webinstaller_assets.py which reconstructs each base64 blob and asserts equality against the on-disk asset. iso/build.sh also copies furtka/assets/ next to the webinstaller source at /opt/furtka/assets on the live ISO so _resolve_assets_dir() finds them with a "next to me" lookup. In dev the same function walks two levels up to the repo copy, so pytest works without any env vars. furtka-status.sh drops the /etc/furtka/version TODO — it now reads /opt/furtka/VERSION directly, which Slice 1b will upgrade to /opt/furtka/current/VERSION once the symlink layout lands. _FURTKA_WRAPPER_SH (the 5-line /usr/local/bin/furtka shim) stays inline; it's tiny and not asset-shaped. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-16 13:08:53 +02:00
"""
import base64
import re
import sys
from pathlib import Path
import pytest
sys.path.insert(0, str(Path(__file__).resolve().parent.parent / "webinstaller"))
import app # noqa: E402
REPO_ROOT = Path(__file__).resolve().parent.parent
fix(furtka): move assets/ to repo top level so Caddy + systemd find it 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>
2026-04-16 15:26:10 +02:00
ASSETS = REPO_ROOT / "assets"
refactor(webinstaller): extract inline payload constants to furtka/assets/ Slice 1a of the self-update story. Every HTML/CSS/shell-script/systemd- unit payload that used to live as a triple-quoted string constant inside webinstaller/app.py now lives as a real file under furtka/assets/: furtka/assets/Caddyfile furtka/assets/VERSION (new — matches pyproject.toml) furtka/assets/www/{index.html, settings/index.html, style.css, status.json} furtka/assets/bin/{furtka-status, furtka-welcome} furtka/assets/systemd/furtka-{api,reconcile,status,welcome}.service furtka/assets/systemd/furtka-status.timer The installer now pulls each file from disk via _read_asset(). Byte-for- byte identical output at install time — a fresh-ISO install should land the same files in the same places with the same contents, verified by tests/test_webinstaller_assets.py which reconstructs each base64 blob and asserts equality against the on-disk asset. iso/build.sh also copies furtka/assets/ next to the webinstaller source at /opt/furtka/assets on the live ISO so _resolve_assets_dir() finds them with a "next to me" lookup. In dev the same function walks two levels up to the repo copy, so pytest works without any env vars. furtka-status.sh drops the /etc/furtka/version TODO — it now reads /opt/furtka/VERSION directly, which Slice 1b will upgrade to /opt/furtka/current/VERSION once the symlink layout lands. _FURTKA_WRAPPER_SH (the 5-line /usr/local/bin/furtka shim) stays inline; it's tiny and not asset-shaped. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-16 13:08:53 +02:00
feat(furtka): serve from /opt/furtka/current, retire /srv/furtka/www/ Slice 1b of the self-update story. The installer now sets up a versioned layout — install extracts the resource-manager tarball to a staging dir, reads the VERSION it contains, moves the dir to /opt/furtka/versions/<ver>/, and creates /opt/furtka/current as a symlink pointing at it. All runtime references (Caddy, wrapper, systemd ExecStart) go through /current, so Phase 2's self-update just flips the symlink atomically. Systemd units move from hand-written files in /etc/systemd/system/ to `systemctl link /opt/furtka/current/assets/systemd/*` — one link per unit, stable across upgrades because the link target is /current. The furtka-status + furtka-welcome units now ExecStart the shipped scripts directly from /opt/furtka/current/assets/bin/, which means we no longer copy those scripts to /usr/local/bin/ at install time. Runtime JSON (status.json, furtka.json, update-state.json) moves to /var/lib/furtka/ so self-updates never clobber it. Caddy serves those three paths from there; everything else from /opt/furtka/current/assets/www/. The __HOSTNAME__ sed-template hack is gone. At install time we write /var/lib/furtka/furtka.json with {hostname, install_date, version}, and the landing page's JS reads it on load to populate the hostname chip and to build the SMB deep-link for the fileshare tile. First paint gets a "—" placeholder and hydrates once fetch completes. Test updates: - test_webinstaller_assets enforces the new command shape (extract-to- staging, ln -sfn /opt/furtka/current, systemctl link per unit, no writes to /srv/furtka/www/). - test_app's legacy "payload present" / "payload absent" tests match the new layout too. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-16 13:15:59 +02:00
# (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
fix(https): restore TLS handshake — name hostname + correct PKI path 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>
2026-04-20 11:38:16 +02:00
# copied; they're reached via /opt/furtka/current and `systemctl link`. The
# Caddyfile is not in this list because it's written with the hostname
# placeholder substituted — see test_post_install_substitutes_hostname_in_caddyfile.
refactor(webinstaller): extract inline payload constants to furtka/assets/ Slice 1a of the self-update story. Every HTML/CSS/shell-script/systemd- unit payload that used to live as a triple-quoted string constant inside webinstaller/app.py now lives as a real file under furtka/assets/: furtka/assets/Caddyfile furtka/assets/VERSION (new — matches pyproject.toml) furtka/assets/www/{index.html, settings/index.html, style.css, status.json} furtka/assets/bin/{furtka-status, furtka-welcome} furtka/assets/systemd/furtka-{api,reconcile,status,welcome}.service furtka/assets/systemd/furtka-status.timer The installer now pulls each file from disk via _read_asset(). Byte-for- byte identical output at install time — a fresh-ISO install should land the same files in the same places with the same contents, verified by tests/test_webinstaller_assets.py which reconstructs each base64 blob and asserts equality against the on-disk asset. iso/build.sh also copies furtka/assets/ next to the webinstaller source at /opt/furtka/assets on the live ISO so _resolve_assets_dir() finds them with a "next to me" lookup. In dev the same function walks two levels up to the repo copy, so pytest works without any env vars. furtka-status.sh drops the /etc/furtka/version TODO — it now reads /opt/furtka/VERSION directly, which Slice 1b will upgrade to /opt/furtka/current/VERSION once the symlink layout lands. _FURTKA_WRAPPER_SH (the 5-line /usr/local/bin/furtka shim) stays inline; it's tiny and not asset-shaped. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-16 13:08:53 +02:00
ASSET_TARGETS = [
feat(furtka): serve from /opt/furtka/current, retire /srv/furtka/www/ Slice 1b of the self-update story. The installer now sets up a versioned layout — install extracts the resource-manager tarball to a staging dir, reads the VERSION it contains, moves the dir to /opt/furtka/versions/<ver>/, and creates /opt/furtka/current as a symlink pointing at it. All runtime references (Caddy, wrapper, systemd ExecStart) go through /current, so Phase 2's self-update just flips the symlink atomically. Systemd units move from hand-written files in /etc/systemd/system/ to `systemctl link /opt/furtka/current/assets/systemd/*` — one link per unit, stable across upgrades because the link target is /current. The furtka-status + furtka-welcome units now ExecStart the shipped scripts directly from /opt/furtka/current/assets/bin/, which means we no longer copy those scripts to /usr/local/bin/ at install time. Runtime JSON (status.json, furtka.json, update-state.json) moves to /var/lib/furtka/ so self-updates never clobber it. Caddy serves those three paths from there; everything else from /opt/furtka/current/assets/www/. The __HOSTNAME__ sed-template hack is gone. At install time we write /var/lib/furtka/furtka.json with {hostname, install_date, version}, and the landing page's JS reads it on load to populate the hostname chip and to build the SMB deep-link for the fileshare tile. First paint gets a "—" placeholder and hydrates once fetch completes. Test updates: - test_webinstaller_assets enforces the new command shape (extract-to- staging, ln -sfn /opt/furtka/current, systemctl link per unit, no writes to /srv/furtka/www/). - test_app's legacy "payload present" / "payload absent" tests match the new layout too. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-16 13:15:59 +02:00
("/var/lib/furtka/status.json", "www/status.json"),
refactor(webinstaller): extract inline payload constants to furtka/assets/ Slice 1a of the self-update story. Every HTML/CSS/shell-script/systemd- unit payload that used to live as a triple-quoted string constant inside webinstaller/app.py now lives as a real file under furtka/assets/: furtka/assets/Caddyfile furtka/assets/VERSION (new — matches pyproject.toml) furtka/assets/www/{index.html, settings/index.html, style.css, status.json} furtka/assets/bin/{furtka-status, furtka-welcome} furtka/assets/systemd/furtka-{api,reconcile,status,welcome}.service furtka/assets/systemd/furtka-status.timer The installer now pulls each file from disk via _read_asset(). Byte-for- byte identical output at install time — a fresh-ISO install should land the same files in the same places with the same contents, verified by tests/test_webinstaller_assets.py which reconstructs each base64 blob and asserts equality against the on-disk asset. iso/build.sh also copies furtka/assets/ next to the webinstaller source at /opt/furtka/assets on the live ISO so _resolve_assets_dir() finds them with a "next to me" lookup. In dev the same function walks two levels up to the repo copy, so pytest works without any env vars. furtka-status.sh drops the /etc/furtka/version TODO — it now reads /opt/furtka/VERSION directly, which Slice 1b will upgrade to /opt/furtka/current/VERSION once the symlink layout lands. _FURTKA_WRAPPER_SH (the 5-line /usr/local/bin/furtka shim) stays inline; it's tiny and not asset-shaped. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-16 13:08:53 +02:00
]
def _extract_written_content(cmd, target):
feat(furtka): serve from /opt/furtka/current, retire /srv/furtka/www/ Slice 1b of the self-update story. The installer now sets up a versioned layout — install extracts the resource-manager tarball to a staging dir, reads the VERSION it contains, moves the dir to /opt/furtka/versions/<ver>/, and creates /opt/furtka/current as a symlink pointing at it. All runtime references (Caddy, wrapper, systemd ExecStart) go through /current, so Phase 2's self-update just flips the symlink atomically. Systemd units move from hand-written files in /etc/systemd/system/ to `systemctl link /opt/furtka/current/assets/systemd/*` — one link per unit, stable across upgrades because the link target is /current. The furtka-status + furtka-welcome units now ExecStart the shipped scripts directly from /opt/furtka/current/assets/bin/, which means we no longer copy those scripts to /usr/local/bin/ at install time. Runtime JSON (status.json, furtka.json, update-state.json) moves to /var/lib/furtka/ so self-updates never clobber it. Caddy serves those three paths from there; everything else from /opt/furtka/current/assets/www/. The __HOSTNAME__ sed-template hack is gone. At install time we write /var/lib/furtka/furtka.json with {hostname, install_date, version}, and the landing page's JS reads it on load to populate the hostname chip and to build the SMB deep-link for the fileshare tile. First paint gets a "—" placeholder and hydrates once fetch completes. Test updates: - test_webinstaller_assets enforces the new command shape (extract-to- staging, ln -sfn /opt/furtka/current, systemctl link per unit, no writes to /srv/furtka/www/). - test_app's legacy "payload present" / "payload absent" tests match the new layout too. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-16 13:15:59 +02:00
"""Pull the base64 payload back out of a _write_file_cmd() shell string."""
refactor(webinstaller): extract inline payload constants to furtka/assets/ Slice 1a of the self-update story. Every HTML/CSS/shell-script/systemd- unit payload that used to live as a triple-quoted string constant inside webinstaller/app.py now lives as a real file under furtka/assets/: furtka/assets/Caddyfile furtka/assets/VERSION (new — matches pyproject.toml) furtka/assets/www/{index.html, settings/index.html, style.css, status.json} furtka/assets/bin/{furtka-status, furtka-welcome} furtka/assets/systemd/furtka-{api,reconcile,status,welcome}.service furtka/assets/systemd/furtka-status.timer The installer now pulls each file from disk via _read_asset(). Byte-for- byte identical output at install time — a fresh-ISO install should land the same files in the same places with the same contents, verified by tests/test_webinstaller_assets.py which reconstructs each base64 blob and asserts equality against the on-disk asset. iso/build.sh also copies furtka/assets/ next to the webinstaller source at /opt/furtka/assets on the live ISO so _resolve_assets_dir() finds them with a "next to me" lookup. In dev the same function walks two levels up to the repo copy, so pytest works without any env vars. furtka-status.sh drops the /etc/furtka/version TODO — it now reads /opt/furtka/VERSION directly, which Slice 1b will upgrade to /opt/furtka/current/VERSION once the symlink layout lands. _FURTKA_WRAPPER_SH (the 5-line /usr/local/bin/furtka shim) stays inline; it's tiny and not asset-shaped. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-16 13:08:53 +02:00
match = re.search(r"printf %s (\S+) \| base64 -d > " + re.escape(target), cmd)
assert match, f"cmd didn't look like a write of {target}: {cmd[:100]}"
return base64.b64decode(match.group(1)).decode("utf-8")
@pytest.fixture
feat(furtka): serve from /opt/furtka/current, retire /srv/furtka/www/ Slice 1b of the self-update story. The installer now sets up a versioned layout — install extracts the resource-manager tarball to a staging dir, reads the VERSION it contains, moves the dir to /opt/furtka/versions/<ver>/, and creates /opt/furtka/current as a symlink pointing at it. All runtime references (Caddy, wrapper, systemd ExecStart) go through /current, so Phase 2's self-update just flips the symlink atomically. Systemd units move from hand-written files in /etc/systemd/system/ to `systemctl link /opt/furtka/current/assets/systemd/*` — one link per unit, stable across upgrades because the link target is /current. The furtka-status + furtka-welcome units now ExecStart the shipped scripts directly from /opt/furtka/current/assets/bin/, which means we no longer copy those scripts to /usr/local/bin/ at install time. Runtime JSON (status.json, furtka.json, update-state.json) moves to /var/lib/furtka/ so self-updates never clobber it. Caddy serves those three paths from there; everything else from /opt/furtka/current/assets/www/. The __HOSTNAME__ sed-template hack is gone. At install time we write /var/lib/furtka/furtka.json with {hostname, install_date, version}, and the landing page's JS reads it on load to populate the hostname chip and to build the SMB deep-link for the fileshare tile. First paint gets a "—" placeholder and hydrates once fetch completes. Test updates: - test_webinstaller_assets enforces the new command shape (extract-to- staging, ln -sfn /opt/furtka/current, systemctl link per unit, no writes to /srv/furtka/www/). - test_app's legacy "payload present" / "payload absent" tests match the new layout too. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-16 13:15:59 +02:00
def install_cmds(tmp_path, monkeypatch):
# Make _resource_manager_commands return a non-empty list so the full
# command tree is exercised. A fake payload file is enough since the
# test only inspects the generated shell strings.
fake = tmp_path / "payload.tar.gz"
fake.write_bytes(b"not a real tarball")
monkeypatch.setattr(app, "RESOURCE_MANAGER_PAYLOAD", fake)
feat(auth): login-guard the Furtka UI with a cookie session One-admin, one-password model — all of /apps, /api/*, /, and /settings/ now require a signed-in session. Passwords are werkzeug PBKDF2-hashed in /var/lib/furtka/users.json (mode 0600, atomic write via the same .tmp+chmod+rename dance installer.write_env uses). Sessions are secrets.token_urlsafe(32) tokens held in a module-level SessionStore dict (thread-safe lock included for when we swap to ThreadingHTTPServer). Cookies are HttpOnly, SameSite=Strict, and Path=/, with Secure set when X-Forwarded-Proto from Caddy says HTTPS. Two bootstrap paths: * Fresh install — webinstaller step-1 collects Linux user + password, the chroot post-install step hashes the password and writes users.json on the target partition. First browser visit lands on /login with the account already present. * Upgrade from 26.10-alpha — no users.json yet, so /login detects setup_needed() and renders a first-run setup form. POST creates the admin and immediately logs in. POST /logout revokes the server session and clears the cookie. Unauthenticated HTML requests 302 to /login; unauthenticated API requests 401 JSON so fetch() callers see a clean error. A sleep(0.5) on failed logins is the brute-force speed bump on top of werkzeug's ~600k-iter PBKDF2. Caddyfile gains /login* and /logout* handle blocks in the shared furtka_routes snippet so both :80 and the HTTPS hostname block forward the auth endpoints to localhost:7000. Without this Caddy would 404 from the static file server. Test surface: * tests/test_auth.py (new, 19 cases): hash roundtrip, users.json I/O, session create/lookup/expire/revoke. * tests/test_api.py: new admin_session fixture; existing HTTP tests updated to send the cookie; new tests cover login setup, login success, wrong-password 401, logout revocation, and the guard's 302/401 split. * tests/test_webinstaller_assets.py: new case that unpacks the users.json _write_file_cmd body and verifies the werkzeug hash round-trips against the step-1 password. Bumped version to 26.11-alpha and rolled CHANGELOG. Also folded in the ruff-format fix that was pending from 26.10-alpha's lint red. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-21 13:01:17 +02:00
return app._post_install_commands("testhost", "daniel", "test-admin-pw")
refactor(webinstaller): extract inline payload constants to furtka/assets/ Slice 1a of the self-update story. Every HTML/CSS/shell-script/systemd- unit payload that used to live as a triple-quoted string constant inside webinstaller/app.py now lives as a real file under furtka/assets/: furtka/assets/Caddyfile furtka/assets/VERSION (new — matches pyproject.toml) furtka/assets/www/{index.html, settings/index.html, style.css, status.json} furtka/assets/bin/{furtka-status, furtka-welcome} furtka/assets/systemd/furtka-{api,reconcile,status,welcome}.service furtka/assets/systemd/furtka-status.timer The installer now pulls each file from disk via _read_asset(). Byte-for- byte identical output at install time — a fresh-ISO install should land the same files in the same places with the same contents, verified by tests/test_webinstaller_assets.py which reconstructs each base64 blob and asserts equality against the on-disk asset. iso/build.sh also copies furtka/assets/ next to the webinstaller source at /opt/furtka/assets on the live ISO so _resolve_assets_dir() finds them with a "next to me" lookup. In dev the same function walks two levels up to the repo copy, so pytest works without any env vars. furtka-status.sh drops the /etc/furtka/version TODO — it now reads /opt/furtka/VERSION directly, which Slice 1b will upgrade to /opt/furtka/current/VERSION once the symlink layout lands. _FURTKA_WRAPPER_SH (the 5-line /usr/local/bin/furtka shim) stays inline; it's tiny and not asset-shaped. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-16 13:08:53 +02:00
@pytest.mark.parametrize("target,asset_relpath", ASSET_TARGETS)
def test_post_install_writes_asset_from_disk(install_cmds, target, asset_relpath):
expected = (ASSETS / asset_relpath).read_text(encoding="utf-8")
matching = [c for c in install_cmds if f" > {target}" in c]
assert matching, f"no command writes {target}"
assert _extract_written_content(matching[0], target) == expected
feat(furtka): serve from /opt/furtka/current, retire /srv/furtka/www/ Slice 1b of the self-update story. The installer now sets up a versioned layout — install extracts the resource-manager tarball to a staging dir, reads the VERSION it contains, moves the dir to /opt/furtka/versions/<ver>/, and creates /opt/furtka/current as a symlink pointing at it. All runtime references (Caddy, wrapper, systemd ExecStart) go through /current, so Phase 2's self-update just flips the symlink atomically. Systemd units move from hand-written files in /etc/systemd/system/ to `systemctl link /opt/furtka/current/assets/systemd/*` — one link per unit, stable across upgrades because the link target is /current. The furtka-status + furtka-welcome units now ExecStart the shipped scripts directly from /opt/furtka/current/assets/bin/, which means we no longer copy those scripts to /usr/local/bin/ at install time. Runtime JSON (status.json, furtka.json, update-state.json) moves to /var/lib/furtka/ so self-updates never clobber it. Caddy serves those three paths from there; everything else from /opt/furtka/current/assets/www/. The __HOSTNAME__ sed-template hack is gone. At install time we write /var/lib/furtka/furtka.json with {hostname, install_date, version}, and the landing page's JS reads it on load to populate the hostname chip and to build the SMB deep-link for the fileshare tile. First paint gets a "—" placeholder and hydrates once fetch completes. Test updates: - test_webinstaller_assets enforces the new command shape (extract-to- staging, ln -sfn /opt/furtka/current, systemctl link per unit, no writes to /srv/furtka/www/). - test_app's legacy "payload present" / "payload absent" tests match the new layout too. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-16 13:15:59 +02:00
def test_no_command_writes_to_retired_srv_path(install_cmds):
for c in install_cmds:
assert "/srv/furtka/www" not in c, f"retired /srv/furtka/www write: {c[:120]}"
def test_resource_manager_extracts_to_versioned_slot(install_cmds):
extract_cmd = next((c for c in install_cmds if "tar -xzf" in c), None)
assert extract_cmd is not None, "no tar extract command"
assert "/opt/furtka/versions" in extract_cmd
assert "staging-" in extract_cmd # mktemp -d pattern
assert 'cat "$staging/VERSION"' in extract_cmd
fix(furtka): pre-ISO audit fixes — chmod, Caddyfile refresh, unit linking Five issues surfaced by the Phase-2 audit before the next ISO rebuild: P1 (real blockers for a fresh install / self-update): 1. chmod +x furtka/assets/bin/furtka-status, furtka-welcome. They were mode 644 in git, so the tarball shipped them non-executable and every ExecStart referencing /opt/furtka/current/assets/bin/furtka-* would have failed on first boot with Permission denied. 2. apply_update now refreshes /etc/caddy/Caddyfile from the new version when the content differs, then reloads caddy. Without this, a release that changes Caddy routes silently stays on the old config. 3. apply_update now systemctl-links any new unit files shipped by the update, not just the five linked at install time. A future release that adds furtka-foo.service would otherwise never appear in /etc/systemd/system/. P2 (hardening, not blockers today): 4. _resource_manager_commands now aborts the install if the tarball's VERSION file is empty — otherwise `mv "$staging" /opt/furtka/versions/` would move the staging dir in as a subdirectory and the symlink target would be invalid. 5. _extract_tarball passes filter='data' to tarfile.extractall on Python 3.12+ to catch symlink-escape / setuid / device-node tricks that the regex path-check can't see. Falls back silently on older interpreters. Plus the CHANGELOG [Unreleased] section got filled in with the whole Phase-1 + Phase-2 + UI-uplevel body so a 26.1-alpha tag cut off main has meaningful release notes. Test additions / updates: - test_refresh_caddyfile_{copies_when_different,noops_if_source_missing} - test_link_new_units_only_links_missing - test_extract_tarball_uses_data_filter_when_available - test_apply_update_happy_path now verifies the Caddyfile gets copied. - test_resource_manager_extracts_to_versioned_slot verifies the empty-VERSION guard is present in the install command. Paths now overridable via FURTKA_CADDYFILE_PATH + FURTKA_SYSTEMD_DIR so tests can pin a tmpdir for these new fs operations. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-16 14:10:07 +02:00
# An empty VERSION file must abort the install instead of silently
# moving the staging dir into versions/ as a subdir.
assert '[ -n "$ver" ]' in extract_cmd
# Version dir must be 755 so Caddy (non-root) can traverse it.
assert 'chmod 755 "/opt/furtka/versions/$ver"' in extract_cmd
feat(furtka): serve from /opt/furtka/current, retire /srv/furtka/www/ Slice 1b of the self-update story. The installer now sets up a versioned layout — install extracts the resource-manager tarball to a staging dir, reads the VERSION it contains, moves the dir to /opt/furtka/versions/<ver>/, and creates /opt/furtka/current as a symlink pointing at it. All runtime references (Caddy, wrapper, systemd ExecStart) go through /current, so Phase 2's self-update just flips the symlink atomically. Systemd units move from hand-written files in /etc/systemd/system/ to `systemctl link /opt/furtka/current/assets/systemd/*` — one link per unit, stable across upgrades because the link target is /current. The furtka-status + furtka-welcome units now ExecStart the shipped scripts directly from /opt/furtka/current/assets/bin/, which means we no longer copy those scripts to /usr/local/bin/ at install time. Runtime JSON (status.json, furtka.json, update-state.json) moves to /var/lib/furtka/ so self-updates never clobber it. Caddy serves those three paths from there; everything else from /opt/furtka/current/assets/www/. The __HOSTNAME__ sed-template hack is gone. At install time we write /var/lib/furtka/furtka.json with {hostname, install_date, version}, and the landing page's JS reads it on load to populate the hostname chip and to build the SMB deep-link for the fileshare tile. First paint gets a "—" placeholder and hydrates once fetch completes. Test updates: - test_webinstaller_assets enforces the new command shape (extract-to- staging, ln -sfn /opt/furtka/current, systemctl link per unit, no writes to /srv/furtka/www/). - test_app's legacy "payload present" / "payload absent" tests match the new layout too. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-16 13:15:59 +02:00
assert 'ln -sfn "/opt/furtka/versions/$ver" /opt/furtka/current' in extract_cmd
def test_furtka_json_cmd_uses_heredoc_and_interpolates_hostname(install_cmds):
# Regression: the previous base64+sed version of this command was a
# silent no-op on some installs due to archinstall-side quoting; the
# heredoc version is the one that reliably writes furtka.json.
cmd = next((c for c in install_cmds if "/var/lib/furtka/furtka.json" in c), None)
assert cmd is not None
assert "cat > /var/lib/furtka/furtka.json <<EOF" in cmd
assert '"hostname": "testhost"' in cmd
assert "date -Iseconds" in cmd
assert "/opt/furtka/current/VERSION" in cmd
feat(furtka): serve from /opt/furtka/current, retire /srv/furtka/www/ Slice 1b of the self-update story. The installer now sets up a versioned layout — install extracts the resource-manager tarball to a staging dir, reads the VERSION it contains, moves the dir to /opt/furtka/versions/<ver>/, and creates /opt/furtka/current as a symlink pointing at it. All runtime references (Caddy, wrapper, systemd ExecStart) go through /current, so Phase 2's self-update just flips the symlink atomically. Systemd units move from hand-written files in /etc/systemd/system/ to `systemctl link /opt/furtka/current/assets/systemd/*` — one link per unit, stable across upgrades because the link target is /current. The furtka-status + furtka-welcome units now ExecStart the shipped scripts directly from /opt/furtka/current/assets/bin/, which means we no longer copy those scripts to /usr/local/bin/ at install time. Runtime JSON (status.json, furtka.json, update-state.json) moves to /var/lib/furtka/ so self-updates never clobber it. Caddy serves those three paths from there; everything else from /opt/furtka/current/assets/www/. The __HOSTNAME__ sed-template hack is gone. At install time we write /var/lib/furtka/furtka.json with {hostname, install_date, version}, and the landing page's JS reads it on load to populate the hostname chip and to build the SMB deep-link for the fileshare tile. First paint gets a "—" placeholder and hydrates once fetch completes. Test updates: - test_webinstaller_assets enforces the new command shape (extract-to- staging, ln -sfn /opt/furtka/current, systemctl link per unit, no writes to /srv/furtka/www/). - test_app's legacy "payload present" / "payload absent" tests match the new layout too. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-16 13:15:59 +02:00
def test_resource_manager_systemctl_links_every_unit(install_cmds):
link_cmd = next((c for c in install_cmds if c.startswith("systemctl link ")), None)
assert link_cmd is not None, "no systemctl link command"
for unit in app._FURTKA_UNITS:
assert f"/opt/furtka/current/assets/systemd/{unit}" in link_cmd
def test_resource_manager_enables_all_units(install_cmds):
enable_cmd = next((c for c in install_cmds if c.startswith("systemctl enable ")), None)
assert enable_cmd is not None
for unit in app._FURTKA_UNITS:
assert unit in enable_cmd
def test_wrapper_script_points_at_current_symlink():
assert "PYTHONPATH=/opt/furtka/current" in app._FURTKA_WRAPPER_SH
def test_caddyfile_asset_serves_from_current():
caddy = (ASSETS / "Caddyfile").read_text()
assert "root * /opt/furtka/current/assets/www" in caddy
assert "/srv/furtka/www" not in caddy
# Runtime JSON paths served from /var/lib/furtka/ so updates don't clobber.
assert "root * /var/lib/furtka" in caddy
def test_caddyfile_serves_both_http_and_https():
# :80 stays so users who haven't installed the CA still reach the box;
fix(https): restore TLS handshake — name hostname + correct PKI path 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>
2026-04-20 11:38:16 +02:00
# HTTPS is served via a named-hostname block so Caddy's `tls internal`
# has something to issue a leaf cert for. A bare `:443 { tls internal }`
# never triggers issuance — that was the 26.4-alpha regression.
caddy = (ASSETS / "Caddyfile").read_text()
assert ":80 {" in caddy
fix(https): restore TLS handshake — name hostname + correct PKI path 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>
2026-04-20 11:38:16 +02:00
assert "__FURTKA_HOSTNAME__.local, __FURTKA_HOSTNAME__ {" 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
fix(https): restore TLS handshake — name hostname + correct PKI path 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>
2026-04-20 11:38:16 +02:00
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()
assert "auto_https disable_redirects" in caddy
def test_caddyfile_imports_force_redirect_snippet_dir():
# The /api/furtka/https/force endpoint toggles HTTP→HTTPS by writing or
# removing a snippet file in this dir; the Caddyfile must glob-import it
# inside the :80 block for the toggle to take effect.
caddy = (ASSETS / "Caddyfile").read_text()
assert "import /etc/caddy/furtka.d/*.caddyfile" in caddy
def test_caddyfile_exposes_root_ca_download():
# /rootCA.crt is the download handle the UI uses. It must map to the
# Caddy local-CA pki path and set a Content-Disposition so the browser
fix(https): restore TLS handshake — name hostname + correct PKI path 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>
2026-04-20 11:38:16 +02:00
# treats it as a download rather than trying to render it. Path is the
# real one Caddy uses under XDG_DATA_HOME=/var/lib (see caddy.service
# Environment= directive) — not the /var/lib/caddy/.local/share/caddy/
# path Caddy docs show for non-systemd installs.
caddy = (ASSETS / "Caddyfile").read_text()
assert "handle /rootCA.crt" in caddy
fix(https): restore TLS handshake — name hostname + correct PKI path 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>
2026-04-20 11:38:16 +02:00
assert "/var/lib/caddy/pki/authorities/local" in caddy
assert ".local/share/caddy" not in caddy
assert "attachment; filename=furtka-local-rootCA.crt" in caddy
fix(https): restore TLS handshake — name hostname + correct PKI path 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>
2026-04-20 11:38:16 +02:00
def test_post_install_substitutes_hostname_in_caddyfile(install_cmds):
# Fresh installs: the placeholder the asset ships with must be replaced
# with the hostname the user picked in the form. The `testhost` value
# comes from the install_cmds fixture. Without substitution Caddy's
# `tls internal` never issues a leaf cert for the real hostname.
caddyfile_cmd = next((c for c in install_cmds if " > /etc/caddy/Caddyfile" in c), None)
fix(https): restore TLS handshake — name hostname + correct PKI path 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>
2026-04-20 11:38:16 +02:00
assert caddyfile_cmd is not None
written = _extract_written_content(caddyfile_cmd, "/etc/caddy/Caddyfile")
assert "__FURTKA_HOSTNAME__" not in written
assert "testhost.local, testhost {" in written
def test_post_install_creates_furtka_d_snippet_dir(install_cmds):
# Pre-existing installs pick up the import path via updater._refresh_caddyfile,
# but fresh installs never run that — this command is the only guarantee
# that the first Caddy start on a brand-new box has a dir to glob-import.
matching = [c for c in install_cmds if "/etc/caddy/furtka.d" in c and "install -d" in c]
assert matching, "no install -d command creates /etc/caddy/furtka.d"
feat(furtka): serve from /opt/furtka/current, retire /srv/furtka/www/ Slice 1b of the self-update story. The installer now sets up a versioned layout — install extracts the resource-manager tarball to a staging dir, reads the VERSION it contains, moves the dir to /opt/furtka/versions/<ver>/, and creates /opt/furtka/current as a symlink pointing at it. All runtime references (Caddy, wrapper, systemd ExecStart) go through /current, so Phase 2's self-update just flips the symlink atomically. Systemd units move from hand-written files in /etc/systemd/system/ to `systemctl link /opt/furtka/current/assets/systemd/*` — one link per unit, stable across upgrades because the link target is /current. The furtka-status + furtka-welcome units now ExecStart the shipped scripts directly from /opt/furtka/current/assets/bin/, which means we no longer copy those scripts to /usr/local/bin/ at install time. Runtime JSON (status.json, furtka.json, update-state.json) moves to /var/lib/furtka/ so self-updates never clobber it. Caddy serves those three paths from there; everything else from /opt/furtka/current/assets/www/. The __HOSTNAME__ sed-template hack is gone. At install time we write /var/lib/furtka/furtka.json with {hostname, install_date, version}, and the landing page's JS reads it on load to populate the hostname chip and to build the SMB deep-link for the fileshare tile. First paint gets a "—" placeholder and hydrates once fetch completes. Test updates: - test_webinstaller_assets enforces the new command shape (extract-to- staging, ln -sfn /opt/furtka/current, systemctl link per unit, no writes to /srv/furtka/www/). - test_app's legacy "payload present" / "payload absent" tests match the new layout too. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-16 13:15:59 +02:00
def test_systemd_units_reference_current_paths():
for unit in ("furtka-status.service", "furtka-welcome.service"):
body = (ASSETS / "systemd" / unit).read_text()
assert "/opt/furtka/current/assets/bin/" in body, (
f"{unit} still references a /usr/local/bin path"
)
refactor(webinstaller): extract inline payload constants to furtka/assets/ Slice 1a of the self-update story. Every HTML/CSS/shell-script/systemd- unit payload that used to live as a triple-quoted string constant inside webinstaller/app.py now lives as a real file under furtka/assets/: furtka/assets/Caddyfile furtka/assets/VERSION (new — matches pyproject.toml) furtka/assets/www/{index.html, settings/index.html, style.css, status.json} furtka/assets/bin/{furtka-status, furtka-welcome} furtka/assets/systemd/furtka-{api,reconcile,status,welcome}.service furtka/assets/systemd/furtka-status.timer The installer now pulls each file from disk via _read_asset(). Byte-for- byte identical output at install time — a fresh-ISO install should land the same files in the same places with the same contents, verified by tests/test_webinstaller_assets.py which reconstructs each base64 blob and asserts equality against the on-disk asset. iso/build.sh also copies furtka/assets/ next to the webinstaller source at /opt/furtka/assets on the live ISO so _resolve_assets_dir() finds them with a "next to me" lookup. In dev the same function walks two levels up to the repo copy, so pytest works without any env vars. furtka-status.sh drops the /etc/furtka/version TODO — it now reads /opt/furtka/VERSION directly, which Slice 1b will upgrade to /opt/furtka/current/VERSION once the symlink layout lands. _FURTKA_WRAPPER_SH (the 5-line /usr/local/bin/furtka shim) stays inline; it's tiny and not asset-shaped. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-16 13:08:53 +02:00
def test_read_asset_raises_for_missing_file():
with pytest.raises(FileNotFoundError):
app._read_asset("does/not/exist.html")
def test_assets_dir_resolves_to_repo_tree():
assert app._ASSETS_DIR == ASSETS
feat(auth): login-guard the Furtka UI with a cookie session One-admin, one-password model — all of /apps, /api/*, /, and /settings/ now require a signed-in session. Passwords are werkzeug PBKDF2-hashed in /var/lib/furtka/users.json (mode 0600, atomic write via the same .tmp+chmod+rename dance installer.write_env uses). Sessions are secrets.token_urlsafe(32) tokens held in a module-level SessionStore dict (thread-safe lock included for when we swap to ThreadingHTTPServer). Cookies are HttpOnly, SameSite=Strict, and Path=/, with Secure set when X-Forwarded-Proto from Caddy says HTTPS. Two bootstrap paths: * Fresh install — webinstaller step-1 collects Linux user + password, the chroot post-install step hashes the password and writes users.json on the target partition. First browser visit lands on /login with the account already present. * Upgrade from 26.10-alpha — no users.json yet, so /login detects setup_needed() and renders a first-run setup form. POST creates the admin and immediately logs in. POST /logout revokes the server session and clears the cookie. Unauthenticated HTML requests 302 to /login; unauthenticated API requests 401 JSON so fetch() callers see a clean error. A sleep(0.5) on failed logins is the brute-force speed bump on top of werkzeug's ~600k-iter PBKDF2. Caddyfile gains /login* and /logout* handle blocks in the shared furtka_routes snippet so both :80 and the HTTPS hostname block forward the auth endpoints to localhost:7000. Without this Caddy would 404 from the static file server. Test surface: * tests/test_auth.py (new, 19 cases): hash roundtrip, users.json I/O, session create/lookup/expire/revoke. * tests/test_api.py: new admin_session fixture; existing HTTP tests updated to send the cookie; new tests cover login setup, login success, wrong-password 401, logout revocation, and the guard's 302/401 split. * tests/test_webinstaller_assets.py: new case that unpacks the users.json _write_file_cmd body and verifies the werkzeug hash round-trips against the step-1 password. Bumped version to 26.11-alpha and rolled CHANGELOG. Also folded in the ruff-format fix that was pending from 26.10-alpha's lint red. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-21 13:01:17 +02:00
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")