Some checks failed
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>
115 lines
3.5 KiB
Python
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)
|