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/ — 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)
|