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:
parent
455150f97f
commit
2468cb3ded
4 changed files with 75 additions and 8 deletions
|
|
@ -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()
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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):
|
||||||
|
|
|
||||||
|
|
@ -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"
|
||||||
|
|
|
||||||
Loading…
Add table
Reference in a new issue