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
|
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
|
2026-04-16 15:46:56 +02:00
|
|
|
# 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
|
|
|
|
|
|
|
|
|
|
|
2026-04-16 15:46:56 +02:00
|
|
|
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
|
|
|
|
|
|
|
|
|
|
|
fix(https): make HTTPS opt-in to stop the BAD_SIGNATURE trap on fresh installs
Every Furtka since 26.5 shipped a Caddyfile with a
`__FURTKA_HOSTNAME__.local { tls internal }` site block, so every
first boot auto-generated a fresh self-signed CA + intermediate +
leaf. That worked for the first-ever Furtka user, but every reinstall
(or second box on the same LAN) produced a new CA whose intermediate
shared the fixed CN `Caddy Local Authority - ECC Intermediate` with
the previous one. Firefox caches intermediates by CN across profiles
— even private windows share cert9.db — so any visitor who had
trusted an older Furtka's CA got a cached intermediate with
mismatched keys when they hit the new box, producing
`SEC_ERROR_BAD_SIGNATURE`. Unlike UNKNOWN_ISSUER, Firefox has NO
"Advanced → Accept Risk" bypass for BAD_SIGNATURE, so fresh-install
boxes were effectively unreachable over HTTPS in any browser that
had ever seen a previous Furtka.
Validated live on the .46 test VM: fresh 26.14 ISO install → Firefox
hits BAD_SIGNATURE on https://furtka.local/ (even in private mode).
Chromium bypasses it via mDNS failure but the issue is the same.
openssl verify on the box confirms the chain is internally valid —
this is purely client-side cache pollution across boxes.
Fix:
- assets/Caddyfile: removed the hostname site block. Default install
serves :80 only — https://furtka.local connection-refuses, which is
a normal error every browser handles instead of the unbypassable
crypto fault. Added top-level import of
/etc/caddy/furtka-https.d/*.caddyfile so the /settings HTTPS toggle
can drop a listener snippet there when a user explicitly opts in.
- furtka/https.py: set_force_https now writes TWO snippets atomically
— the top-level hostname + tls internal block (enables :443) and
the :80-scoped redirect (forces HTTP→HTTPS). Disable removes both.
Reload failure rolls both back. Added _read_hostname + _https_snippet_content
helpers with `/etc/hostname` → 'furtka' fallback so a missing
hostname file doesn't produce an empty site block Caddy rejects.
- furtka/https.py::status: force_https now reads the listener
snippet (was reading the redirect snippet). A redirect without a
listener isn't actually HTTPS being served, so the listener is the
authoritative "HTTPS is on" signal.
- furtka/updater.py: new _maybe_migrate_preserve_https hook runs
inside _refresh_caddyfile on the 26.14 → 26.15 transition. If the
box had the redirect snippet on disk (user had opted into HTTPS
under the old regime), it writes the new listener snippet too so
HTTPS keeps working after the Caddyfile swap removes the hostname
block.
- webinstaller/app.py: post-install creates /etc/caddy/furtka-https.d/
alongside /etc/caddy/furtka.d/ so the glob import can't trip an
older Caddy on a missing path during the first reload.
Live-tested on .46: set_force_https(True) writes both snippets, Caddy
reloads, :443 listener comes up with fresh CA, curl -k returns 302,
HTTP 301-redirects. set_force_https(False) removes both snippets
atomically, :443 goes back to connection-refused.
Tests: test_https.py expanded from 13 to 15 cases. Toggle-on asserts
both snippets written + hostname substituted. Toggle-off asserts
both removed. Rollback cases verify BOTH snippets restore on reload
failure. New test_https_snippet_content_has_tls_internal_and_routes
locks the exact shape of the listener block.
test_webinstaller_assets.py: updated two old asserts that assumed
hostname block was in Caddyfile; new test_post_install_creates_https_snippet_dir
guards the new directory.
276 tests pass, ruff check + format clean.
Known remaining wart (documented in CHANGELOG): a browser that
trusted a prior Furtka CA still hits BAD_SIGNATURE on this box's
HTTPS after enabling it, because the fixed intermediate CN is a
Caddy-side limitation. Workaround: clear cert9.db or visit in a
fresh profile. Won't affect end users with one Furtka box ever.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-21 19:30:04 +02:00
|
|
|
def _strip_caddy_comments(text: str) -> str:
|
|
|
|
|
"""Remove # comments + blank lines so string-match assertions can
|
|
|
|
|
target actual Caddyfile directives, not the leading doc block.
|
|
|
|
|
Comment intro is ``#`` at start-of-line or preceded by whitespace."""
|
|
|
|
|
out = []
|
|
|
|
|
for line in text.splitlines():
|
|
|
|
|
stripped = line.split("#", 1)[0].rstrip()
|
|
|
|
|
if stripped:
|
|
|
|
|
out.append(stripped)
|
|
|
|
|
return "\n".join(out)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def test_caddyfile_serves_http_by_default_https_opt_in():
|
|
|
|
|
# 26.15-alpha: HTTPS is opt-in. The default Caddyfile has a :80 block
|
|
|
|
|
# and imports /etc/caddy/furtka-https.d/*.caddyfile at top level —
|
|
|
|
|
# the /settings HTTPS toggle drops the hostname+tls-internal block
|
|
|
|
|
# into that dir when the user explicitly enables HTTPS. Default
|
|
|
|
|
# Caddyfile therefore contains no `tls internal` directive anywhere;
|
|
|
|
|
# if a future refactor puts it back, every fresh install regresses
|
|
|
|
|
# to the 26.14-era BAD_SIGNATURE trap. Strip comments first because
|
|
|
|
|
# the doc-block DOES mention `tls internal` in prose.
|
|
|
|
|
caddy_full = (ASSETS / "Caddyfile").read_text()
|
|
|
|
|
caddy = _strip_caddy_comments(caddy_full)
|
2026-04-17 12:19:06 +02:00
|
|
|
assert ":80 {" in caddy
|
fix(https): make HTTPS opt-in to stop the BAD_SIGNATURE trap on fresh installs
Every Furtka since 26.5 shipped a Caddyfile with a
`__FURTKA_HOSTNAME__.local { tls internal }` site block, so every
first boot auto-generated a fresh self-signed CA + intermediate +
leaf. That worked for the first-ever Furtka user, but every reinstall
(or second box on the same LAN) produced a new CA whose intermediate
shared the fixed CN `Caddy Local Authority - ECC Intermediate` with
the previous one. Firefox caches intermediates by CN across profiles
— even private windows share cert9.db — so any visitor who had
trusted an older Furtka's CA got a cached intermediate with
mismatched keys when they hit the new box, producing
`SEC_ERROR_BAD_SIGNATURE`. Unlike UNKNOWN_ISSUER, Firefox has NO
"Advanced → Accept Risk" bypass for BAD_SIGNATURE, so fresh-install
boxes were effectively unreachable over HTTPS in any browser that
had ever seen a previous Furtka.
Validated live on the .46 test VM: fresh 26.14 ISO install → Firefox
hits BAD_SIGNATURE on https://furtka.local/ (even in private mode).
Chromium bypasses it via mDNS failure but the issue is the same.
openssl verify on the box confirms the chain is internally valid —
this is purely client-side cache pollution across boxes.
Fix:
- assets/Caddyfile: removed the hostname site block. Default install
serves :80 only — https://furtka.local connection-refuses, which is
a normal error every browser handles instead of the unbypassable
crypto fault. Added top-level import of
/etc/caddy/furtka-https.d/*.caddyfile so the /settings HTTPS toggle
can drop a listener snippet there when a user explicitly opts in.
- furtka/https.py: set_force_https now writes TWO snippets atomically
— the top-level hostname + tls internal block (enables :443) and
the :80-scoped redirect (forces HTTP→HTTPS). Disable removes both.
Reload failure rolls both back. Added _read_hostname + _https_snippet_content
helpers with `/etc/hostname` → 'furtka' fallback so a missing
hostname file doesn't produce an empty site block Caddy rejects.
- furtka/https.py::status: force_https now reads the listener
snippet (was reading the redirect snippet). A redirect without a
listener isn't actually HTTPS being served, so the listener is the
authoritative "HTTPS is on" signal.
- furtka/updater.py: new _maybe_migrate_preserve_https hook runs
inside _refresh_caddyfile on the 26.14 → 26.15 transition. If the
box had the redirect snippet on disk (user had opted into HTTPS
under the old regime), it writes the new listener snippet too so
HTTPS keeps working after the Caddyfile swap removes the hostname
block.
- webinstaller/app.py: post-install creates /etc/caddy/furtka-https.d/
alongside /etc/caddy/furtka.d/ so the glob import can't trip an
older Caddy on a missing path during the first reload.
Live-tested on .46: set_force_https(True) writes both snippets, Caddy
reloads, :443 listener comes up with fresh CA, curl -k returns 302,
HTTP 301-redirects. set_force_https(False) removes both snippets
atomically, :443 goes back to connection-refused.
Tests: test_https.py expanded from 13 to 15 cases. Toggle-on asserts
both snippets written + hostname substituted. Toggle-off asserts
both removed. Rollback cases verify BOTH snippets restore on reload
failure. New test_https_snippet_content_has_tls_internal_and_routes
locks the exact shape of the listener block.
test_webinstaller_assets.py: updated two old asserts that assumed
hostname block was in Caddyfile; new test_post_install_creates_https_snippet_dir
guards the new directory.
276 tests pass, ruff check + format clean.
Known remaining wart (documented in CHANGELOG): a browser that
trusted a prior Furtka CA still hits BAD_SIGNATURE on this box's
HTTPS after enabling it, because the fixed intermediate CN is a
Caddy-side limitation. Workaround: clear cert9.db or visit in a
fresh profile. Won't affect end users with one Furtka box ever.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-21 19:30:04 +02:00
|
|
|
assert "tls internal" not in caddy
|
|
|
|
|
assert "__FURTKA_HOSTNAME__" not in caddy
|
|
|
|
|
assert "import /etc/caddy/furtka-https.d/*.caddyfile" in caddy
|
|
|
|
|
# Shared routes still live in a named snippet so the HTTPS toggle's
|
|
|
|
|
# snippet can import the same routes without duplication.
|
2026-04-17 12:19:06 +02:00
|
|
|
assert "(furtka_routes)" in caddy
|
fix(https): make HTTPS opt-in to stop the BAD_SIGNATURE trap on fresh installs
Every Furtka since 26.5 shipped a Caddyfile with a
`__FURTKA_HOSTNAME__.local { tls internal }` site block, so every
first boot auto-generated a fresh self-signed CA + intermediate +
leaf. That worked for the first-ever Furtka user, but every reinstall
(or second box on the same LAN) produced a new CA whose intermediate
shared the fixed CN `Caddy Local Authority - ECC Intermediate` with
the previous one. Firefox caches intermediates by CN across profiles
— even private windows share cert9.db — so any visitor who had
trusted an older Furtka's CA got a cached intermediate with
mismatched keys when they hit the new box, producing
`SEC_ERROR_BAD_SIGNATURE`. Unlike UNKNOWN_ISSUER, Firefox has NO
"Advanced → Accept Risk" bypass for BAD_SIGNATURE, so fresh-install
boxes were effectively unreachable over HTTPS in any browser that
had ever seen a previous Furtka.
Validated live on the .46 test VM: fresh 26.14 ISO install → Firefox
hits BAD_SIGNATURE on https://furtka.local/ (even in private mode).
Chromium bypasses it via mDNS failure but the issue is the same.
openssl verify on the box confirms the chain is internally valid —
this is purely client-side cache pollution across boxes.
Fix:
- assets/Caddyfile: removed the hostname site block. Default install
serves :80 only — https://furtka.local connection-refuses, which is
a normal error every browser handles instead of the unbypassable
crypto fault. Added top-level import of
/etc/caddy/furtka-https.d/*.caddyfile so the /settings HTTPS toggle
can drop a listener snippet there when a user explicitly opts in.
- furtka/https.py: set_force_https now writes TWO snippets atomically
— the top-level hostname + tls internal block (enables :443) and
the :80-scoped redirect (forces HTTP→HTTPS). Disable removes both.
Reload failure rolls both back. Added _read_hostname + _https_snippet_content
helpers with `/etc/hostname` → 'furtka' fallback so a missing
hostname file doesn't produce an empty site block Caddy rejects.
- furtka/https.py::status: force_https now reads the listener
snippet (was reading the redirect snippet). A redirect without a
listener isn't actually HTTPS being served, so the listener is the
authoritative "HTTPS is on" signal.
- furtka/updater.py: new _maybe_migrate_preserve_https hook runs
inside _refresh_caddyfile on the 26.14 → 26.15 transition. If the
box had the redirect snippet on disk (user had opted into HTTPS
under the old regime), it writes the new listener snippet too so
HTTPS keeps working after the Caddyfile swap removes the hostname
block.
- webinstaller/app.py: post-install creates /etc/caddy/furtka-https.d/
alongside /etc/caddy/furtka.d/ so the glob import can't trip an
older Caddy on a missing path during the first reload.
Live-tested on .46: set_force_https(True) writes both snippets, Caddy
reloads, :443 listener comes up with fresh CA, curl -k returns 302,
HTTP 301-redirects. set_force_https(False) removes both snippets
atomically, :443 goes back to connection-refused.
Tests: test_https.py expanded from 13 to 15 cases. Toggle-on asserts
both snippets written + hostname substituted. Toggle-off asserts
both removed. Rollback cases verify BOTH snippets restore on reload
failure. New test_https_snippet_content_has_tls_internal_and_routes
locks the exact shape of the listener block.
test_webinstaller_assets.py: updated two old asserts that assumed
hostname block was in Caddyfile; new test_post_install_creates_https_snippet_dir
guards the new directory.
276 tests pass, ruff check + format clean.
Known remaining wart (documented in CHANGELOG): a browser that
trusted a prior Furtka CA still hits BAD_SIGNATURE on this box's
HTTPS after enabling it, because the fixed intermediate CN is a
Caddy-side limitation. Workaround: clear cert9.db or visit in a
fresh profile. Won't affect end users with one Furtka box ever.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-21 19:30:04 +02:00
|
|
|
# Default Caddyfile imports it once (inside :80). The HTTPS snippet,
|
|
|
|
|
# when written by the toggle, imports it a second time.
|
|
|
|
|
assert caddy.count("import furtka_routes") == 1
|
2026-04-17 12:19:06 +02:00
|
|
|
|
|
|
|
|
|
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
|
|
|
|
|
|
|
|
|
|
|
2026-04-17 12:19:06 +02:00
|
|
|
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.
|
2026-04-17 12:19:06 +02:00
|
|
|
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
|
2026-04-18 11:41:28 +02:00
|
|
|
assert "attachment; filename=furtka-local-rootCA.crt" in caddy
|
2026-04-17 12:19:06 +02:00
|
|
|
|
|
|
|
|
|
fix(https): make HTTPS opt-in to stop the BAD_SIGNATURE trap on fresh installs
Every Furtka since 26.5 shipped a Caddyfile with a
`__FURTKA_HOSTNAME__.local { tls internal }` site block, so every
first boot auto-generated a fresh self-signed CA + intermediate +
leaf. That worked for the first-ever Furtka user, but every reinstall
(or second box on the same LAN) produced a new CA whose intermediate
shared the fixed CN `Caddy Local Authority - ECC Intermediate` with
the previous one. Firefox caches intermediates by CN across profiles
— even private windows share cert9.db — so any visitor who had
trusted an older Furtka's CA got a cached intermediate with
mismatched keys when they hit the new box, producing
`SEC_ERROR_BAD_SIGNATURE`. Unlike UNKNOWN_ISSUER, Firefox has NO
"Advanced → Accept Risk" bypass for BAD_SIGNATURE, so fresh-install
boxes were effectively unreachable over HTTPS in any browser that
had ever seen a previous Furtka.
Validated live on the .46 test VM: fresh 26.14 ISO install → Firefox
hits BAD_SIGNATURE on https://furtka.local/ (even in private mode).
Chromium bypasses it via mDNS failure but the issue is the same.
openssl verify on the box confirms the chain is internally valid —
this is purely client-side cache pollution across boxes.
Fix:
- assets/Caddyfile: removed the hostname site block. Default install
serves :80 only — https://furtka.local connection-refuses, which is
a normal error every browser handles instead of the unbypassable
crypto fault. Added top-level import of
/etc/caddy/furtka-https.d/*.caddyfile so the /settings HTTPS toggle
can drop a listener snippet there when a user explicitly opts in.
- furtka/https.py: set_force_https now writes TWO snippets atomically
— the top-level hostname + tls internal block (enables :443) and
the :80-scoped redirect (forces HTTP→HTTPS). Disable removes both.
Reload failure rolls both back. Added _read_hostname + _https_snippet_content
helpers with `/etc/hostname` → 'furtka' fallback so a missing
hostname file doesn't produce an empty site block Caddy rejects.
- furtka/https.py::status: force_https now reads the listener
snippet (was reading the redirect snippet). A redirect without a
listener isn't actually HTTPS being served, so the listener is the
authoritative "HTTPS is on" signal.
- furtka/updater.py: new _maybe_migrate_preserve_https hook runs
inside _refresh_caddyfile on the 26.14 → 26.15 transition. If the
box had the redirect snippet on disk (user had opted into HTTPS
under the old regime), it writes the new listener snippet too so
HTTPS keeps working after the Caddyfile swap removes the hostname
block.
- webinstaller/app.py: post-install creates /etc/caddy/furtka-https.d/
alongside /etc/caddy/furtka.d/ so the glob import can't trip an
older Caddy on a missing path during the first reload.
Live-tested on .46: set_force_https(True) writes both snippets, Caddy
reloads, :443 listener comes up with fresh CA, curl -k returns 302,
HTTP 301-redirects. set_force_https(False) removes both snippets
atomically, :443 goes back to connection-refused.
Tests: test_https.py expanded from 13 to 15 cases. Toggle-on asserts
both snippets written + hostname substituted. Toggle-off asserts
both removed. Rollback cases verify BOTH snippets restore on reload
failure. New test_https_snippet_content_has_tls_internal_and_routes
locks the exact shape of the listener block.
test_webinstaller_assets.py: updated two old asserts that assumed
hostname block was in Caddyfile; new test_post_install_creates_https_snippet_dir
guards the new directory.
276 tests pass, ruff check + format clean.
Known remaining wart (documented in CHANGELOG): a browser that
trusted a prior Furtka CA still hits BAD_SIGNATURE on this box's
HTTPS after enabling it, because the fixed intermediate CN is a
Caddy-side limitation. Workaround: clear cert9.db or visit in a
fresh profile. Won't affect end users with one Furtka box ever.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-21 19:30:04 +02:00
|
|
|
def test_post_install_writes_caddyfile_without_hostname_placeholder(install_cmds):
|
|
|
|
|
# 26.15-alpha: the shipped Caddyfile no longer carries the
|
|
|
|
|
# __FURTKA_HOSTNAME__ marker — HTTPS + hostname now live in the
|
|
|
|
|
# opt-in snippet written by set_force_https(), not in the base
|
|
|
|
|
# Caddyfile. Verify the post-install writes the file as-is (no
|
|
|
|
|
# substitution expected) and it has the opt-in import glob.
|
2026-04-20 12:41:58 +02:00
|
|
|
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
|
fix(https): make HTTPS opt-in to stop the BAD_SIGNATURE trap on fresh installs
Every Furtka since 26.5 shipped a Caddyfile with a
`__FURTKA_HOSTNAME__.local { tls internal }` site block, so every
first boot auto-generated a fresh self-signed CA + intermediate +
leaf. That worked for the first-ever Furtka user, but every reinstall
(or second box on the same LAN) produced a new CA whose intermediate
shared the fixed CN `Caddy Local Authority - ECC Intermediate` with
the previous one. Firefox caches intermediates by CN across profiles
— even private windows share cert9.db — so any visitor who had
trusted an older Furtka's CA got a cached intermediate with
mismatched keys when they hit the new box, producing
`SEC_ERROR_BAD_SIGNATURE`. Unlike UNKNOWN_ISSUER, Firefox has NO
"Advanced → Accept Risk" bypass for BAD_SIGNATURE, so fresh-install
boxes were effectively unreachable over HTTPS in any browser that
had ever seen a previous Furtka.
Validated live on the .46 test VM: fresh 26.14 ISO install → Firefox
hits BAD_SIGNATURE on https://furtka.local/ (even in private mode).
Chromium bypasses it via mDNS failure but the issue is the same.
openssl verify on the box confirms the chain is internally valid —
this is purely client-side cache pollution across boxes.
Fix:
- assets/Caddyfile: removed the hostname site block. Default install
serves :80 only — https://furtka.local connection-refuses, which is
a normal error every browser handles instead of the unbypassable
crypto fault. Added top-level import of
/etc/caddy/furtka-https.d/*.caddyfile so the /settings HTTPS toggle
can drop a listener snippet there when a user explicitly opts in.
- furtka/https.py: set_force_https now writes TWO snippets atomically
— the top-level hostname + tls internal block (enables :443) and
the :80-scoped redirect (forces HTTP→HTTPS). Disable removes both.
Reload failure rolls both back. Added _read_hostname + _https_snippet_content
helpers with `/etc/hostname` → 'furtka' fallback so a missing
hostname file doesn't produce an empty site block Caddy rejects.
- furtka/https.py::status: force_https now reads the listener
snippet (was reading the redirect snippet). A redirect without a
listener isn't actually HTTPS being served, so the listener is the
authoritative "HTTPS is on" signal.
- furtka/updater.py: new _maybe_migrate_preserve_https hook runs
inside _refresh_caddyfile on the 26.14 → 26.15 transition. If the
box had the redirect snippet on disk (user had opted into HTTPS
under the old regime), it writes the new listener snippet too so
HTTPS keeps working after the Caddyfile swap removes the hostname
block.
- webinstaller/app.py: post-install creates /etc/caddy/furtka-https.d/
alongside /etc/caddy/furtka.d/ so the glob import can't trip an
older Caddy on a missing path during the first reload.
Live-tested on .46: set_force_https(True) writes both snippets, Caddy
reloads, :443 listener comes up with fresh CA, curl -k returns 302,
HTTP 301-redirects. set_force_https(False) removes both snippets
atomically, :443 goes back to connection-refused.
Tests: test_https.py expanded from 13 to 15 cases. Toggle-on asserts
both snippets written + hostname substituted. Toggle-off asserts
both removed. Rollback cases verify BOTH snippets restore on reload
failure. New test_https_snippet_content_has_tls_internal_and_routes
locks the exact shape of the listener block.
test_webinstaller_assets.py: updated two old asserts that assumed
hostname block was in Caddyfile; new test_post_install_creates_https_snippet_dir
guards the new directory.
276 tests pass, ruff check + format clean.
Known remaining wart (documented in CHANGELOG): a browser that
trusted a prior Furtka CA still hits BAD_SIGNATURE on this box's
HTTPS after enabling it, because the fixed intermediate CN is a
Caddy-side limitation. Workaround: clear cert9.db or visit in a
fresh profile. Won't affect end users with one Furtka box ever.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-21 19:30:04 +02:00
|
|
|
written_full = _extract_written_content(caddyfile_cmd, "/etc/caddy/Caddyfile")
|
|
|
|
|
written = _strip_caddy_comments(written_full)
|
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__" not in written
|
fix(https): make HTTPS opt-in to stop the BAD_SIGNATURE trap on fresh installs
Every Furtka since 26.5 shipped a Caddyfile with a
`__FURTKA_HOSTNAME__.local { tls internal }` site block, so every
first boot auto-generated a fresh self-signed CA + intermediate +
leaf. That worked for the first-ever Furtka user, but every reinstall
(or second box on the same LAN) produced a new CA whose intermediate
shared the fixed CN `Caddy Local Authority - ECC Intermediate` with
the previous one. Firefox caches intermediates by CN across profiles
— even private windows share cert9.db — so any visitor who had
trusted an older Furtka's CA got a cached intermediate with
mismatched keys when they hit the new box, producing
`SEC_ERROR_BAD_SIGNATURE`. Unlike UNKNOWN_ISSUER, Firefox has NO
"Advanced → Accept Risk" bypass for BAD_SIGNATURE, so fresh-install
boxes were effectively unreachable over HTTPS in any browser that
had ever seen a previous Furtka.
Validated live on the .46 test VM: fresh 26.14 ISO install → Firefox
hits BAD_SIGNATURE on https://furtka.local/ (even in private mode).
Chromium bypasses it via mDNS failure but the issue is the same.
openssl verify on the box confirms the chain is internally valid —
this is purely client-side cache pollution across boxes.
Fix:
- assets/Caddyfile: removed the hostname site block. Default install
serves :80 only — https://furtka.local connection-refuses, which is
a normal error every browser handles instead of the unbypassable
crypto fault. Added top-level import of
/etc/caddy/furtka-https.d/*.caddyfile so the /settings HTTPS toggle
can drop a listener snippet there when a user explicitly opts in.
- furtka/https.py: set_force_https now writes TWO snippets atomically
— the top-level hostname + tls internal block (enables :443) and
the :80-scoped redirect (forces HTTP→HTTPS). Disable removes both.
Reload failure rolls both back. Added _read_hostname + _https_snippet_content
helpers with `/etc/hostname` → 'furtka' fallback so a missing
hostname file doesn't produce an empty site block Caddy rejects.
- furtka/https.py::status: force_https now reads the listener
snippet (was reading the redirect snippet). A redirect without a
listener isn't actually HTTPS being served, so the listener is the
authoritative "HTTPS is on" signal.
- furtka/updater.py: new _maybe_migrate_preserve_https hook runs
inside _refresh_caddyfile on the 26.14 → 26.15 transition. If the
box had the redirect snippet on disk (user had opted into HTTPS
under the old regime), it writes the new listener snippet too so
HTTPS keeps working after the Caddyfile swap removes the hostname
block.
- webinstaller/app.py: post-install creates /etc/caddy/furtka-https.d/
alongside /etc/caddy/furtka.d/ so the glob import can't trip an
older Caddy on a missing path during the first reload.
Live-tested on .46: set_force_https(True) writes both snippets, Caddy
reloads, :443 listener comes up with fresh CA, curl -k returns 302,
HTTP 301-redirects. set_force_https(False) removes both snippets
atomically, :443 goes back to connection-refused.
Tests: test_https.py expanded from 13 to 15 cases. Toggle-on asserts
both snippets written + hostname substituted. Toggle-off asserts
both removed. Rollback cases verify BOTH snippets restore on reload
failure. New test_https_snippet_content_has_tls_internal_and_routes
locks the exact shape of the listener block.
test_webinstaller_assets.py: updated two old asserts that assumed
hostname block was in Caddyfile; new test_post_install_creates_https_snippet_dir
guards the new directory.
276 tests pass, ruff check + format clean.
Known remaining wart (documented in CHANGELOG): a browser that
trusted a prior Furtka CA still hits BAD_SIGNATURE on this box's
HTTPS after enabling it, because the fixed intermediate CN is a
Caddy-side limitation. Workaround: clear cert9.db or visit in a
fresh profile. Won't affect end users with one Furtka box ever.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-21 19:30:04 +02:00
|
|
|
assert "import /etc/caddy/furtka-https.d/*.caddyfile" in written
|
|
|
|
|
assert "tls internal" not in written
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def test_post_install_creates_https_snippet_dir(install_cmds):
|
|
|
|
|
# The top-level HTTPS opt-in snippet dir must exist before Caddy's
|
|
|
|
|
# first start — its glob import tolerates an empty directory, but
|
|
|
|
|
# not a missing one on older Caddy builds. Parallel guarantee to
|
|
|
|
|
# test_post_install_creates_furtka_d_snippet_dir below.
|
|
|
|
|
matching = [c for c in install_cmds if "/etc/caddy/furtka-https.d" in c and "install -d" in c]
|
|
|
|
|
assert matching, "no install -d command creates /etc/caddy/furtka-https.d"
|
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
|
|
|
|
|
|
|
|
|
2026-04-17 12:19:06 +02:00
|
|
|
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")
|