furtka/assets/Caddyfile

103 lines
3.7 KiB
Text
Raw Normal View History

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
# Serves the Furtka landing page + live JSON on :80 (plain HTTP). HTTPS
# is **opt-in** — Caddy doesn't serve :443 until the user clicks the
# "Enable HTTPS" toggle on /settings, which drops an import snippet into
# /etc/caddy/furtka-https.d/. Default install has NO tls site block →
# Caddy never generates a self-signed CA / leaf cert → no
# SEC_ERROR_BAD_SIGNATURE when a user visits https://furtka.local before
# they've trusted anything. That was the 26.14-era regression this file
# exists to cure: the old Caddyfile always served :443 with a freshly-
# generated cert, and a browser that had ever trusted an older Furtka
# box's CA would reject the new one with an unbypassable bad-sig error.
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
#
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
# /apps, /api, /login, /logout, / (home), /settings are reverse-proxied
# to the resource-manager API (furtka serve, bound to 127.0.0.1:7000).
# Static pages are read from /opt/furtka/current/ — updates flip the
# symlink and everything picks up the new content without a Caddy
# restart (a `systemctl reload caddy` is still triggered post-swap to
# flush the file-server's handle cache).
#
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
# Two snippet dirs, both silently no-op when empty:
# - /etc/caddy/furtka.d/*.caddyfile → imported inside the :80 block.
# The HTTPS toggle's "force HTTP→HTTPS redirect" snippet lands here.
# - /etc/caddy/furtka-https.d/*.caddyfile → imported at TOP LEVEL, so
# the HTTPS hostname+tls-internal site block can drop in here when
# the toggle is on. Hostname is substituted at toggle-time.
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
{
# Named-hostname :443 blocks would otherwise make Caddy add its own
# HTTP→HTTPS redirect — but we already serve our own `:80` block and
# the opt-in /settings toggle owns the redirect. Disable the built-in
# to keep a single source of truth.
auto_https disable_redirects
}
(furtka_routes) {
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
handle /api/* {
reverse_proxy localhost:7000
}
handle /apps* {
reverse_proxy localhost:7000
}
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
handle /login* {
reverse_proxy localhost:7000
}
handle /logout* {
reverse_proxy localhost:7000
}
fix: auth-guard / and /settings, add Logout link to static navs Since 26.11 shipped login, two of the three nav pages were secretly unauthenticated. The Caddyfile only reverse-proxied /api/*, /apps*, /login*, /logout* to the Python auth-gated handler. Everything else — including / (landing page) and /settings/ — fell through to Caddy's catch-all file_server straight out of assets/www/, skipping the session check entirely. LAN visitor effect: they could read the box's hostname, IP, Furtka version, uptime, and see all the Update-now / Reboot / HTTPS-toggle buttons on /settings/. The API calls those buttons fired were themselves 401-gated so nothing actually happened — but the info leak plus "looks open" UX was real. Caught in the 26.13 SSH test session when the user noticed Logout only appeared in the nav on /apps, and not on / or /settings/. Fix: - Caddyfile: new `handle /settings*` and `handle /` blocks in the shared `(furtka_routes)` snippet reverse-proxy to localhost:7000, so both hit the Python auth-guard before the HTML goes out. - api.py: new `_serve_static_www(relative_path)` helper reads assets/www/{index.html, settings/index.html} with a path-traversal clamp (resolved path must stay under static_www_dir). `do_GET` routes `/` and `/settings[/]` to it. Removed the `/` branch from the old combined-with-/apps line — those are different pages now. - paths.py: new `static_www_dir()` helper with `FURTKA_STATIC_WWW` env override for tests. - assets/www/*.html: both nav bars get the Logout link + a shared `doLogout()` inline script matching the _HTML pattern. Users never see the link unauthed (the Python handler 302s them before the page renders), but authed users get consistent navigation across all three pages. Tests: 5 new cases in test_api.py — unauth / redirects, unauth /settings redirects (both trailing-slash and not), authed / serves index.html, authed /settings serves settings/index.html, regression guard that / and /apps serve different content. Existing test updated (the one that used / as a proxy for /apps). Static /style.css, /rootCA.crt, /status.json, /furtka.json, /update-state.json stay served by Caddy's catch-all — those are public by design (login page needs style.css, fresh users need the CA to trust HTTPS, runtime JSON is metadata not creds). 272 tests pass, ruff check + format clean. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-21 18:16:42 +02:00
# /settings and / — these previously served as static HTML straight
# from the catch-all file_server, which meant the auth-guard was
# bypassed: a LAN visitor could see the box's version, IP, and
# reach the Update-now / Reboot buttons (the API calls behind them
# are auth-gated, but the page itself rendered without a redirect
# to /login). Route them through the Python handler which checks
# the session cookie and either serves the static HTML from
# assets/www/ or redirects to /login.
handle /settings* {
reverse_proxy localhost:7000
}
handle / {
reverse_proxy localhost:7000
}
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
# Runtime JSON lives under /var/lib/furtka/ so it survives self-updates
# (which only swap /opt/furtka/current).
handle /status.json {
root * /var/lib/furtka
file_server
}
handle /furtka.json {
root * /var/lib/furtka
file_server
}
handle /update-state.json {
root * /var/lib/furtka
file_server
}
# Download the local root CA cert Caddy generated for `tls internal`.
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
# Public because users need to grab it before they've trusted it.
# The private key next to it stays 0600 / caddy-owned.
handle /rootCA.crt {
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
root * /var/lib/caddy/pki/authorities/local
rewrite * /root.crt
file_server
header Content-Type "application/x-x509-ca-cert"
header Content-Disposition "attachment; filename=furtka-local-rootCA.crt"
}
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
handle {
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
root * /opt/furtka/current/assets/www
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
file_server
encode gzip
}
log {
output stdout
}
}
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
# HTTPS opt-in: when /settings toggles HTTPS on, a snippet gets written
# into /etc/caddy/furtka-https.d/ that adds the hostname+tls-internal
# site block. Empty directory = HTTP-only (default fresh install).
import /etc/caddy/furtka-https.d/*.caddyfile
:80 {
import /etc/caddy/furtka.d/*.caddyfile
import furtka_routes
}