The force-HTTPS snippet was 'redir https://{host}{uri} permanent': - every Host was redirected, including a bare IP that the tls-internal block has no certificate for — devices reaching the box by IP (no mDNS on Android / older Windows, the console IP fallback) couldn't load the UI at all - 301 is cached by browsers, so switching HTTPS off kept bouncing them to a dead :443 Now a host matcher limits the redirect to <hostname>.local and <hostname> and it's a 302. Self-update rewrites the old snippet if it is still the untouched legacy content. Browsers that already cached the old 301 keep it until their cache is cleared. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Wq1hzUp4ZmCKHDuLKCnu73
201 lines
7.3 KiB
Python
201 lines
7.3 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.
|
|
|
|
HTTPS is **opt-in** since 26.15-alpha. Default Caddyfile has no `:443`
|
|
site block, so `tls internal` never triggers cert issuance. The
|
|
/settings toggle drops a snippet file into /etc/caddy/furtka-https.d/
|
|
that adds the hostname+tls-internal block (plus the redirect snippet
|
|
inside /etc/caddy/furtka.d/ for HTTP→HTTPS). Disabling the toggle
|
|
removes both snippets and reloads — Caddy falls back to HTTP-only.
|
|
|
|
Why opt-in: fresh-install boxes used to always serve a self-signed
|
|
cert on :443. Any browser that had ever trusted a previous Furtka
|
|
box's local CA rejected the new cert with an unbypassable
|
|
SEC_ERROR_BAD_SIGNATURE — Firefox in particular has no "Advanced →
|
|
Accept" for that case. Making HTTPS explicit means fresh installs
|
|
never hit that trap; users who want HTTPS download the rootCA.crt
|
|
first and then click the toggle.
|
|
|
|
This module exposes:
|
|
- status(): CA fingerprint + current toggle state
|
|
- set_force_https(enabled): write/remove BOTH snippets atomically,
|
|
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"
|
|
# Pre-26.21 redirect: permanent (browsers cache a 301 indefinitely, so
|
|
# turning HTTPS off left them bouncing to a dead :443) and for every Host —
|
|
# including a bare IP, which has no certificate, so devices that reach the
|
|
# box by IP (no mDNS on Android / older Windows) couldn't load the UI at all.
|
|
LEGACY_REDIRECT_CONTENT = "redir https://{host}{uri} permanent\n"
|
|
HTTPS_SNIPPET_DIR = Path("/etc/caddy/furtka-https.d")
|
|
HTTPS_SNIPPET = HTTPS_SNIPPET_DIR / "https.caddyfile"
|
|
HOSTNAME_FILE = Path("/etc/hostname")
|
|
|
|
_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 _read_hostname(hostname_file: Path = HOSTNAME_FILE) -> str:
|
|
"""Return the box's hostname, stripped. Falls back to 'furtka' so a
|
|
missing /etc/hostname doesn't produce an empty site block that Caddy
|
|
would reject at parse time."""
|
|
try:
|
|
value = hostname_file.read_text().strip()
|
|
except (FileNotFoundError, PermissionError, OSError):
|
|
return "furtka"
|
|
return value or "furtka"
|
|
|
|
|
|
def _redirect_snippet_content(hostname: str) -> str:
|
|
"""HTTP→HTTPS redirect for the names the HTTPS block has a cert for.
|
|
|
|
Only those hosts are redirected: an IP or any other name keeps plain
|
|
HTTP instead of landing on a TLS handshake that can't succeed. 302 so
|
|
switching HTTPS off takes effect in browsers immediately.
|
|
"""
|
|
return (
|
|
f"@furtka_https_host host {hostname}.local {hostname}\n"
|
|
"redir @furtka_https_host https://{host}{uri} 302\n"
|
|
)
|
|
|
|
|
|
def _https_snippet_content(hostname: str) -> str:
|
|
"""Caddy site block the HTTPS toggle installs at opt-in.
|
|
|
|
Serves <hostname>.local and <hostname> on :443 with Caddy's
|
|
`tls internal` (local CA auto-issuance), and imports the shared
|
|
furtka_routes snippet so the :443 listener exposes the same
|
|
routes as :80. Must be written at top-level (not inside another
|
|
site block) — that's why the Caddyfile imports furtka-https.d at
|
|
top-level rather than inside :80.
|
|
"""
|
|
return f"{hostname}.local, {hostname} {{\n\ttls internal\n\timport furtka_routes\n}}\n"
|
|
|
|
|
|
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,
|
|
https_snippet: Path = HTTPS_SNIPPET,
|
|
) -> dict:
|
|
"""force_https is True iff the HTTPS listener snippet exists.
|
|
|
|
Before 26.15-alpha this checked the redirect snippet instead — but
|
|
the redirect alone without a :443 listener wouldn't actually serve
|
|
HTTPS, so the listener snippet is the authoritative "HTTPS is on"
|
|
signal.
|
|
"""
|
|
fp = _ca_fingerprint(ca_path)
|
|
return {
|
|
"ca_available": fp is not None,
|
|
"fingerprint_sha256": _format_fingerprint(fp) if fp else None,
|
|
"force_https": 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,
|
|
https_snippet_dir: Path = HTTPS_SNIPPET_DIR,
|
|
https_snippet: Path = HTTPS_SNIPPET,
|
|
hostname_file: Path = HOSTNAME_FILE,
|
|
reload_caddy=_default_reload,
|
|
) -> bool:
|
|
"""Toggle HTTPS by writing or removing two snippets atomically:
|
|
|
|
1. The top-level HTTPS hostname+tls-internal block (enables :443
|
|
listener + Caddy's `tls internal` cert issuance)
|
|
2. The :80-scoped redirect snippet (forces HTTP → HTTPS)
|
|
|
|
Reload Caddy after the snippet swap. On reload failure both
|
|
snippets are reverted to their pre-call state so a bad config
|
|
can't leave Caddy wedged.
|
|
"""
|
|
snippet_dir.mkdir(mode=0o755, parents=True, exist_ok=True)
|
|
https_snippet_dir.mkdir(mode=0o755, parents=True, exist_ok=True)
|
|
|
|
had_redirect = snippet.is_file()
|
|
previous_redirect = snippet.read_text() if had_redirect else None
|
|
had_https = https_snippet.is_file()
|
|
previous_https = https_snippet.read_text() if had_https else None
|
|
|
|
if enabled:
|
|
hostname = _read_hostname(hostname_file)
|
|
snippet.write_text(_redirect_snippet_content(hostname))
|
|
https_snippet.write_text(_https_snippet_content(hostname))
|
|
else:
|
|
if had_redirect:
|
|
snippet.unlink()
|
|
if had_https:
|
|
https_snippet.unlink()
|
|
|
|
try:
|
|
reload_caddy()
|
|
except subprocess.CalledProcessError as e:
|
|
_revert(snippet, previous_redirect)
|
|
_revert(https_snippet, previous_https)
|
|
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_redirect)
|
|
_revert(https_snippet, previous_https)
|
|
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)
|