furtka/furtka/https.py
Daniel Maksymilian Syrnicki 3f7b97c8c7 style: ruff format two files the pre-commit hook caught
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-18 11:41:28 +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/ — 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)