From 663bd74572dc86a57c909dec26551655f581836f Mon Sep 17 00:00:00 2001 From: Daniel Maksymilian Syrnicki Date: Fri, 17 Apr 2026 12:19:06 +0200 Subject: [PATCH] feat(https): local HTTPS via Caddy tls internal + opt-in redirect toggle MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Caddy now serves both :80 (plain HTTP, unchanged default) and :443 with tls internal — it generates its own per-box root CA on first start, stored under /var/lib/caddy/.local/share/caddy/pki/authorities/local/. Users can download rootCA.crt at /rootCA.crt (served on both listeners) and install it per-OS via the new /https-install/ guide. Settings page grows a Local HTTPS card with CA fingerprint, download button, reachability probe, and an opt-in "force HTTPS" toggle. The toggle only unhides itself once the current browser already trusts the cert, so enabling it can't lock the user out of the settings page. Backend: GET /api/furtka/https/status and POST /api/furtka/https/force in furtka.https. The force toggle drops a Caddy import snippet into /etc/caddy/furtka.d/redirect.caddyfile and reloads Caddy; reload failure rolls the snippet state back so a bad config can't wedge the next service start. updater._refresh_caddyfile() ensures /etc/caddy/furtka.d exists before every reload so 26.3-alpha → 26.4-alpha self-updates don't trip on the new glob import directive. Co-Authored-By: Claude Opus 4.7 (1M context) --- CHANGELOG.md | 4 + assets/Caddyfile | 44 ++++++-- assets/www/https-install/index.html | 159 ++++++++++++++++++++++++++ assets/www/settings/index.html | 134 ++++++++++++++++++++++ assets/www/style.css | 23 ++++ furtka/api.py | 27 +++++ furtka/https.py | 117 ++++++++++++++++++++ furtka/updater.py | 7 ++ tests/test_https.py | 166 ++++++++++++++++++++++++++++ tests/test_webinstaller_assets.py | 40 +++++++ webinstaller/app.py | 7 ++ 11 files changed, 720 insertions(+), 8 deletions(-) create mode 100644 assets/www/https-install/index.html create mode 100644 furtka/https.py create mode 100644 tests/test_https.py diff --git a/CHANGELOG.md b/CHANGELOG.md index bfca0b9..2878377 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,10 @@ This project uses calendar versioning: `YY.N-stage` (e.g. `26.0-alpha` = 2026, r ## [Unreleased] +### Added + +- **Local HTTPS via Caddy `tls internal`** on port 443. Caddy generates a per-box local root CA on first start; the Caddyfile now serves both `:80` and `:443` from the same routes. HTTP stays on by default — no regression for users who haven't trusted the CA yet. New "Local HTTPS" section in `/settings` shows the CA's SHA-256 fingerprint, offers a one-click download of `rootCA.crt`, links to the per-OS install guide at `/https-install/`, and exposes an opt-in "force HTTPS" toggle that only unhides itself once the current browser has already trusted the cert (so enabling it can't lock the user out of the settings page). Backend: `GET /api/furtka/https/status` and `POST /api/furtka/https/force` in `furtka.https`. The force toggle drops a Caddy import snippet into `/etc/caddy/furtka.d/redirect.caddyfile` and reloads Caddy; reload failure automatically rolls the snippet state back so a bad config can't wedge the next service start. + ### Fixed - **Settings page "Installed" field now refreshes after a self-update.** The `/api/furtka/update/check` response already carries `current` — the settings JS now drives `upd-current` from it the same way it drives `upd-latest`, so clicking "Check for updates" after a successful update reflects the new installed version without a force-reload. diff --git a/assets/Caddyfile b/assets/Caddyfile index 7896a37..ba3c753 100644 --- a/assets/Caddyfile +++ b/assets/Caddyfile @@ -1,11 +1,19 @@ -# Serves the Furtka landing page + live JSON on :80. Static pages are read -# from the current-version directory under /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). TLS / auth -# come later when Authentik is wired in. -:80 { +# Serves the Furtka landing page + live JSON on :80 (plain HTTP) and :443 +# (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/.local/share/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). +# +# 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". +(furtka_routes) { handle /api/* { reverse_proxy localhost:7000 } @@ -26,6 +34,16 @@ root * /var/lib/furtka 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. + handle /rootCA.crt { + root * /var/lib/caddy/.local/share/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" + } handle { root * /opt/furtka/current/assets/www file_server @@ -35,3 +53,13 @@ output stdout } } + +:80 { + import /etc/caddy/furtka.d/*.caddyfile + import furtka_routes +} + +:443 { + tls internal + import furtka_routes +} diff --git a/assets/www/https-install/index.html b/assets/www/https-install/index.html new file mode 100644 index 0000000..b517977 --- /dev/null +++ b/assets/www/https-install/index.html @@ -0,0 +1,159 @@ + + + + + Install local HTTPS · Furtka + + + + +
+ + +

Install local HTTPS

+

+ Trust the Furtka root CA on your device, then reach this box at + https:/// with a green padlock. + HTTP stays available until you enable the redirect in + Settings. +

+ +
+

Download the CA

+
+
+
Fingerprint (SHA-256)
+
+

+ Check this fingerprint matches what /settings shows before + trusting it on another device. The root CA is unique to this box. +

+
+ +
+
+
+ +
+

Linux (system-wide)

+
+

Arch / Fedora / RHEL:

+
sudo cp rootCA.crt /etc/ca-certificates/trust-source/anchors/furtka-local.crt
+sudo update-ca-trust
+

Debian / Ubuntu:

+
sudo cp rootCA.crt /usr/local/share/ca-certificates/furtka-local.crt
+sudo update-ca-certificates
+

+ Firefox keeps its own certificate store. After the above, open + about:preferences#privacyView Certificates → + AuthoritiesImport, pick rootCA.crt, + tick Trust this CA to identify websites. +

+
+
+ +
+

macOS

+
+
    +
  1. Double-click rootCA.crt. Keychain Access opens.
  2. +
  3. When prompted, add it to the System keychain.
  4. +
  5. Find the Furtka entry, double-click, expand Trust, + set When using this certificate to Always Trust.
  6. +
  7. Close the window — you will be asked for your password.
  8. +
+
+
+ +
+

Windows

+
+
    +
  1. Double-click rootCA.crt.
  2. +
  3. Click Install Certificate.
  4. +
  5. Choose Local Machine (requires admin) and click Next.
  6. +
  7. Select Place all certificates in the following store → + BrowseTrusted Root Certification Authorities.
  8. +
  9. Finish. Chrome and Edge pick this up immediately. Firefox keeps its + own store — import the same file via Firefox settings.
  10. +
+
+
+ +
+

Android

+
+
    +
  1. Transfer rootCA.crt to the device (AirDrop, email, + USB — whatever is handy).
  2. +
  3. Settings → Security (or Security & privacy) + → More security settingsEncryption & credentials + → Install a certificateCA certificate.
  4. +
  5. Confirm the warning, then pick the file.
  6. +
+

+ Android 11+ only trusts user-installed CAs for browsers by default. + Some apps (banking, Play services) ignore them. Not a Furtka bug — + an Android policy choice. +

+
+
+ +
+

iOS & iPadOS

+
+

+ Honest warning: iOS needs a signed configuration profile for a + properly trusted CA. What works today: +

+
    +
  1. Email rootCA.crt to yourself and open the attachment + in Mail. iOS prompts to install a profile.
  2. +
  3. Settings → GeneralVPN & Device Management + → tap the Furtka profile → Install.
  4. +
  5. Settings → GeneralAboutCertificate + Trust Settings → toggle Furtka on.
  6. +
+

+ A packaged .mobileconfig makes this smoother; it's on + the roadmap but not in this release. +

+
+
+ + +
+ + + + diff --git a/assets/www/settings/index.html b/assets/www/settings/index.html index e8d93c5..9d90528 100644 --- a/assets/www/settings/index.html +++ b/assets/www/settings/index.html @@ -50,6 +50,35 @@ +
+

Local HTTPS

+
+

+ Serve this box over https:/// + with a green padlock. Install the Furtka root CA once per device, then + optionally force every HTTP request to redirect. +

+
+
CA fingerprint (SHA-256)
+
Reachable from this browser
checking…
+
+
+ + Per-OS install guide +
+ + +

+
+
+

Appearance

@@ -182,6 +211,111 @@ } }); + // --- Local HTTPS -------------------------------------------------- + + const httpsFingerprintEl = document.getElementById('https-fingerprint'); + const httpsReachableEl = document.getElementById('https-reachable'); + const httpsHostEl = document.getElementById('https-host'); + const httpsDownloadBtn = document.getElementById('https-download-btn'); + const httpsForceWrap = document.getElementById('https-force-wrap'); + const httpsForceHint = document.getElementById('https-force-hint'); + const httpsForce = document.getElementById('https-force'); + const httpsStatusEl = document.getElementById('https-status'); + + httpsHostEl.textContent = location.hostname; + + httpsDownloadBtn.addEventListener('click', () => { + // Use an anchor with the download attr so the browser treats + // the cert as a download rather than rendering it. + const a = document.createElement('a'); + a.href = '/rootCA.crt'; + a.download = 'furtka-local-rootCA.crt'; + document.body.appendChild(a); + a.click(); + a.remove(); + }); + + async function refreshHttpsStatus() { + try { + const r = await fetch('/api/furtka/https/status', { cache: 'no-store' }); + if (!r.ok) return; + const s = await r.json(); + httpsFingerprintEl.textContent = s.fingerprint_sha256 || 'waiting for Caddy…'; + httpsDownloadBtn.disabled = !s.ca_available; + httpsForce.checked = !!s.force_https; + updateForceToggleVisibility(s); + } catch (e) { + /* next refresh will retry */ + } + } + + async function probeHttpsReachable() { + if (location.protocol === 'https:') { + httpsReachableEl.textContent = 'yes — you are on HTTPS now'; + return true; + } + try { + // no-cors: we don't need the response body, just whether the + // TLS handshake + fetch succeed. Browsers reject on untrusted + // cert with a TypeError, which is exactly the signal we want. + await fetch('https://' + location.hostname + '/furtka.json', + { cache: 'no-store', mode: 'no-cors' }); + httpsReachableEl.textContent = 'yes — CA already trusted'; + return true; + } catch (e) { + httpsReachableEl.textContent = 'no — install the CA first'; + return false; + } + } + + let httpsReachableCache = false; + function updateForceToggleVisibility(status) { + // Show the force-redirect toggle only when both: + // - Caddy's CA exists (otherwise there's no HTTPS to redirect to) + // - the current browser already trusts the cert (otherwise the + // user would lock themselves out of this very page) + const show = status.ca_available && httpsReachableCache; + httpsForceWrap.hidden = !show; + httpsForceHint.hidden = !show; + } + + httpsForce.addEventListener('change', async () => { + httpsForce.disabled = true; + const desired = httpsForce.checked; + httpsStatusEl.textContent = desired + ? 'Enabling HTTP→HTTPS redirect…' + : 'Disabling HTTP→HTTPS redirect…'; + httpsStatusEl.style.color = 'var(--muted)'; + try { + const r = await fetch('/api/furtka/https/force', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ enabled: desired }), + }); + const data = await r.json(); + if (!r.ok) { + httpsStatusEl.textContent = data.error || `HTTP ${r.status}`; + httpsStatusEl.style.color = 'var(--danger)'; + httpsForce.checked = !desired; + } else { + httpsStatusEl.textContent = data.force_https + ? 'Redirect on — new HTTP requests will jump to HTTPS.' + : 'Redirect off — HTTP serves the content directly.'; + } + } catch (e) { + httpsStatusEl.textContent = `Network error: ${e.message}`; + httpsStatusEl.style.color = 'var(--danger)'; + httpsForce.checked = !desired; + } finally { + httpsForce.disabled = false; + } + }); + + (async () => { + httpsReachableCache = await probeHttpsReachable(); + await refreshHttpsStatus(); + })(); + async function pollUpdateState() { try { const r = await fetch('/update-state.json', { cache: 'no-store' }); diff --git a/assets/www/style.css b/assets/www/style.css index ff24426..3244093 100644 --- a/assets/www/style.css +++ b/assets/www/style.css @@ -311,8 +311,31 @@ details.log-details[open] > summary { color: var(--fg); } gap: 0.5rem; flex-wrap: wrap; margin-top: 1rem; + align-items: center; } +/* Inline link rendered alongside a button (e.g. next to "Download CA" + on /settings). No button chrome — just accent colour + underline on + hover — so the distinction between primary action and secondary + resource stays visually clear. */ +.inline-link { + color: var(--accent); + text-decoration: none; + font-size: 0.9rem; +} +.inline-link:hover { text-decoration: underline; } + +/* Checkbox + label row for the /settings HTTPS-force toggle. */ +.https-toggle { + display: flex; + align-items: center; + gap: 0.55rem; + margin-top: 1rem; + font-size: 0.95rem; + cursor: pointer; +} +.https-toggle input { cursor: pointer; } + /* -- Shared primitives for later slices ------------------------ */ .chip { display: inline-block; diff --git a/furtka/api.py b/furtka/api.py index 965e5e8..f0249fa 100644 --- a/furtka/api.py +++ b/furtka/api.py @@ -550,6 +550,27 @@ def _do_furtka_apply(): return 202, {"status": "dispatched", "unit": "furtka-update"} +def _do_https_status(): + """Return CA fingerprint + force-redirect state for /api/furtka/https/status.""" + from furtka import https + + return 200, https.status() + + +def _do_https_force(payload): + """Toggle HTTP→HTTPS redirect for /api/furtka/https/force.""" + from furtka import https + + enabled = payload.get("enabled") + if not isinstance(enabled, bool): + return 400, {"error": "'enabled' must be a boolean"} + try: + result = https.set_force_https(enabled) + except https.HttpsError as e: + return 500, {"error": str(e)} + return 200, {"force_https": result} + + def _do_furtka_status(): """Return the latest update-state.json written by the updater. @@ -636,6 +657,9 @@ class _Handler(BaseHTTPRequestHandler): if self.path == "/api/furtka/update/status": status, body = _do_furtka_status() return self._json(status, body) + if self.path == "/api/furtka/https/status": + status, body = _do_https_status() + return self._json(status, body) # /api/apps//settings if self.path.startswith("/api/apps/") and self.path.endswith("/settings"): name = self.path[len("/api/apps/") : -len("/settings")] @@ -681,6 +705,9 @@ class _Handler(BaseHTTPRequestHandler): if self.path == "/api/furtka/update/apply": status, body = _do_furtka_apply() return self._json(status, body) + if self.path == "/api/furtka/https/force": + status, body = _do_https_force(payload) + return self._json(status, body) name = payload.get("name") if not isinstance(name, str) or not name: diff --git a/furtka/https.py b/furtka/https.py new file mode 100644 index 0000000..763f4a1 --- /dev/null +++ b/furtka/https.py @@ -0,0 +1,117 @@ +"""Local-CA HTTPS helpers for the `tls internal` setup. + +Caddy generates the local root CA lazily on first start and keeps it under +$XDG_DATA_HOME/caddy/pki/authorities/local/ — on the target that's +/var/lib/caddy/.local/share/caddy/pki/authorities/local/ (the caddy system +user's XDG_DATA_HOME resolves there). 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. +""" + +import base64 +import hashlib +import re +import subprocess +from pathlib import Path + +CA_CERT_PATH = Path( + "/var/lib/caddy/.local/share/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" + +_PEM_RE = re.compile( + r"-----BEGIN CERTIFICATE-----\s*(.+?)\s*-----END CERTIFICATE-----", + re.DOTALL, +) + + +class HttpsError(Exception): + """Recoverable failure from set_force_https — the caller should 5xx.""" + + +def _ca_fingerprint(ca_path: Path) -> str | None: + try: + pem = ca_path.read_text() + except (FileNotFoundError, PermissionError, IsADirectoryError): + return None + match = _PEM_RE.search(pem) + if not match: + return None + try: + der = base64.b64decode("".join(match.group(1).split())) + except (ValueError, base64.binascii.Error): + return None + return hashlib.sha256(der).hexdigest().upper() + + +def _format_fingerprint(hex_upper: str) -> str: + return ":".join(hex_upper[i : i + 2] for i in range(0, len(hex_upper), 2)) + + +def status( + ca_path: Path = CA_CERT_PATH, + snippet: Path = REDIRECT_SNIPPET, +) -> dict: + 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(), + "ca_download_url": "/rootCA.crt", + } + + +def _default_reload() -> None: + subprocess.run( + ["systemctl", "reload", "caddy"], + check=True, + capture_output=True, + text=True, + ) + + +def set_force_https( + enabled: bool, + snippet_dir: Path = SNIPPET_DIR, + snippet: Path = REDIRECT_SNIPPET, + 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. + """ + snippet_dir.mkdir(mode=0o755, parents=True, exist_ok=True) + had = snippet.is_file() + previous = snippet.read_text() if had else None + if enabled: + snippet.write_text(REDIRECT_CONTENT) + elif had: + snippet.unlink() + + try: + reload_caddy() + except subprocess.CalledProcessError as e: + _revert(snippet, previous) + 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) + raise HttpsError(f"systemctl not available: {e}") from e + return enabled + + +def _revert(snippet: Path, previous: str | None) -> None: + if previous is None: + try: + snippet.unlink() + except FileNotFoundError: + pass + else: + snippet.write_text(previous) diff --git a/furtka/updater.py b/furtka/updater.py index 5ebc5a7..09760ea 100644 --- a/furtka/updater.py +++ b/furtka/updater.py @@ -46,6 +46,9 @@ FORGEJO_REPO = os.environ.get("FURTKA_FORGEJO_REPO", "daniel/furtka") _FURTKA_ROOT = Path(os.environ.get("FURTKA_ROOT", "/opt/furtka")) _STATE_DIR = Path(os.environ.get("FURTKA_STATE_DIR", "/var/lib/furtka")) _CADDYFILE_LIVE = Path(os.environ.get("FURTKA_CADDYFILE_PATH", "/etc/caddy/Caddyfile")) +_CADDY_SNIPPET_DIR = Path( + os.environ.get("FURTKA_CADDY_SNIPPET_DIR", str(_CADDYFILE_LIVE.parent / "furtka.d")) +) _SYSTEMD_DIR = Path(os.environ.get("FURTKA_SYSTEMD_DIR", "/etc/systemd/system")) @@ -218,6 +221,10 @@ def _refresh_caddyfile(source: Path) -> bool: if the file changed (so caddy needs more than a bare reload).""" 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. + _CADDY_SNIPPET_DIR.mkdir(mode=0o755, parents=True, exist_ok=True) if _CADDYFILE_LIVE.is_file() and source.read_bytes() == _CADDYFILE_LIVE.read_bytes(): return False _CADDYFILE_LIVE.parent.mkdir(parents=True, exist_ok=True) diff --git a/tests/test_https.py b/tests/test_https.py new file mode 100644 index 0000000..bcf6229 --- /dev/null +++ b/tests/test_https.py @@ -0,0 +1,166 @@ +"""Tests for furtka.https — fingerprint extraction + force-HTTPS toggle. + +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. +""" + +import subprocess + +import pytest + +from furtka import https + +# Self-signed test-only cert. Don't trust it anywhere; it's here because +# we need a real PEM whose fingerprint we can pre-compute. +_TEST_CERT_PEM = """-----BEGIN CERTIFICATE----- +MIIBjjCCATOgAwIBAgIUGIKx2BGMvNQwAcZvjwJiaJO1GvEwCgYIKoZIzj0EAwIw +HDEaMBgGA1UEAwwRRnVydGthIFRlc3QgTG9jYWwwHhcNMjYwNDE3MTAxNTMxWhcN +MzYwNDE0MTAxNTMxWjAcMRowGAYDVQQDDBFGdXJ0a2EgVGVzdCBMb2NhbDBZMBMG +ByqGSM49AgEGCCqGSM49AwEHA0IABIfWX2oVXrw+iv4lCcIIceoX24bvRdlEECB5 +QoMYphmlOoI492tRCGHxA8eaIwIYqFn1DzBKBRSL0H3xcu+4Pg6jUzBRMB0GA1Ud +DgQWBBSMizCL5Kh+SLE5n12oKV05L9bJXjAfBgNVHSMEGDAWgBSMizCL5Kh+SLE5 +n12oKV05L9bJXjAPBgNVHRMBAf8EBTADAQH/MAoGCCqGSM49BAMCA0kAMEYCIQDp +6etGEuj7AGD5zzyzDSpmRiMEgBp1k6fVoLYW7N2K3AIhAK8khUp3gKPo4UqtWNK9 +Cs/B0mzRy2MUPGdZ5QU6LoDz +-----END CERTIFICATE----- +""" +_TEST_CERT_FP_SHA256 = ( + "40:A7:98:2E:8D:1F:4C:0D:9B:E6:87:ED:91:FA:6F:B1:" + "3D:8A:10:06:79:7C:08:A9:8F:AD:71:0C:B8:29:87:28" +) + + +def test_ca_fingerprint_matches_openssl(tmp_path): + cert = tmp_path / "root.crt" + cert.write_text(_TEST_CERT_PEM) + fp_hex = https._ca_fingerprint(cert) + assert fp_hex is not None + assert https._format_fingerprint(fp_hex) == _TEST_CERT_FP_SHA256 + + +def test_ca_fingerprint_missing_file(tmp_path): + assert https._ca_fingerprint(tmp_path / "nope.crt") is None + + +def test_ca_fingerprint_no_pem_block(tmp_path): + garbage = tmp_path / "root.crt" + garbage.write_text("not a certificate") + assert https._ca_fingerprint(garbage) is None + + +def test_status_no_ca_no_snippet(tmp_path): + s = https.status(ca_path=tmp_path / "root.crt", snippet=tmp_path / "redirect.caddyfile") + assert s == { + "ca_available": False, + "fingerprint_sha256": None, + "force_https": False, + "ca_download_url": "/rootCA.crt", + } + + +def test_status_with_ca_and_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) + 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" + calls = [] + + def fake_reload(): + calls.append("reload") + + result = https.set_force_https( + True, snippet_dir=snippet_dir, snippet=snippet, reload_caddy=fake_reload + ) + assert result is True + assert snippet.read_text() == https.REDIRECT_CONTENT + 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) + + result = https.set_force_https( + False, snippet_dir=snippet_dir, snippet=snippet, reload_caddy=lambda: None + ) + assert result is False + assert not 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 + ) + assert result is False + assert not snippet.exists() + + +def test_reload_failure_rolls_back_enable(tmp_path): + snippet_dir = tmp_path / "furtka.d" + snippet = snippet_dir / "redirect.caddyfile" + + 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() + + +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) + + 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 + + +def test_systemctl_missing_raises_and_rolls_back(tmp_path): + snippet_dir = tmp_path / "furtka.d" + snippet = snippet_dir / "redirect.caddyfile" + + 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() + + +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" diff --git a/tests/test_webinstaller_assets.py b/tests/test_webinstaller_assets.py index 739ee7a..1901324 100644 --- a/tests/test_webinstaller_assets.py +++ b/tests/test_webinstaller_assets.py @@ -121,6 +121,46 @@ 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; + # :443 uses Caddy's built-in local CA (tls internal) so users who have + # installed it get the green padlock. + caddy = (ASSETS / "Caddyfile").read_text() + assert ":80 {" in caddy + assert ":443 {" in caddy + assert "tls internal" in caddy + # Shared routes live in a named snippet to avoid drift between the two + # listeners — both site blocks must import it. + assert "(furtka_routes)" in caddy + assert caddy.count("import furtka_routes") == 2 + + +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 + # treats it as a download rather than trying to render it. + caddy = (ASSETS / "Caddyfile").read_text() + assert "handle /rootCA.crt" in caddy + assert "/var/lib/caddy/.local/share/caddy/pki/authorities/local" in caddy + assert 'attachment; filename=furtka-local-rootCA.crt' in caddy + + +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" + + def test_systemd_units_reference_current_paths(): for unit in ("furtka-status.service", "furtka-welcome.service"): body = (ASSETS / "systemd" / unit).read_text() diff --git a/webinstaller/app.py b/webinstaller/app.py index ab80862..f9cdced 100644 --- a/webinstaller/app.py +++ b/webinstaller/app.py @@ -320,6 +320,13 @@ def _post_install_commands(hostname): "/etc/nsswitch.conf" ) return [ + # Import dir for the HTTP→HTTPS force-redirect snippet. The + # /api/furtka/https/force endpoint writes/removes a .caddyfile here + # to toggle the redirect. Must exist before Caddy starts — the + # Caddyfile's glob `import /etc/caddy/furtka.d/*.caddyfile` tolerates + # 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", # 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