diff --git a/assets/Caddyfile b/assets/Caddyfile index ba3c753..71ea0ca 100644 --- a/assets/Caddyfile +++ b/assets/Caddyfile @@ -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 } diff --git a/furtka/https.py b/furtka/https.py index c744319..f59189e 100644 --- a/furtka/https.py +++ b/furtka/https.py @@ -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" diff --git a/furtka/updater.py b/furtka/updater.py index 09760ea..e36e6e7 100644 --- a/furtka/updater.py +++ b/furtka/updater.py @@ -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 diff --git a/tests/test_updater.py b/tests/test_updater.py index 5a1839e..cf8c657 100644 --- a/tests/test_updater.py +++ b/tests/test_updater.py @@ -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() diff --git a/tests/test_webinstaller_assets.py b/tests/test_webinstaller_assets.py index e3eeba8..5b82249 100644 --- a/tests/test_webinstaller_assets.py +++ b/tests/test_webinstaller_assets.py @@ -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 diff --git a/webinstaller/app.py b/webinstaller/app.py index f9cdced..31847be 100644 --- a/webinstaller/app.py +++ b/webinstaller/app.py @@ -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,