Caddy now serves both :80 (plain HTTP, unchanged default) and :443 with tls internal — it generates its own per-box root CA on first start, stored under /var/lib/caddy/.local/share/caddy/pki/authorities/local/. Users can download rootCA.crt at /rootCA.crt (served on both listeners) and install it per-OS via the new /https-install/ guide. Settings page grows a Local HTTPS card with CA fingerprint, download button, reachability probe, and an opt-in "force HTTPS" toggle. The toggle only unhides itself once the current browser already trusts the cert, so enabling it can't lock the user out of the settings page. Backend: GET /api/furtka/https/status and POST /api/furtka/https/force in furtka.https. The force toggle drops a Caddy import snippet into /etc/caddy/furtka.d/redirect.caddyfile and reloads Caddy; reload failure rolls the snippet state back so a bad config can't wedge the next service start. updater._refresh_caddyfile() ensures /etc/caddy/furtka.d exists before every reload so 26.3-alpha → 26.4-alpha self-updates don't trip on the new glob import directive. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
166 lines
5.7 KiB
Python
166 lines
5.7 KiB
Python
"""Tests for furtka.https — fingerprint extraction + force-HTTPS toggle.
|
|
|
|
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 the snippet file is written / removed and that reload
|
|
failures roll 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 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", snippet=tmp_path / "redirect.caddyfile")
|
|
assert s == {
|
|
"ca_available": False,
|
|
"fingerprint_sha256": None,
|
|
"force_https": False,
|
|
"ca_download_url": "/rootCA.crt",
|
|
}
|
|
|
|
|
|
def test_status_with_ca_and_snippet(tmp_path):
|
|
ca = tmp_path / "root.crt"
|
|
ca.write_text(_TEST_CERT_PEM)
|
|
snippet = tmp_path / "redirect.caddyfile"
|
|
snippet.write_text(https.REDIRECT_CONTENT)
|
|
s = https.status(ca_path=ca, snippet=snippet)
|
|
assert s["ca_available"] is True
|
|
assert s["fingerprint_sha256"] == _TEST_CERT_FP_SHA256
|
|
assert s["force_https"] is True
|
|
|
|
|
|
def test_set_force_enable_writes_snippet_and_reloads(tmp_path):
|
|
snippet_dir = tmp_path / "furtka.d"
|
|
snippet = snippet_dir / "redirect.caddyfile"
|
|
calls = []
|
|
|
|
def fake_reload():
|
|
calls.append("reload")
|
|
|
|
result = https.set_force_https(
|
|
True, snippet_dir=snippet_dir, snippet=snippet, reload_caddy=fake_reload
|
|
)
|
|
assert result is True
|
|
assert snippet.read_text() == https.REDIRECT_CONTENT
|
|
assert calls == ["reload"]
|
|
|
|
|
|
def test_set_force_disable_removes_snippet(tmp_path):
|
|
snippet_dir = tmp_path / "furtka.d"
|
|
snippet_dir.mkdir()
|
|
snippet = snippet_dir / "redirect.caddyfile"
|
|
snippet.write_text(https.REDIRECT_CONTENT)
|
|
|
|
result = https.set_force_https(
|
|
False, snippet_dir=snippet_dir, snippet=snippet, reload_caddy=lambda: None
|
|
)
|
|
assert result is False
|
|
assert not snippet.exists()
|
|
|
|
|
|
def test_set_force_disable_is_idempotent_when_already_off(tmp_path):
|
|
snippet_dir = tmp_path / "furtka.d"
|
|
snippet = snippet_dir / "redirect.caddyfile"
|
|
|
|
result = https.set_force_https(
|
|
False, snippet_dir=snippet_dir, snippet=snippet, reload_caddy=lambda: None
|
|
)
|
|
assert result is False
|
|
assert not snippet.exists()
|
|
|
|
|
|
def test_reload_failure_rolls_back_enable(tmp_path):
|
|
snippet_dir = tmp_path / "furtka.d"
|
|
snippet = snippet_dir / "redirect.caddyfile"
|
|
|
|
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, snippet_dir=snippet_dir, snippet=snippet, reload_caddy=failing_reload
|
|
)
|
|
# Rollback: since snippet didn't exist before, it must not exist after.
|
|
assert not snippet.exists()
|
|
|
|
|
|
def test_reload_failure_rolls_back_disable(tmp_path):
|
|
snippet_dir = tmp_path / "furtka.d"
|
|
snippet_dir.mkdir()
|
|
snippet = snippet_dir / "redirect.caddyfile"
|
|
original = "redir https://{host}{uri} permanent\n# marker\n"
|
|
snippet.write_text(original)
|
|
|
|
def failing_reload():
|
|
raise subprocess.CalledProcessError(1, ["systemctl"], stderr="bad config")
|
|
|
|
with pytest.raises(https.HttpsError):
|
|
https.set_force_https(
|
|
False, snippet_dir=snippet_dir, snippet=snippet, reload_caddy=failing_reload
|
|
)
|
|
# Rollback: snippet is restored to its exact prior contents.
|
|
assert snippet.read_text() == original
|
|
|
|
|
|
def test_systemctl_missing_raises_and_rolls_back(tmp_path):
|
|
snippet_dir = tmp_path / "furtka.d"
|
|
snippet = snippet_dir / "redirect.caddyfile"
|
|
|
|
def missing_systemctl():
|
|
raise FileNotFoundError(2, "No such file", "systemctl")
|
|
|
|
with pytest.raises(https.HttpsError, match="systemctl not available"):
|
|
https.set_force_https(
|
|
True, snippet_dir=snippet_dir, snippet=snippet, reload_caddy=missing_systemctl
|
|
)
|
|
assert not snippet.exists()
|
|
|
|
|
|
def test_redirect_snippet_content_is_caddy_redir_directive():
|
|
# Lock the exact directive. 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"
|