furtka/furtka/https.py
Daniel Maksymilian Syrnicki 8fbe67ffb9
Some checks failed
Build ISO / build-iso (push) Waiting to run
CI / lint (push) Failing after 2m11s
CI / test (push) Successful in 2m8s
CI / validate-json (push) Successful in 55s
CI / markdown-links (push) Successful in 25s
Deploy site / deploy (push) Successful in 8s
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:39:48 +02:00

115 lines
3.5 KiB
Python

"""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/ — our packaged caddy.service
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.
"""
import base64
import hashlib
import re
import subprocess
from pathlib import Path
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"
_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)