All checks were successful
Build ISO / build-iso (push) Successful in 17m23s
CI / lint (push) Successful in 27s
CI / test (push) Successful in 1m2s
CI / validate-json (push) Successful in 24s
CI / markdown-links (push) Successful in 15s
Release / release (push) Successful in 11m34s
Every Furtka since 26.5 shipped a Caddyfile with a
`__FURTKA_HOSTNAME__.local { tls internal }` site block, so every
first boot auto-generated a fresh self-signed CA + intermediate +
leaf. That worked for the first-ever Furtka user, but every reinstall
(or second box on the same LAN) produced a new CA whose intermediate
shared the fixed CN `Caddy Local Authority - ECC Intermediate` with
the previous one. Firefox caches intermediates by CN across profiles
— even private windows share cert9.db — so any visitor who had
trusted an older Furtka's CA got a cached intermediate with
mismatched keys when they hit the new box, producing
`SEC_ERROR_BAD_SIGNATURE`. Unlike UNKNOWN_ISSUER, Firefox has NO
"Advanced → Accept Risk" bypass for BAD_SIGNATURE, so fresh-install
boxes were effectively unreachable over HTTPS in any browser that
had ever seen a previous Furtka.
Validated live on the .46 test VM: fresh 26.14 ISO install → Firefox
hits BAD_SIGNATURE on https://furtka.local/ (even in private mode).
Chromium bypasses it via mDNS failure but the issue is the same.
openssl verify on the box confirms the chain is internally valid —
this is purely client-side cache pollution across boxes.
Fix:
- assets/Caddyfile: removed the hostname site block. Default install
serves :80 only — https://furtka.local connection-refuses, which is
a normal error every browser handles instead of the unbypassable
crypto fault. Added top-level import of
/etc/caddy/furtka-https.d/*.caddyfile so the /settings HTTPS toggle
can drop a listener snippet there when a user explicitly opts in.
- furtka/https.py: set_force_https now writes TWO snippets atomically
— the top-level hostname + tls internal block (enables :443) and
the :80-scoped redirect (forces HTTP→HTTPS). Disable removes both.
Reload failure rolls both back. Added _read_hostname + _https_snippet_content
helpers with `/etc/hostname` → 'furtka' fallback so a missing
hostname file doesn't produce an empty site block Caddy rejects.
- furtka/https.py::status: force_https now reads the listener
snippet (was reading the redirect snippet). A redirect without a
listener isn't actually HTTPS being served, so the listener is the
authoritative "HTTPS is on" signal.
- furtka/updater.py: new _maybe_migrate_preserve_https hook runs
inside _refresh_caddyfile on the 26.14 → 26.15 transition. If the
box had the redirect snippet on disk (user had opted into HTTPS
under the old regime), it writes the new listener snippet too so
HTTPS keeps working after the Caddyfile swap removes the hostname
block.
- webinstaller/app.py: post-install creates /etc/caddy/furtka-https.d/
alongside /etc/caddy/furtka.d/ so the glob import can't trip an
older Caddy on a missing path during the first reload.
Live-tested on .46: set_force_https(True) writes both snippets, Caddy
reloads, :443 listener comes up with fresh CA, curl -k returns 302,
HTTP 301-redirects. set_force_https(False) removes both snippets
atomically, :443 goes back to connection-refused.
Tests: test_https.py expanded from 13 to 15 cases. Toggle-on asserts
both snippets written + hostname substituted. Toggle-off asserts
both removed. Rollback cases verify BOTH snippets restore on reload
failure. New test_https_snippet_content_has_tls_internal_and_routes
locks the exact shape of the listener block.
test_webinstaller_assets.py: updated two old asserts that assumed
hostname block was in Caddyfile; new test_post_install_creates_https_snippet_dir
guards the new directory.
276 tests pass, ruff check + format clean.
Known remaining wart (documented in CHANGELOG): a browser that
trusted a prior Furtka CA still hits BAD_SIGNATURE on this box's
HTTPS after enabling it, because the fixed intermediate CN is a
Caddy-side limitation. Workaround: clear cert9.db or visit in a
fresh profile. Won't affect end users with one Furtka box ever.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
183 lines
6.4 KiB
Python
183 lines
6.4 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"
|
|
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 _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:
|
|
snippet.write_text(REDIRECT_CONTENT)
|
|
https_snippet.write_text(_https_snippet_content(_read_hostname(hostname_file)))
|
|
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)
|