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 @@
+
+
+
+
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