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
222 lines
8.4 KiB
Python
222 lines
8.4 KiB
Python
"""Tests for furtka.https — fingerprint extraction + HTTPS toggle.
|
|
|
|
Since 26.15-alpha the toggle writes/removes TWO snippets atomically:
|
|
- The top-level HTTPS listener snippet (enables :443 + tls internal)
|
|
- The :80-scoped redirect snippet (forces HTTP → HTTPS)
|
|
|
|
The fingerprint case uses a throwaway self-signed EC cert with a known
|
|
reference fingerprint (computed once via `openssl x509 -fingerprint
|
|
-sha256 -noout`) so we verify the PEM → DER → SHA256 path without a
|
|
runtime subprocess dependency. The toggle cases stub the caddy reload
|
|
so we assert both snippet files are written / removed together and that
|
|
reload failures roll BOTH state back.
|
|
"""
|
|
|
|
import subprocess
|
|
|
|
import pytest
|
|
|
|
from furtka import https
|
|
|
|
# Self-signed test-only cert. Don't trust it anywhere; it's here because
|
|
# we need a real PEM whose fingerprint we can pre-compute.
|
|
_TEST_CERT_PEM = """-----BEGIN CERTIFICATE-----
|
|
MIIBjjCCATOgAwIBAgIUGIKx2BGMvNQwAcZvjwJiaJO1GvEwCgYIKoZIzj0EAwIw
|
|
HDEaMBgGA1UEAwwRRnVydGthIFRlc3QgTG9jYWwwHhcNMjYwNDE3MTAxNTMxWhcN
|
|
MzYwNDE0MTAxNTMxWjAcMRowGAYDVQQDDBFGdXJ0a2EgVGVzdCBMb2NhbDBZMBMG
|
|
ByqGSM49AgEGCCqGSM49AwEHA0IABIfWX2oVXrw+iv4lCcIIceoX24bvRdlEECB5
|
|
QoMYphmlOoI492tRCGHxA8eaIwIYqFn1DzBKBRSL0H3xcu+4Pg6jUzBRMB0GA1Ud
|
|
DgQWBBSMizCL5Kh+SLE5n12oKV05L9bJXjAfBgNVHSMEGDAWgBSMizCL5Kh+SLE5
|
|
n12oKV05L9bJXjAPBgNVHRMBAf8EBTADAQH/MAoGCCqGSM49BAMCA0kAMEYCIQDp
|
|
6etGEuj7AGD5zzyzDSpmRiMEgBp1k6fVoLYW7N2K3AIhAK8khUp3gKPo4UqtWNK9
|
|
Cs/B0mzRy2MUPGdZ5QU6LoDz
|
|
-----END CERTIFICATE-----
|
|
"""
|
|
_TEST_CERT_FP_SHA256 = (
|
|
"40:A7:98:2E:8D:1F:4C:0D:9B:E6:87:ED:91:FA:6F:B1:"
|
|
"3D:8A:10:06:79:7C:08:A9:8F:AD:71:0C:B8:29:87:28"
|
|
)
|
|
|
|
|
|
def _paths(tmp_path):
|
|
"""Return the four paths the toggle touches, in a dict for kwargs
|
|
spreading. Keeps each test's fixture boilerplate small."""
|
|
return {
|
|
"snippet_dir": tmp_path / "furtka.d",
|
|
"snippet": tmp_path / "furtka.d" / "redirect.caddyfile",
|
|
"https_snippet_dir": tmp_path / "furtka-https.d",
|
|
"https_snippet": tmp_path / "furtka-https.d" / "https.caddyfile",
|
|
"hostname_file": tmp_path / "etc_hostname",
|
|
}
|
|
|
|
|
|
def _prepare_hostname(tmp_path, value="testbox"):
|
|
(tmp_path / "etc_hostname").write_text(f"{value}\n")
|
|
|
|
|
|
def test_ca_fingerprint_matches_openssl(tmp_path):
|
|
cert = tmp_path / "root.crt"
|
|
cert.write_text(_TEST_CERT_PEM)
|
|
fp_hex = https._ca_fingerprint(cert)
|
|
assert fp_hex is not None
|
|
assert https._format_fingerprint(fp_hex) == _TEST_CERT_FP_SHA256
|
|
|
|
|
|
def test_ca_fingerprint_missing_file(tmp_path):
|
|
assert https._ca_fingerprint(tmp_path / "nope.crt") is None
|
|
|
|
|
|
def test_ca_fingerprint_no_pem_block(tmp_path):
|
|
garbage = tmp_path / "root.crt"
|
|
garbage.write_text("not a certificate")
|
|
assert https._ca_fingerprint(garbage) is None
|
|
|
|
|
|
def test_status_no_ca_no_snippet(tmp_path):
|
|
s = https.status(ca_path=tmp_path / "root.crt", https_snippet=tmp_path / "https.caddyfile")
|
|
assert s == {
|
|
"ca_available": False,
|
|
"fingerprint_sha256": None,
|
|
"force_https": False,
|
|
"ca_download_url": "/rootCA.crt",
|
|
}
|
|
|
|
|
|
def test_status_with_ca_and_https_snippet(tmp_path):
|
|
ca = tmp_path / "root.crt"
|
|
ca.write_text(_TEST_CERT_PEM)
|
|
https_snip = tmp_path / "https.caddyfile"
|
|
https_snip.write_text("furtka.local, furtka {\n\ttls internal\n\timport furtka_routes\n}\n")
|
|
s = https.status(ca_path=ca, https_snippet=https_snip)
|
|
assert s["ca_available"] is True
|
|
assert s["fingerprint_sha256"] == _TEST_CERT_FP_SHA256
|
|
assert s["force_https"] is True
|
|
|
|
|
|
def test_status_force_reflects_https_snippet_not_redirect(tmp_path):
|
|
"""Authoritative signal for "HTTPS is on" is the listener snippet —
|
|
a lone redirect without a :443 listener wouldn't actually serve
|
|
HTTPS, so the status must NOT report it as on. Locks 26.15 semantic."""
|
|
ca = tmp_path / "root.crt"
|
|
ca.write_text(_TEST_CERT_PEM)
|
|
s = https.status(ca_path=ca, https_snippet=tmp_path / "does-not-exist.caddyfile")
|
|
assert s["force_https"] is False
|
|
|
|
|
|
def test_set_force_enable_writes_both_snippets_and_reloads(tmp_path):
|
|
_prepare_hostname(tmp_path)
|
|
p = _paths(tmp_path)
|
|
calls = []
|
|
|
|
def fake_reload():
|
|
calls.append("reload")
|
|
|
|
result = https.set_force_https(True, reload_caddy=fake_reload, **p)
|
|
assert result is True
|
|
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
|
|
assert "import furtka_routes" in written
|
|
assert calls == ["reload"]
|
|
|
|
|
|
def test_set_force_uses_fallback_hostname_when_file_missing(tmp_path):
|
|
# No /etc/hostname → fall back to 'furtka' so Caddy gets a parseable
|
|
# block instead of an empty hostname that would fail config load.
|
|
p = _paths(tmp_path)
|
|
result = https.set_force_https(True, reload_caddy=lambda: None, **p)
|
|
assert result is True
|
|
assert "furtka.local, furtka" in p["https_snippet"].read_text()
|
|
|
|
|
|
def test_set_force_disable_removes_both_snippets(tmp_path):
|
|
_prepare_hostname(tmp_path)
|
|
p = _paths(tmp_path)
|
|
p["snippet_dir"].mkdir()
|
|
p["https_snippet_dir"].mkdir()
|
|
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)
|
|
assert result is False
|
|
assert not p["snippet"].exists()
|
|
assert not p["https_snippet"].exists()
|
|
|
|
|
|
def test_set_force_disable_is_idempotent_when_already_off(tmp_path):
|
|
p = _paths(tmp_path)
|
|
result = https.set_force_https(False, reload_caddy=lambda: None, **p)
|
|
assert result is False
|
|
assert not p["snippet"].exists()
|
|
assert not p["https_snippet"].exists()
|
|
|
|
|
|
def test_reload_failure_rolls_back_enable(tmp_path):
|
|
_prepare_hostname(tmp_path)
|
|
p = _paths(tmp_path)
|
|
|
|
def failing_reload():
|
|
raise subprocess.CalledProcessError(1, ["systemctl"], stderr="bad config")
|
|
|
|
with pytest.raises(https.HttpsError, match="caddy reload failed: bad config"):
|
|
https.set_force_https(True, reload_caddy=failing_reload, **p)
|
|
# Rollback: since neither snippet existed before, neither exists after.
|
|
assert not p["snippet"].exists()
|
|
assert not p["https_snippet"].exists()
|
|
|
|
|
|
def test_reload_failure_rolls_back_disable(tmp_path):
|
|
_prepare_hostname(tmp_path)
|
|
p = _paths(tmp_path)
|
|
p["snippet_dir"].mkdir()
|
|
p["https_snippet_dir"].mkdir()
|
|
original_redirect = "redir https://{host}{uri} permanent\n# marker\n"
|
|
original_https = "# old https block\nfurtka.local { tls internal }\n"
|
|
p["snippet"].write_text(original_redirect)
|
|
p["https_snippet"].write_text(original_https)
|
|
|
|
def failing_reload():
|
|
raise subprocess.CalledProcessError(1, ["systemctl"], stderr="bad config")
|
|
|
|
with pytest.raises(https.HttpsError):
|
|
https.set_force_https(False, reload_caddy=failing_reload, **p)
|
|
# Rollback: both snippets are restored to their exact prior contents.
|
|
assert p["snippet"].read_text() == original_redirect
|
|
assert p["https_snippet"].read_text() == original_https
|
|
|
|
|
|
def test_systemctl_missing_raises_and_rolls_back(tmp_path):
|
|
_prepare_hostname(tmp_path)
|
|
p = _paths(tmp_path)
|
|
|
|
def missing_systemctl():
|
|
raise FileNotFoundError(2, "No such file", "systemctl")
|
|
|
|
with pytest.raises(https.HttpsError, match="systemctl not available"):
|
|
https.set_force_https(True, reload_caddy=missing_systemctl, **p)
|
|
assert not p["snippet"].exists()
|
|
assert not p["https_snippet"].exists()
|
|
|
|
|
|
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.
|
|
# 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):
|
|
# Lock the shape of the opt-in HTTPS listener block. Caddy parses
|
|
# this verbatim — changing the shape without updating the test
|
|
# risks shipping a silently-broken Caddyfile import.
|
|
s = https._https_snippet_content("mybox")
|
|
assert "mybox.local, mybox {" in s
|
|
assert "\ttls internal" in s
|
|
assert "\timport furtka_routes" in s
|
|
assert s.endswith("}\n")
|