fix(https): redirect only the certificate's hostnames, with 302

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
This commit is contained in:
Daniel Maksymilian Syrnicki 2026-09-28 14:21:14 +02:00
parent 455150f97f
commit 2468cb3ded
4 changed files with 75 additions and 8 deletions

View file

@ -36,7 +36,11 @@ from pathlib import Path
CA_CERT_PATH = Path("/var/lib/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") SNIPPET_DIR = Path("/etc/caddy/furtka.d")
REDIRECT_SNIPPET = SNIPPET_DIR / "redirect.caddyfile" 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_DIR = Path("/etc/caddy/furtka-https.d")
HTTPS_SNIPPET = HTTPS_SNIPPET_DIR / "https.caddyfile" HTTPS_SNIPPET = HTTPS_SNIPPET_DIR / "https.caddyfile"
HOSTNAME_FILE = Path("/etc/hostname") HOSTNAME_FILE = Path("/etc/hostname")
@ -62,6 +66,19 @@ def _read_hostname(hostname_file: Path = HOSTNAME_FILE) -> str:
return value or "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: def _https_snippet_content(hostname: str) -> str:
"""Caddy site block the HTTPS toggle installs at opt-in. """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 previous_https = https_snippet.read_text() if had_https else None
if enabled: if enabled:
snippet.write_text(REDIRECT_CONTENT) hostname = _read_hostname(hostname_file)
https_snippet.write_text(_https_snippet_content(_read_hostname(hostname_file))) snippet.write_text(_redirect_snippet_content(hostname))
https_snippet.write_text(_https_snippet_content(hostname))
else: else:
if had_redirect: if had_redirect:
snippet.unlink() snippet.unlink()

View file

@ -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: def _refresh_caddyfile(source: Path) -> bool:
"""Copy the shipped Caddyfile to /etc/caddy/ iff it differs. Returns True """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).
@ -215,6 +229,7 @@ def _refresh_caddyfile(source: Path) -> bool:
# hostname block is gone, so the redirect would 301 to a dead :443. # hostname block is gone, so the redirect would 301 to a dead :443.
# Preserve intent by writing the HTTPS listener snippet too. # Preserve intent by writing the HTTPS listener snippet too.
_maybe_migrate_preserve_https() _maybe_migrate_preserve_https()
_maybe_migrate_redirect_snippet()
rendered = source.read_text().replace(_CADDYFILE_HOSTNAME_MARKER, _current_hostname()) rendered = source.read_text().replace(_CADDYFILE_HOSTNAME_MARKER, _current_hostname())
if _CADDYFILE_LIVE.is_file() and rendered == _CADDYFILE_LIVE.read_text(): if _CADDYFILE_LIVE.is_file() and rendered == _CADDYFILE_LIVE.read_text():
return False return False

View file

@ -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) result = https.set_force_https(True, reload_caddy=fake_reload, **p)
assert result is True 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() written = p["https_snippet"].read_text()
assert "testbox.local, testbox" in written assert "testbox.local, testbox" in written
assert "tls internal" 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 = _paths(tmp_path)
p["snippet_dir"].mkdir() p["snippet_dir"].mkdir()
p["https_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") p["https_snippet"].write_text("furtka.local { tls internal }\n")
result = https.set_force_https(False, reload_caddy=lambda: None, **p) 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() assert not p["https_snippet"].exists()
def test_redirect_snippet_content_is_caddy_redir_directive(): def test_redirect_snippet_only_covers_cert_names_and_is_temporary():
# Lock the exact directive. A regression here silently stops the # Lock the exact directives. A regression here silently stops the
# redirect from taking effect even though the file-swap looks fine. # 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): def test_https_snippet_content_has_tls_internal_and_routes(tmp_path):

View file

@ -539,3 +539,31 @@ def test_health_check_survives_read_timeout(updater, monkeypatch):
monkeypatch.setattr("urllib.request.urlopen", slow) monkeypatch.setattr("urllib.request.urlopen", slow)
monkeypatch.setattr(updater.time, "sleep", lambda _s: None) 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 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"