fix(https): restore TLS handshake — name hostname + correct PKI path
Some checks failed
Build ISO / build-iso (push) Waiting to run
CI / lint (push) Failing after 2m11s
CI / test (push) Successful in 2m8s
CI / validate-json (push) Successful in 55s
CI / markdown-links (push) Successful in 25s
Deploy site / deploy (push) Successful in 8s

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>
This commit is contained in:
Daniel Maksymilian Syrnicki 2026-04-20 11:38:16 +02:00
parent 9ae14f4108
commit 8fbe67ffb9
6 changed files with 126 additions and 25 deletions

View file

@ -1,18 +1,33 @@
# Serves the Furtka landing page + live JSON on :80 (plain HTTP) and :443
# (HTTPS via Caddy's built-in `tls internal` — locally-issued certs signed
# Serves the Furtka landing page + live JSON on :80 (plain HTTP) and on
# HTTPS via Caddy's built-in `tls internal` — locally-issued certs signed
# by a root CA that Caddy generates on first start and stores under
# /var/lib/caddy/.local/share/caddy/pki/authorities/local/). Static pages
# are read from /opt/furtka/current/ — updates flip the symlink and
# everything picks up the new content without a Caddy restart (a
# `systemctl reload caddy` is still triggered post-swap to flush the
# file-server's handle cache). /apps and /api are reverse-proxied to the
# resource-manager API (furtka serve, bound to 127.0.0.1:7000).
# /var/lib/caddy/pki/authorities/local/. Static pages are read from
# /opt/furtka/current/ — updates flip the symlink and everything picks up
# the new content without a Caddy restart (a `systemctl reload caddy` is
# still triggered post-swap to flush the file-server's handle cache).
# /apps and /api are reverse-proxied to the resource-manager API
# (furtka serve, bound to 127.0.0.1:7000).
#
# Hostname templating: __FURTKA_HOSTNAME__ gets substituted with the
# install-time hostname by webinstaller/app.py on first install and by
# furtka.updater._refresh_caddyfile on every self-update. A bare `:443
# { tls internal }` (no hostname) never triggers leaf-cert issuance, so
# SNI-based handshakes die with `SSL_ERROR_INTERNAL_ERROR_ALERT` — the
# 26.4-alpha regression this file exists to cure.
#
# Force-HTTPS: /etc/caddy/furtka.d/*.caddyfile gets imported into the :80
# block. The /api/furtka/https/force endpoint creates or removes
# redirect.caddyfile there to toggle the HTTP→HTTPS redirect, then reloads
# Caddy. Glob imports silently no-op on an empty/missing directory, so the
# toggle-off state is "no file present" rather than "empty file".
{
# Named-hostname :443 blocks would otherwise make Caddy add its own
# HTTP→HTTPS redirect — but we already serve our own `:80` block and
# the opt-in /settings toggle owns the redirect. Disable the built-in
# to keep a single source of truth.
auto_https disable_redirects
}
(furtka_routes) {
handle /api/* {
reverse_proxy localhost:7000
@ -38,7 +53,7 @@
# Available on both :80 and :443 so users can grab it before they've
# trusted it. The private key next to it stays 0600 / caddy-owned.
handle /rootCA.crt {
root * /var/lib/caddy/.local/share/caddy/pki/authorities/local
root * /var/lib/caddy/pki/authorities/local
rewrite * /root.crt
file_server
header Content-Type "application/x-x509-ca-cert"
@ -59,7 +74,7 @@
import furtka_routes
}
:443 {
__FURTKA_HOSTNAME__.local, __FURTKA_HOSTNAME__ {
tls internal
import furtka_routes
}

View file

@ -1,9 +1,9 @@
"""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 /
$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:
@ -18,7 +18,7 @@ import re
import subprocess
from pathlib import Path
CA_CERT_PATH = Path("/var/lib/caddy/.local/share/caddy/pki/authorities/local/root.crt")
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"

View file

@ -50,6 +50,8 @@ _CADDY_SNIPPET_DIR = Path(
os.environ.get("FURTKA_CADDY_SNIPPET_DIR", str(_CADDYFILE_LIVE.parent / "furtka.d"))
)
_SYSTEMD_DIR = Path(os.environ.get("FURTKA_SYSTEMD_DIR", "/etc/systemd/system"))
_HOSTNAME_FILE = Path(os.environ.get("FURTKA_HOSTNAME_FILE", "/etc/hostname"))
_CADDYFILE_HOSTNAME_MARKER = "__FURTKA_HOSTNAME__"
class UpdateError(RuntimeError):
@ -216,19 +218,38 @@ def _extract_tarball(tarball: Path, dest: Path) -> str:
return version_file.read_text().strip()
def _current_hostname() -> str:
"""Read the box's hostname from /etc/hostname, falling back to 'furtka'.
Used to substitute the __FURTKA_HOSTNAME__ marker in the shipped Caddyfile
so Caddy's `tls internal` sees a real name to issue a leaf cert for.
"""
try:
name = _HOSTNAME_FILE.read_text().strip()
except (FileNotFoundError, PermissionError, OSError):
return "furtka"
return name or "furtka"
def _refresh_caddyfile(source: Path) -> bool:
"""Copy the shipped Caddyfile to /etc/caddy/ iff it differs. Returns True
if the file changed (so caddy needs more than a bare reload)."""
if the file changed (so caddy needs more than a bare reload).
Substitutes __FURTKA_HOSTNAME__ with the current hostname before comparing
and writing same rendering the webinstaller applies at install time, so
a self-update lands byte-identical content when nothing else changed.
"""
if not source.is_file():
return False
# Snippet dir for the /api/furtka/https/force toggle. Pre-HTTPS installs
# don't have this dir; ensure it so the Caddyfile's glob import can't
# trip an older Caddy on a missing path during the first reload.
_CADDY_SNIPPET_DIR.mkdir(mode=0o755, parents=True, exist_ok=True)
if _CADDYFILE_LIVE.is_file() and source.read_bytes() == _CADDYFILE_LIVE.read_bytes():
rendered = source.read_text().replace(_CADDYFILE_HOSTNAME_MARKER, _current_hostname())
if _CADDYFILE_LIVE.is_file() and rendered == _CADDYFILE_LIVE.read_text():
return False
_CADDYFILE_LIVE.parent.mkdir(parents=True, exist_ok=True)
shutil.copy(source, _CADDYFILE_LIVE)
_CADDYFILE_LIVE.write_text(rendered)
return True

View file

@ -24,6 +24,9 @@ def updater(tmp_path, monkeypatch):
monkeypatch.setenv("FURTKA_LOCK_PATH", str(tmp_path / "update.lock"))
monkeypatch.setenv("FURTKA_CADDYFILE_PATH", str(tmp_path / "etc_caddy" / "Caddyfile"))
monkeypatch.setenv("FURTKA_SYSTEMD_DIR", str(tmp_path / "etc_systemd_system"))
hostname_file = tmp_path / "etc_hostname"
hostname_file.write_text("testbox\n")
monkeypatch.setenv("FURTKA_HOSTNAME_FILE", str(hostname_file))
(tmp_path / "etc_systemd_system").mkdir()
# Reload the module so the path constants pick up the env vars.
import importlib
@ -206,6 +209,31 @@ def test_refresh_caddyfile_noops_if_source_missing(updater, tmp_path):
assert updater._refresh_caddyfile(tmp_path / "does-not-exist") is False
def test_refresh_caddyfile_substitutes_hostname_placeholder(updater, tmp_path):
# Self-update rewrites the shipped Caddyfile against the box's real
# hostname, same substitution the installer does on first boot. Without
# this the named-hostname :443 block ships with a literal
# `__FURTKA_HOSTNAME__` and Caddy refuses to load the config.
src = tmp_path / "src"
src.write_text(
"__FURTKA_HOSTNAME__.local, __FURTKA_HOSTNAME__ {\n\ttls internal\n}\n"
)
assert updater._refresh_caddyfile(src) is True
live = updater._CADDYFILE_LIVE.read_text()
assert "testbox.local, testbox {" in live
assert "__FURTKA_HOSTNAME__" not in live
# Second call with the same source is a no-op — rendered content matches.
assert updater._refresh_caddyfile(src) is False
def test_current_hostname_falls_back_when_file_missing(updater, monkeypatch, tmp_path):
monkeypatch.setenv("FURTKA_HOSTNAME_FILE", str(tmp_path / "missing"))
import importlib
importlib.reload(updater)
assert updater._current_hostname() == "furtka"
def test_link_new_units_only_links_missing(updater, tmp_path, monkeypatch):
unit_dir = tmp_path / "assets_systemd"
unit_dir.mkdir()

View file

@ -31,9 +31,10 @@ ASSETS = REPO_ROOT / "assets"
# (install target path, asset path under furtka/assets/) — only the files we
# still copy bit-for-bit at install time. Scripts + unit files are no longer
# copied; they're reached via /opt/furtka/current and `systemctl link`.
# copied; they're reached via /opt/furtka/current and `systemctl link`. The
# Caddyfile is not in this list because it's written with the hostname
# placeholder substituted — see test_post_install_substitutes_hostname_in_caddyfile.
ASSET_TARGETS = [
("/etc/caddy/Caddyfile", "Caddyfile"),
("/var/lib/furtka/status.json", "www/status.json"),
]
@ -123,11 +124,12 @@ def test_caddyfile_asset_serves_from_current():
def test_caddyfile_serves_both_http_and_https():
# :80 stays so users who haven't installed the CA still reach the box;
# :443 uses Caddy's built-in local CA (tls internal) so users who have
# installed it get the green padlock.
# HTTPS is served via a named-hostname block so Caddy's `tls internal`
# has something to issue a leaf cert for. A bare `:443 { tls internal }`
# never triggers issuance — that was the 26.4-alpha regression.
caddy = (ASSETS / "Caddyfile").read_text()
assert ":80 {" in caddy
assert ":443 {" in caddy
assert "__FURTKA_HOSTNAME__.local, __FURTKA_HOSTNAME__ {" in caddy
assert "tls internal" in caddy
# Shared routes live in a named snippet to avoid drift between the two
# listeners — both site blocks must import it.
@ -135,6 +137,14 @@ def test_caddyfile_serves_both_http_and_https():
assert caddy.count("import furtka_routes") == 2
def test_caddyfile_disables_caddy_auto_redirects():
# Named-hostname :443 block makes Caddy want to add its own HTTP→HTTPS
# redirect. The /settings toggle is the single source of truth, so the
# built-in has to be off — otherwise the toggle and auto_https race.
caddy = (ASSETS / "Caddyfile").read_text()
assert "auto_https disable_redirects" in caddy
def test_caddyfile_imports_force_redirect_snippet_dir():
# The /api/furtka/https/force endpoint toggles HTTP→HTTPS by writing or
# removing a snippet file in this dir; the Caddyfile must glob-import it
@ -146,13 +156,31 @@ def test_caddyfile_imports_force_redirect_snippet_dir():
def test_caddyfile_exposes_root_ca_download():
# /rootCA.crt is the download handle the UI uses. It must map to the
# Caddy local-CA pki path and set a Content-Disposition so the browser
# treats it as a download rather than trying to render it.
# treats it as a download rather than trying to render it. Path is the
# real one Caddy uses under XDG_DATA_HOME=/var/lib (see caddy.service
# Environment= directive) — not the /var/lib/caddy/.local/share/caddy/
# path Caddy docs show for non-systemd installs.
caddy = (ASSETS / "Caddyfile").read_text()
assert "handle /rootCA.crt" in caddy
assert "/var/lib/caddy/.local/share/caddy/pki/authorities/local" in caddy
assert "/var/lib/caddy/pki/authorities/local" in caddy
assert ".local/share/caddy" not in caddy
assert "attachment; filename=furtka-local-rootCA.crt" in caddy
def test_post_install_substitutes_hostname_in_caddyfile(install_cmds):
# Fresh installs: the placeholder the asset ships with must be replaced
# with the hostname the user picked in the form. The `testhost` value
# comes from the install_cmds fixture. Without substitution Caddy's
# `tls internal` never issues a leaf cert for the real hostname.
caddyfile_cmd = next(
(c for c in install_cmds if " > /etc/caddy/Caddyfile" in c), None
)
assert caddyfile_cmd is not None
written = _extract_written_content(caddyfile_cmd, "/etc/caddy/Caddyfile")
assert "__FURTKA_HOSTNAME__" not in written
assert "testhost.local, testhost {" in written
def test_post_install_creates_furtka_d_snippet_dir(install_cmds):
# Pre-existing installs pick up the import path via updater._refresh_caddyfile,
# but fresh installs never run that — this command is the only guarantee

View file

@ -331,7 +331,16 @@ def _post_install_commands(hostname):
# (systemd unit points there). Content comes from the shipped asset,
# which we copy in at install time so updates that change routing
# need a new release to refresh it.
_write_file_cmd("/etc/caddy/Caddyfile", _read_asset("Caddyfile")),
#
# __FURTKA_HOSTNAME__ is the placeholder the asset carries in place
# of the real hostname — Caddy's `tls internal` needs a named site
# block to issue a leaf cert, and the hostname isn't known until
# the user fills in the form. Self-updates re-apply the same
# substitution against /etc/hostname (see updater._refresh_caddyfile).
_write_file_cmd(
"/etc/caddy/Caddyfile",
_read_asset("Caddyfile").replace("__FURTKA_HOSTNAME__", hostname),
),
# Initial status.json so Caddy doesn't 404 before furtka-status fires.
_write_file_cmd("/var/lib/furtka/status.json", _read_asset("www/status.json")),
nss_sed,