From 2468cb3ded61b58ac2273d43661af6706ed594ce Mon Sep 17 00:00:00 2001 From: Daniel Maksymilian Syrnicki Date: Mon, 28 Sep 2026 14:21:14 +0200 Subject: [PATCH] fix(https): redirect only the certificate's hostnames, with 302 MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 .local and 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 Claude-Session: https://claude.ai/code/session_01Wq1hzUp4ZmCKHDuLKCnu73 --- furtka/https.py | 24 +++++++++++++++++++++--- furtka/updater.py | 15 +++++++++++++++ tests/test_https.py | 16 +++++++++++----- tests/test_updater.py | 28 ++++++++++++++++++++++++++++ 4 files changed, 75 insertions(+), 8 deletions(-) diff --git a/furtka/https.py b/furtka/https.py index ce8e582..ed08759 100644 --- a/furtka/https.py +++ b/furtka/https.py @@ -36,7 +36,11 @@ 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" +# 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") @@ -62,6 +66,19 @@ def _read_hostname(hostname_file: Path = HOSTNAME_FILE) -> str: 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. @@ -151,8 +168,9 @@ def set_force_https( 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))) + 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() diff --git a/furtka/updater.py b/furtka/updater.py index 39455fd..b3aceea 100644 --- a/furtka/updater.py +++ b/furtka/updater.py @@ -192,6 +192,20 @@ def _maybe_migrate_preserve_https() -> None: ) +def _maybe_migrate_redirect_snippet() -> None: + """26.21 migration: swap the old catch-all 301 redirect for the + hostname-scoped 302 one, so boxes reached by IP can load the UI again + and turning HTTPS off stops sticking in browser caches.""" + from furtka import https + + redirect_snippet = _CADDY_SNIPPET_DIR / "redirect.caddyfile" + if not redirect_snippet.is_file(): + return + if redirect_snippet.read_text() != https.LEGACY_REDIRECT_CONTENT: + return + redirect_snippet.write_text(https._redirect_snippet_content(_current_hostname())) + + 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). @@ -215,6 +229,7 @@ def _refresh_caddyfile(source: Path) -> bool: # hostname block is gone, so the redirect would 301 to a dead :443. # Preserve intent by writing the HTTPS listener snippet too. _maybe_migrate_preserve_https() + _maybe_migrate_redirect_snippet() rendered = source.read_text().replace(_CADDYFILE_HOSTNAME_MARKER, _current_hostname()) if _CADDYFILE_LIVE.is_file() and rendered == _CADDYFILE_LIVE.read_text(): return False diff --git a/tests/test_https.py b/tests/test_https.py index 7b423cf..86dc8b9 100644 --- a/tests/test_https.py +++ b/tests/test_https.py @@ -113,7 +113,7 @@ def test_set_force_enable_writes_both_snippets_and_reloads(tmp_path): result = https.set_force_https(True, reload_caddy=fake_reload, **p) assert result is True - assert p["snippet"].read_text() == https.REDIRECT_CONTENT + assert p["snippet"].read_text() == https._redirect_snippet_content("testbox") written = p["https_snippet"].read_text() assert "testbox.local, testbox" in written assert "tls internal" in written @@ -135,7 +135,7 @@ def test_set_force_disable_removes_both_snippets(tmp_path): p = _paths(tmp_path) p["snippet_dir"].mkdir() p["https_snippet_dir"].mkdir() - p["snippet"].write_text(https.REDIRECT_CONTENT) + p["snippet"].write_text(https._redirect_snippet_content("testbox")) p["https_snippet"].write_text("furtka.local { tls internal }\n") result = https.set_force_https(False, reload_caddy=lambda: None, **p) @@ -199,10 +199,16 @@ def test_systemctl_missing_raises_and_rolls_back(tmp_path): assert not p["https_snippet"].exists() -def test_redirect_snippet_content_is_caddy_redir_directive(): - # Lock the exact directive. A regression here silently stops the +def test_redirect_snippet_only_covers_cert_names_and_is_temporary(): + # Lock the exact directives. A regression here silently stops the # redirect from taking effect even though the file-swap looks fine. - assert https.REDIRECT_CONTENT.strip() == "redir https://{host}{uri} permanent" + # Only the names the tls-internal block has a cert for are redirected + # (an IP would hit a failing TLS handshake), and 302 so switching HTTPS + # off isn't stuck in browser caches like the old 301 was. + assert https._redirect_snippet_content("mybox") == ( + "@furtka_https_host host mybox.local mybox\n" + "redir @furtka_https_host https://{host}{uri} 302\n" + ) def test_https_snippet_content_has_tls_internal_and_routes(tmp_path): diff --git a/tests/test_updater.py b/tests/test_updater.py index 13fddd9..bba2d3e 100644 --- a/tests/test_updater.py +++ b/tests/test_updater.py @@ -539,3 +539,31 @@ def test_health_check_survives_read_timeout(updater, monkeypatch): monkeypatch.setattr("urllib.request.urlopen", slow) monkeypatch.setattr(updater.time, "sleep", lambda _s: None) assert updater._health_check("http://127.0.0.1:7000/api/apps", deadline_s=0.05) is False + + +def test_refresh_caddyfile_migrates_legacy_301_redirect(updater, tmp_path): + from furtka import https + + snippet_dir = tmp_path / "etc_caddy" / "furtka.d" + snippet_dir.mkdir(parents=True) + (tmp_path / "etc_caddy" / "furtka-https.d").mkdir() + (tmp_path / "etc_caddy" / "furtka-https.d" / "https.caddyfile").write_text("x\n") + redirect = snippet_dir / "redirect.caddyfile" + redirect.write_text(https.LEGACY_REDIRECT_CONTENT) + src = tmp_path / "Caddyfile.src" + src.write_text("# caddy\n") + updater._refresh_caddyfile(src) + assert redirect.read_text() == https._redirect_snippet_content("testbox") + + +def test_refresh_caddyfile_leaves_custom_redirect_alone(updater, tmp_path): + snippet_dir = tmp_path / "etc_caddy" / "furtka.d" + snippet_dir.mkdir(parents=True) + (tmp_path / "etc_caddy" / "furtka-https.d").mkdir() + (tmp_path / "etc_caddy" / "furtka-https.d" / "https.caddyfile").write_text("x\n") + redirect = snippet_dir / "redirect.caddyfile" + redirect.write_text("# hand-edited\n") + src = tmp_path / "Caddyfile.src" + src.write_text("# caddy\n") + updater._refresh_caddyfile(src) + assert redirect.read_text() == "# hand-edited\n"