diff --git a/CHANGELOG.md b/CHANGELOG.md index e1c72d3..ea6227b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,53 @@ This project uses calendar versioning: `YY.N-stage` (e.g. `26.0-alpha` = 2026, r ## [Unreleased] +## [26.15-alpha] - 2026-04-21 + +### Fixed + +- **HTTPS is now opt-in; fresh installs no longer hit unbypassable + SEC_ERROR_BAD_SIGNATURE.** Every version since 26.5 shipped a + Caddyfile with a `__FURTKA_HOSTNAME__.local { tls internal }` site + block, so Caddy auto-generated a self-signed root CA + intermediate + + leaf on first boot. That worked for first-time-ever users, but + every reinstall (or second Furtka box on the same LAN) produced a + new CA with the **same intermediate CN** (`Caddy Local Authority - + ECC Intermediate` — Caddy hardcodes it). Any browser that had ever + trusted an earlier Furtka CA got a cached intermediate with + mismatched keys, then Firefox's cert lookup substituted the cached + intermediate when validating the new box's leaf → the signature + check failed → `SEC_ERROR_BAD_SIGNATURE`, which Firefox has no + "Advanced → Accept Risk" bypass for. + - Removed the hostname site block from the default Caddyfile. + Fresh installs serve `:80` only; visiting `https://furtka.local` + now yields a clean connection-refused instead of the crypto + fault. + - Added top-level `import /etc/caddy/furtka-https.d/*.caddyfile`. + The `/settings` HTTPS toggle (via `furtka.https.set_force_https`) + now writes TWO snippets atomically — the top-level hostname + + `tls internal` block (enables `:443`) and the `:80`-scoped + redirect (forces HTTP → HTTPS) — and removes both on disable. + Caddy reloads after the pair-swap; failure rolls both back. + - Webinstaller creates `/etc/caddy/furtka-https.d/` during + post-install alongside the existing `furtka.d/`. + - `updater._refresh_caddyfile` runs a 26.14 → 26.15 migration: if + the box already had the redirect snippet on disk (user had + explicitly enabled "Force HTTPS" under the old regime), the + migration also writes the new listener snippet so HTTPS keeps + working across the upgrade. +- **`status.force_https` now reads the listener snippet, not the + redirect snippet.** A lone redirect without a `:443` listener + wouldn't actually serve HTTPS, so the listener file is the + authoritative "HTTPS is on" signal. The UI on `/settings` sees the + correct state as a result. + +Known remaining UX wart: a browser that trusted a previous Furtka box +still sees `BAD_SIGNATURE` when visiting this box's `https://` after +enabling HTTPS here — the fixed intermediate CN is a Caddy-side +limitation we can't fix from Furtka. Fresh installs on a browser that +never visited another Furtka box work correctly. Workaround: +`about:networking#sts` → Forget → clear `cert9.db`. + ## [26.14-alpha] - 2026-04-21 ### Fixed @@ -307,7 +354,8 @@ First tagged snapshot. Pre-alpha — the installer does not yet boot, but the de - **Containers:** Docker + Compose - **License:** AGPL-3.0 -[Unreleased]: https://forgejo.sourcegate.online/daniel/furtka/compare/26.14-alpha...HEAD +[Unreleased]: https://forgejo.sourcegate.online/daniel/furtka/compare/26.15-alpha...HEAD +[26.15-alpha]: https://forgejo.sourcegate.online/daniel/furtka/releases/tag/26.15-alpha [26.14-alpha]: https://forgejo.sourcegate.online/daniel/furtka/releases/tag/26.14-alpha [26.13-alpha]: https://forgejo.sourcegate.online/daniel/furtka/releases/tag/26.13-alpha [26.12-alpha]: https://forgejo.sourcegate.online/daniel/furtka/releases/tag/26.12-alpha diff --git a/assets/Caddyfile b/assets/Caddyfile index b9437af..fe14265 100644 --- a/assets/Caddyfile +++ b/assets/Caddyfile @@ -1,25 +1,27 @@ -# Serves the Furtka landing page + live JSON on :80 (plain HTTP) and on -# HTTPS via Caddy's built-in `tls internal` — locally-issued certs signed -# by a root CA that Caddy generates on first start and stores under -# /var/lib/caddy/pki/authorities/local/. 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). -# /apps and /api are reverse-proxied to the resource-manager API -# (furtka serve, bound to 127.0.0.1:7000). +# 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. # -# Hostname templating: __FURTKA_HOSTNAME__ gets substituted with the -# install-time hostname by webinstaller/app.py on first install and by -# furtka.updater._refresh_caddyfile on every self-update. A bare `:443 -# { tls internal }` (no hostname) never triggers leaf-cert issuance, so -# SNI-based handshakes die with `SSL_ERROR_INTERNAL_ERROR_ALERT` — the -# 26.4-alpha regression this file exists to cure. +# /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). # -# Force-HTTPS: /etc/caddy/furtka.d/*.caddyfile gets imported into the :80 -# block. The /api/furtka/https/force endpoint creates or removes -# redirect.caddyfile there to toggle the HTTP→HTTPS redirect, then reloads -# Caddy. Glob imports silently no-op on an empty/missing directory, so the -# toggle-off state is "no file present" rather than "empty file". +# Two snippet dirs, both silently no-op when empty: +# - /etc/caddy/furtka.d/*.caddyfile → imported inside the :80 block. +# The HTTPS toggle's "force HTTP→HTTPS redirect" snippet lands here. +# - /etc/caddy/furtka-https.d/*.caddyfile → imported at TOP LEVEL, so +# the HTTPS hostname+tls-internal site block can drop in here when +# the toggle is on. Hostname is substituted at toggle-time. { # Named-hostname :443 blocks would otherwise make Caddy add its own # HTTP→HTTPS redirect — but we already serve our own `:80` block and @@ -70,8 +72,8 @@ file_server } # Download the local root CA cert Caddy generated for `tls internal`. - # Available on both :80 and :443 so users can grab it before they've - # trusted it. The private key next to it stays 0600 / caddy-owned. + # 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 { root * /var/lib/caddy/pki/authorities/local rewrite * /root.crt @@ -89,12 +91,12 @@ } } +# HTTPS opt-in: when /settings toggles HTTPS on, a snippet gets written +# into /etc/caddy/furtka-https.d/ that adds the hostname+tls-internal +# site block. Empty directory = HTTP-only (default fresh install). +import /etc/caddy/furtka-https.d/*.caddyfile + :80 { import /etc/caddy/furtka.d/*.caddyfile import furtka_routes } - -__FURTKA_HOSTNAME__.local, __FURTKA_HOSTNAME__ { - tls internal - import furtka_routes -} diff --git a/furtka/https.py b/furtka/https.py index f59189e..ce8e582 100644 --- a/furtka/https.py +++ b/furtka/https.py @@ -6,10 +6,25 @@ sets `XDG_DATA_HOME=/var/lib`, so on the target that resolves to /var/lib/caddy/pki/authorities/local/. The private key stays 0600 / caddy-owned; we only ever read the public root.crt next to it. -This module exposes two operations: -- status(): current CA fingerprint + whether force-HTTPS is on -- set_force_https(enabled): write/remove the Caddy import snippet that - redirects HTTP to HTTPS, reload Caddy, roll back on failure. +HTTPS is **opt-in** since 26.15-alpha. Default Caddyfile has no `:443` +site block, so `tls internal` never triggers cert issuance. The +/settings toggle drops a snippet file into /etc/caddy/furtka-https.d/ +that adds the hostname+tls-internal block (plus the redirect snippet +inside /etc/caddy/furtka.d/ for HTTP→HTTPS). Disabling the toggle +removes both snippets and reloads — Caddy falls back to HTTP-only. + +Why opt-in: fresh-install boxes used to always serve a self-signed +cert on :443. Any browser that had ever trusted a previous Furtka +box's local CA rejected the new cert with an unbypassable +SEC_ERROR_BAD_SIGNATURE — Firefox in particular has no "Advanced → +Accept" for that case. Making HTTPS explicit means fresh installs +never hit that trap; users who want HTTPS download the rootCA.crt +first and then click the toggle. + +This module exposes: +- status(): CA fingerprint + current toggle state +- set_force_https(enabled): write/remove BOTH snippets atomically, + reload Caddy, roll back on failure. """ import base64 @@ -22,6 +37,9 @@ CA_CERT_PATH = Path("/var/lib/caddy/pki/authorities/local/root.crt") SNIPPET_DIR = Path("/etc/caddy/furtka.d") REDIRECT_SNIPPET = SNIPPET_DIR / "redirect.caddyfile" REDIRECT_CONTENT = "redir https://{host}{uri} permanent\n" +HTTPS_SNIPPET_DIR = Path("/etc/caddy/furtka-https.d") +HTTPS_SNIPPET = HTTPS_SNIPPET_DIR / "https.caddyfile" +HOSTNAME_FILE = Path("/etc/hostname") _PEM_RE = re.compile( r"-----BEGIN CERTIFICATE-----\s*(.+?)\s*-----END CERTIFICATE-----", @@ -33,6 +51,30 @@ class HttpsError(Exception): """Recoverable failure from set_force_https — the caller should 5xx.""" +def _read_hostname(hostname_file: Path = HOSTNAME_FILE) -> str: + """Return the box's hostname, stripped. Falls back to 'furtka' so a + missing /etc/hostname doesn't produce an empty site block that Caddy + would reject at parse time.""" + try: + value = hostname_file.read_text().strip() + except (FileNotFoundError, PermissionError, OSError): + return "furtka" + return value or "furtka" + + +def _https_snippet_content(hostname: str) -> str: + """Caddy site block the HTTPS toggle installs at opt-in. + + Serves .local and on :443 with Caddy's + `tls internal` (local CA auto-issuance), and imports the shared + furtka_routes snippet so the :443 listener exposes the same + routes as :80. Must be written at top-level (not inside another + site block) — that's why the Caddyfile imports furtka-https.d at + top-level rather than inside :80. + """ + return f"{hostname}.local, {hostname} {{\n\ttls internal\n\timport furtka_routes\n}}\n" + + def _ca_fingerprint(ca_path: Path) -> str | None: try: pem = ca_path.read_text() @@ -54,13 +96,20 @@ def _format_fingerprint(hex_upper: str) -> str: def status( ca_path: Path = CA_CERT_PATH, - snippet: Path = REDIRECT_SNIPPET, + https_snippet: Path = HTTPS_SNIPPET, ) -> dict: + """force_https is True iff the HTTPS listener snippet exists. + + Before 26.15-alpha this checked the redirect snippet instead — but + the redirect alone without a :443 listener wouldn't actually serve + HTTPS, so the listener snippet is the authoritative "HTTPS is on" + signal. + """ fp = _ca_fingerprint(ca_path) return { "ca_available": fp is not None, "fingerprint_sha256": _format_fingerprint(fp) if fp else None, - "force_https": snippet.is_file(), + "force_https": https_snippet.is_file(), "ca_download_url": "/rootCA.crt", } @@ -78,29 +127,48 @@ def set_force_https( enabled: bool, snippet_dir: Path = SNIPPET_DIR, snippet: Path = REDIRECT_SNIPPET, + https_snippet_dir: Path = HTTPS_SNIPPET_DIR, + https_snippet: Path = HTTPS_SNIPPET, + hostname_file: Path = HOSTNAME_FILE, reload_caddy=_default_reload, ) -> bool: - """Toggle the HTTP→HTTPS redirect by writing or removing the snippet - Caddy imports. Always reloads Caddy. Rolls the snippet state back on - reload failure so a broken config can't leave Caddy wedged on the next - restart. + """Toggle HTTPS by writing or removing two snippets atomically: + + 1. The top-level HTTPS hostname+tls-internal block (enables :443 + listener + Caddy's `tls internal` cert issuance) + 2. The :80-scoped redirect snippet (forces HTTP → HTTPS) + + Reload Caddy after the snippet swap. On reload failure both + snippets are reverted to their pre-call state so a bad config + can't leave Caddy wedged. """ snippet_dir.mkdir(mode=0o755, parents=True, exist_ok=True) - had = snippet.is_file() - previous = snippet.read_text() if had else None + https_snippet_dir.mkdir(mode=0o755, parents=True, exist_ok=True) + + had_redirect = snippet.is_file() + previous_redirect = snippet.read_text() if had_redirect else None + had_https = https_snippet.is_file() + previous_https = https_snippet.read_text() if had_https else None + if enabled: snippet.write_text(REDIRECT_CONTENT) - elif had: - snippet.unlink() + https_snippet.write_text(_https_snippet_content(_read_hostname(hostname_file))) + else: + if had_redirect: + snippet.unlink() + if had_https: + https_snippet.unlink() try: reload_caddy() except subprocess.CalledProcessError as e: - _revert(snippet, previous) + _revert(snippet, previous_redirect) + _revert(https_snippet, previous_https) msg = (e.stderr or e.stdout or "").strip() or f"exit {e.returncode}" raise HttpsError(f"caddy reload failed: {msg}") from e except FileNotFoundError as e: - _revert(snippet, previous) + _revert(snippet, previous_redirect) + _revert(https_snippet, previous_https) raise HttpsError(f"systemctl not available: {e}") from e return enabled diff --git a/furtka/updater.py b/furtka/updater.py index b78ea03..77a14c0 100644 --- a/furtka/updater.py +++ b/furtka/updater.py @@ -49,6 +49,9 @@ _CADDYFILE_LIVE = Path(os.environ.get("FURTKA_CADDYFILE_PATH", "/etc/caddy/Caddy _CADDY_SNIPPET_DIR = Path( os.environ.get("FURTKA_CADDY_SNIPPET_DIR", str(_CADDYFILE_LIVE.parent / "furtka.d")) ) +_CADDY_HTTPS_SNIPPET_DIR = Path( + os.environ.get("FURTKA_CADDY_HTTPS_SNIPPET_DIR", str(_CADDYFILE_LIVE.parent / "furtka-https.d")) +) _SYSTEMD_DIR = Path(os.environ.get("FURTKA_SYSTEMD_DIR", "/etc/systemd/system")) _HOSTNAME_FILE = Path(os.environ.get("FURTKA_HOSTNAME_FILE", "/etc/hostname")) _CADDYFILE_HOSTNAME_MARKER = "__FURTKA_HOSTNAME__" @@ -170,6 +173,24 @@ def _current_hostname() -> str: return name or "furtka" +def _maybe_migrate_preserve_https() -> None: + """26.14 → 26.15 migration: if the box already had the force-HTTPS + redirect snippet on disk, that means the user explicitly opted + into HTTPS under the old regime. Under the new opt-in regime, + HTTPS also requires a separate listener snippet — write it here so + the user's HTTPS doesn't silently break when the Caddyfile refresh + removes the default hostname block. + """ + redirect_snippet = _CADDY_SNIPPET_DIR / "redirect.caddyfile" + https_snippet = _CADDY_HTTPS_SNIPPET_DIR / "https.caddyfile" + if not redirect_snippet.is_file() or https_snippet.is_file(): + return + hostname = _current_hostname() + https_snippet.write_text( + f"{hostname}.local, {hostname} {{\n\ttls internal\n\timport furtka_routes\n}}\n" + ) + + def _refresh_caddyfile(source: Path) -> bool: """Copy the shipped Caddyfile to /etc/caddy/ iff it differs. Returns True if the file changed (so caddy needs more than a bare reload). @@ -180,10 +201,19 @@ def _refresh_caddyfile(source: Path) -> bool: """ if not source.is_file(): return False - # Snippet dir for the /api/furtka/https/force toggle. Pre-HTTPS installs - # don't have this dir; ensure it so the Caddyfile's glob import can't - # trip an older Caddy on a missing path during the first reload. + # Snippet dirs for the /api/furtka/https/force toggle. Pre-HTTPS + # installs don't have them; ensure both so the Caddyfile's glob + # imports can't trip an older Caddy on missing paths during the + # first reload. furtka-https.d is new in 26.15-alpha — older boxes + # upgrading across this version line won't have it on disk yet. _CADDY_SNIPPET_DIR.mkdir(mode=0o755, parents=True, exist_ok=True) + _CADDY_HTTPS_SNIPPET_DIR.mkdir(mode=0o755, parents=True, exist_ok=True) + # Migration: pre-26.15 Caddyfile always served :443 via tls internal, + # so a box that had the "force HTTPS" redirect toggle ON relied on + # HTTPS being there implicitly. After this Caddyfile refresh the + # hostname block is gone, so the redirect would 301 to a dead :443. + # Preserve intent by writing the HTTPS listener snippet too. + _maybe_migrate_preserve_https() rendered = source.read_text().replace(_CADDYFILE_HOSTNAME_MARKER, _current_hostname()) if _CADDYFILE_LIVE.is_file() and rendered == _CADDYFILE_LIVE.read_text(): return False diff --git a/pyproject.toml b/pyproject.toml index e8a8e6f..2344472 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [project] name = "furtka" -version = "26.14-alpha" +version = "26.15-alpha" description = "Open-source home server OS — simple enough for everyone." requires-python = ">=3.11" readme = "README.md" diff --git a/tests/test_https.py b/tests/test_https.py index bcf6229..7b423cf 100644 --- a/tests/test_https.py +++ b/tests/test_https.py @@ -1,11 +1,15 @@ -"""Tests for furtka.https — fingerprint extraction + force-HTTPS toggle. +"""Tests for furtka.https — fingerprint extraction + HTTPS toggle. + +Since 26.15-alpha the toggle writes/removes TWO snippets atomically: +- The top-level HTTPS listener snippet (enables :443 + tls internal) +- The :80-scoped redirect snippet (forces HTTP → HTTPS) The fingerprint case uses a throwaway self-signed EC cert with a known reference fingerprint (computed once via `openssl x509 -fingerprint -sha256 -noout`) so we verify the PEM → DER → SHA256 path without a runtime subprocess dependency. The toggle cases stub the caddy reload -so we assert the snippet file is written / removed and that reload -failures roll state back. +so we assert both snippet files are written / removed together and that +reload failures roll BOTH state back. """ import subprocess @@ -34,6 +38,22 @@ _TEST_CERT_FP_SHA256 = ( ) +def _paths(tmp_path): + """Return the four paths the toggle touches, in a dict for kwargs + spreading. Keeps each test's fixture boilerplate small.""" + return { + "snippet_dir": tmp_path / "furtka.d", + "snippet": tmp_path / "furtka.d" / "redirect.caddyfile", + "https_snippet_dir": tmp_path / "furtka-https.d", + "https_snippet": tmp_path / "furtka-https.d" / "https.caddyfile", + "hostname_file": tmp_path / "etc_hostname", + } + + +def _prepare_hostname(tmp_path, value="testbox"): + (tmp_path / "etc_hostname").write_text(f"{value}\n") + + def test_ca_fingerprint_matches_openssl(tmp_path): cert = tmp_path / "root.crt" cert.write_text(_TEST_CERT_PEM) @@ -53,7 +73,7 @@ def test_ca_fingerprint_no_pem_block(tmp_path): def test_status_no_ca_no_snippet(tmp_path): - s = https.status(ca_path=tmp_path / "root.crt", snippet=tmp_path / "redirect.caddyfile") + s = https.status(ca_path=tmp_path / "root.crt", https_snippet=tmp_path / "https.caddyfile") assert s == { "ca_available": False, "fingerprint_sha256": None, @@ -62,105 +82,135 @@ def test_status_no_ca_no_snippet(tmp_path): } -def test_status_with_ca_and_snippet(tmp_path): +def test_status_with_ca_and_https_snippet(tmp_path): ca = tmp_path / "root.crt" ca.write_text(_TEST_CERT_PEM) - snippet = tmp_path / "redirect.caddyfile" - snippet.write_text(https.REDIRECT_CONTENT) - s = https.status(ca_path=ca, snippet=snippet) + https_snip = tmp_path / "https.caddyfile" + https_snip.write_text("furtka.local, furtka {\n\ttls internal\n\timport furtka_routes\n}\n") + s = https.status(ca_path=ca, https_snippet=https_snip) assert s["ca_available"] is True assert s["fingerprint_sha256"] == _TEST_CERT_FP_SHA256 assert s["force_https"] is True -def test_set_force_enable_writes_snippet_and_reloads(tmp_path): - snippet_dir = tmp_path / "furtka.d" - snippet = snippet_dir / "redirect.caddyfile" +def test_status_force_reflects_https_snippet_not_redirect(tmp_path): + """Authoritative signal for "HTTPS is on" is the listener snippet — + a lone redirect without a :443 listener wouldn't actually serve + HTTPS, so the status must NOT report it as on. Locks 26.15 semantic.""" + ca = tmp_path / "root.crt" + ca.write_text(_TEST_CERT_PEM) + s = https.status(ca_path=ca, https_snippet=tmp_path / "does-not-exist.caddyfile") + assert s["force_https"] is False + + +def test_set_force_enable_writes_both_snippets_and_reloads(tmp_path): + _prepare_hostname(tmp_path) + p = _paths(tmp_path) calls = [] def fake_reload(): calls.append("reload") - result = https.set_force_https( - True, snippet_dir=snippet_dir, snippet=snippet, reload_caddy=fake_reload - ) + result = https.set_force_https(True, reload_caddy=fake_reload, **p) assert result is True - assert snippet.read_text() == https.REDIRECT_CONTENT + assert p["snippet"].read_text() == https.REDIRECT_CONTENT + written = p["https_snippet"].read_text() + assert "testbox.local, testbox" in written + assert "tls internal" in written + assert "import furtka_routes" in written assert calls == ["reload"] -def test_set_force_disable_removes_snippet(tmp_path): - snippet_dir = tmp_path / "furtka.d" - snippet_dir.mkdir() - snippet = snippet_dir / "redirect.caddyfile" - snippet.write_text(https.REDIRECT_CONTENT) +def test_set_force_uses_fallback_hostname_when_file_missing(tmp_path): + # No /etc/hostname → fall back to 'furtka' so Caddy gets a parseable + # block instead of an empty hostname that would fail config load. + p = _paths(tmp_path) + result = https.set_force_https(True, reload_caddy=lambda: None, **p) + assert result is True + assert "furtka.local, furtka" in p["https_snippet"].read_text() - result = https.set_force_https( - False, snippet_dir=snippet_dir, snippet=snippet, reload_caddy=lambda: None - ) + +def test_set_force_disable_removes_both_snippets(tmp_path): + _prepare_hostname(tmp_path) + p = _paths(tmp_path) + p["snippet_dir"].mkdir() + p["https_snippet_dir"].mkdir() + p["snippet"].write_text(https.REDIRECT_CONTENT) + p["https_snippet"].write_text("furtka.local { tls internal }\n") + + result = https.set_force_https(False, reload_caddy=lambda: None, **p) assert result is False - assert not snippet.exists() + assert not p["snippet"].exists() + assert not p["https_snippet"].exists() def test_set_force_disable_is_idempotent_when_already_off(tmp_path): - snippet_dir = tmp_path / "furtka.d" - snippet = snippet_dir / "redirect.caddyfile" - - result = https.set_force_https( - False, snippet_dir=snippet_dir, snippet=snippet, reload_caddy=lambda: None - ) + p = _paths(tmp_path) + result = https.set_force_https(False, reload_caddy=lambda: None, **p) assert result is False - assert not snippet.exists() + assert not p["snippet"].exists() + assert not p["https_snippet"].exists() def test_reload_failure_rolls_back_enable(tmp_path): - snippet_dir = tmp_path / "furtka.d" - snippet = snippet_dir / "redirect.caddyfile" + _prepare_hostname(tmp_path) + p = _paths(tmp_path) def failing_reload(): raise subprocess.CalledProcessError(1, ["systemctl"], stderr="bad config") with pytest.raises(https.HttpsError, match="caddy reload failed: bad config"): - https.set_force_https( - True, snippet_dir=snippet_dir, snippet=snippet, reload_caddy=failing_reload - ) - # Rollback: since snippet didn't exist before, it must not exist after. - assert not snippet.exists() + https.set_force_https(True, reload_caddy=failing_reload, **p) + # Rollback: since neither snippet existed before, neither exists after. + assert not p["snippet"].exists() + assert not p["https_snippet"].exists() def test_reload_failure_rolls_back_disable(tmp_path): - snippet_dir = tmp_path / "furtka.d" - snippet_dir.mkdir() - snippet = snippet_dir / "redirect.caddyfile" - original = "redir https://{host}{uri} permanent\n# marker\n" - snippet.write_text(original) + _prepare_hostname(tmp_path) + p = _paths(tmp_path) + p["snippet_dir"].mkdir() + p["https_snippet_dir"].mkdir() + original_redirect = "redir https://{host}{uri} permanent\n# marker\n" + original_https = "# old https block\nfurtka.local { tls internal }\n" + p["snippet"].write_text(original_redirect) + p["https_snippet"].write_text(original_https) def failing_reload(): raise subprocess.CalledProcessError(1, ["systemctl"], stderr="bad config") with pytest.raises(https.HttpsError): - https.set_force_https( - False, snippet_dir=snippet_dir, snippet=snippet, reload_caddy=failing_reload - ) - # Rollback: snippet is restored to its exact prior contents. - assert snippet.read_text() == original + https.set_force_https(False, reload_caddy=failing_reload, **p) + # Rollback: both snippets are restored to their exact prior contents. + assert p["snippet"].read_text() == original_redirect + assert p["https_snippet"].read_text() == original_https def test_systemctl_missing_raises_and_rolls_back(tmp_path): - snippet_dir = tmp_path / "furtka.d" - snippet = snippet_dir / "redirect.caddyfile" + _prepare_hostname(tmp_path) + p = _paths(tmp_path) def missing_systemctl(): raise FileNotFoundError(2, "No such file", "systemctl") with pytest.raises(https.HttpsError, match="systemctl not available"): - https.set_force_https( - True, snippet_dir=snippet_dir, snippet=snippet, reload_caddy=missing_systemctl - ) - assert not snippet.exists() + https.set_force_https(True, reload_caddy=missing_systemctl, **p) + assert not p["snippet"].exists() + assert not p["https_snippet"].exists() def test_redirect_snippet_content_is_caddy_redir_directive(): # Lock the exact directive. A regression here silently stops the # redirect from taking effect even though the file-swap looks fine. assert https.REDIRECT_CONTENT.strip() == "redir https://{host}{uri} permanent" + + +def test_https_snippet_content_has_tls_internal_and_routes(tmp_path): + # Lock the shape of the opt-in HTTPS listener block. Caddy parses + # this verbatim — changing the shape without updating the test + # risks shipping a silently-broken Caddyfile import. + s = https._https_snippet_content("mybox") + assert "mybox.local, mybox {" in s + assert "\ttls internal" in s + assert "\timport furtka_routes" in s + assert s.endswith("}\n") diff --git a/tests/test_webinstaller_assets.py b/tests/test_webinstaller_assets.py index 4d3783c..041c210 100644 --- a/tests/test_webinstaller_assets.py +++ b/tests/test_webinstaller_assets.py @@ -122,19 +122,39 @@ def test_caddyfile_asset_serves_from_current(): assert "root * /var/lib/furtka" in caddy -def test_caddyfile_serves_both_http_and_https(): - # :80 stays so users who haven't installed the CA still reach the box; - # HTTPS is served via a named-hostname block so Caddy's `tls internal` - # has something to issue a leaf cert for. A bare `:443 { tls internal }` - # never triggers issuance — that was the 26.4-alpha regression. - caddy = (ASSETS / "Caddyfile").read_text() +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) assert ":80 {" in caddy - assert "__FURTKA_HOSTNAME__.local, __FURTKA_HOSTNAME__ {" in caddy - assert "tls internal" in caddy - # Shared routes live in a named snippet to avoid drift between the two - # listeners — both site blocks must import it. + assert "tls internal" not in caddy + assert "__FURTKA_HOSTNAME__" not in caddy + assert "import /etc/caddy/furtka-https.d/*.caddyfile" in caddy + # Shared routes still live in a named snippet so the HTTPS toggle's + # snippet can import the same routes without duplication. assert "(furtka_routes)" in caddy - assert caddy.count("import furtka_routes") == 2 + # Default Caddyfile imports it once (inside :80). The HTTPS snippet, + # when written by the toggle, imports it a second time. + assert caddy.count("import furtka_routes") == 1 def test_caddyfile_disables_caddy_auto_redirects(): @@ -167,16 +187,28 @@ def test_caddyfile_exposes_root_ca_download(): assert "attachment; filename=furtka-local-rootCA.crt" in caddy -def test_post_install_substitutes_hostname_in_caddyfile(install_cmds): - # Fresh installs: the placeholder the asset ships with must be replaced - # with the hostname the user picked in the form. The `testhost` value - # comes from the install_cmds fixture. Without substitution Caddy's - # `tls internal` never issues a leaf cert for the real hostname. +def test_post_install_writes_caddyfile_without_hostname_placeholder(install_cmds): + # 26.15-alpha: the shipped Caddyfile no longer carries the + # __FURTKA_HOSTNAME__ marker — HTTPS + hostname now live in the + # opt-in snippet written by set_force_https(), not in the base + # Caddyfile. Verify the post-install writes the file as-is (no + # substitution expected) and it has the opt-in import glob. caddyfile_cmd = next((c for c in install_cmds if " > /etc/caddy/Caddyfile" in c), None) assert caddyfile_cmd is not None - written = _extract_written_content(caddyfile_cmd, "/etc/caddy/Caddyfile") + written_full = _extract_written_content(caddyfile_cmd, "/etc/caddy/Caddyfile") + written = _strip_caddy_comments(written_full) assert "__FURTKA_HOSTNAME__" not in written - assert "testhost.local, testhost {" in written + assert "import /etc/caddy/furtka-https.d/*.caddyfile" in written + assert "tls internal" not in written + + +def test_post_install_creates_https_snippet_dir(install_cmds): + # The top-level HTTPS opt-in snippet dir must exist before Caddy's + # first start — its glob import tolerates an empty directory, but + # not a missing one on older Caddy builds. Parallel guarantee to + # test_post_install_creates_furtka_d_snippet_dir below. + matching = [c for c in install_cmds if "/etc/caddy/furtka-https.d" in c and "install -d" in c] + assert matching, "no install -d command creates /etc/caddy/furtka-https.d" def test_post_install_creates_furtka_d_snippet_dir(install_cmds): diff --git a/webinstaller/app.py b/webinstaller/app.py index 6156441..08236a3 100644 --- a/webinstaller/app.py +++ b/webinstaller/app.py @@ -395,6 +395,14 @@ def _post_install_commands(hostname, admin_username, admin_password): # an empty dir but not a missing one on every Caddy version, so we # create it up front and stay on the safe side. "install -d -m 0755 -o root -g root /etc/caddy/furtka.d", + # Parallel dir for the top-level HTTPS-listener snippet, written + # by /api/furtka/https/force (26.15-alpha+) when the user opts + # into HTTPS. Empty by default so fresh installs never generate + # a tls internal cert — that was the 26.14 regression where + # Firefox hit unbypassable SEC_ERROR_BAD_SIGNATURE because + # Caddy's fixed intermediate-CN clashed with any cached trust + # from a previously-reinstalled Furtka box. + "install -d -m 0755 -o root -g root /etc/caddy/furtka-https.d", # The Caddyfile lives at /etc/caddy/Caddyfile per Caddy's convention # (systemd unit points there). Content comes from the shipped asset, # which we copy in at install time so updates that change routing