fix(https): make HTTPS opt-in to stop the BAD_SIGNATURE trap on fresh installs
All checks were successful
Build ISO / build-iso (push) Successful in 17m23s
CI / lint (push) Successful in 27s
CI / test (push) Successful in 1m2s
CI / validate-json (push) Successful in 24s
CI / markdown-links (push) Successful in 15s
Release / release (push) Successful in 11m34s
All checks were successful
Build ISO / build-iso (push) Successful in 17m23s
CI / lint (push) Successful in 27s
CI / test (push) Successful in 1m2s
CI / validate-json (push) Successful in 24s
CI / markdown-links (push) Successful in 15s
Release / release (push) Successful in 11m34s
Every Furtka since 26.5 shipped a Caddyfile with a
`__FURTKA_HOSTNAME__.local { tls internal }` site block, so every
first boot auto-generated a fresh self-signed CA + intermediate +
leaf. That worked for the first-ever Furtka user, but every reinstall
(or second box on the same LAN) produced a new CA whose intermediate
shared the fixed CN `Caddy Local Authority - ECC Intermediate` with
the previous one. Firefox caches intermediates by CN across profiles
— even private windows share cert9.db — so any visitor who had
trusted an older Furtka's CA got a cached intermediate with
mismatched keys when they hit the new box, producing
`SEC_ERROR_BAD_SIGNATURE`. Unlike UNKNOWN_ISSUER, Firefox has NO
"Advanced → Accept Risk" bypass for BAD_SIGNATURE, so fresh-install
boxes were effectively unreachable over HTTPS in any browser that
had ever seen a previous Furtka.
Validated live on the .46 test VM: fresh 26.14 ISO install → Firefox
hits BAD_SIGNATURE on https://furtka.local/ (even in private mode).
Chromium bypasses it via mDNS failure but the issue is the same.
openssl verify on the box confirms the chain is internally valid —
this is purely client-side cache pollution across boxes.
Fix:
- assets/Caddyfile: removed the hostname site block. Default install
serves :80 only — https://furtka.local connection-refuses, which is
a normal error every browser handles instead of the unbypassable
crypto fault. Added top-level import of
/etc/caddy/furtka-https.d/*.caddyfile so the /settings HTTPS toggle
can drop a listener snippet there when a user explicitly opts in.
- furtka/https.py: set_force_https now writes TWO snippets atomically
— the top-level hostname + tls internal block (enables :443) and
the :80-scoped redirect (forces HTTP→HTTPS). Disable removes both.
Reload failure rolls both back. Added _read_hostname + _https_snippet_content
helpers with `/etc/hostname` → 'furtka' fallback so a missing
hostname file doesn't produce an empty site block Caddy rejects.
- furtka/https.py::status: force_https now reads the listener
snippet (was reading the redirect snippet). A redirect without a
listener isn't actually HTTPS being served, so the listener is the
authoritative "HTTPS is on" signal.
- furtka/updater.py: new _maybe_migrate_preserve_https hook runs
inside _refresh_caddyfile on the 26.14 → 26.15 transition. If the
box had the redirect snippet on disk (user had opted into HTTPS
under the old regime), it writes the new listener snippet too so
HTTPS keeps working after the Caddyfile swap removes the hostname
block.
- webinstaller/app.py: post-install creates /etc/caddy/furtka-https.d/
alongside /etc/caddy/furtka.d/ so the glob import can't trip an
older Caddy on a missing path during the first reload.
Live-tested on .46: set_force_https(True) writes both snippets, Caddy
reloads, :443 listener comes up with fresh CA, curl -k returns 302,
HTTP 301-redirects. set_force_https(False) removes both snippets
atomically, :443 goes back to connection-refused.
Tests: test_https.py expanded from 13 to 15 cases. Toggle-on asserts
both snippets written + hostname substituted. Toggle-off asserts
both removed. Rollback cases verify BOTH snippets restore on reload
failure. New test_https_snippet_content_has_tls_internal_and_routes
locks the exact shape of the listener block.
test_webinstaller_assets.py: updated two old asserts that assumed
hostname block was in Caddyfile; new test_post_install_creates_https_snippet_dir
guards the new directory.
276 tests pass, ruff check + format clean.
Known remaining wart (documented in CHANGELOG): a browser that
trusted a prior Furtka CA still hits BAD_SIGNATURE on this box's
HTTPS after enabling it, because the fixed intermediate CN is a
Caddy-side limitation. Workaround: clear cert9.db or visit in a
fresh profile. Won't affect end users with one Furtka box ever.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
parent
26f0424ae3
commit
e68ed279cc
8 changed files with 358 additions and 120 deletions
50
CHANGELOG.md
50
CHANGELOG.md
|
|
@ -7,6 +7,53 @@ This project uses calendar versioning: `YY.N-stage` (e.g. `26.0-alpha` = 2026, r
|
|||
|
||||
## [Unreleased]
|
||||
|
||||
## [26.15-alpha] - 2026-04-21
|
||||
|
||||
### Fixed
|
||||
|
||||
- **HTTPS is now opt-in; fresh installs no longer hit unbypassable
|
||||
SEC_ERROR_BAD_SIGNATURE.** Every version since 26.5 shipped a
|
||||
Caddyfile with a `__FURTKA_HOSTNAME__.local { tls internal }` site
|
||||
block, so Caddy auto-generated a self-signed root CA + intermediate
|
||||
+ leaf on first boot. That worked for first-time-ever users, but
|
||||
every reinstall (or second Furtka box on the same LAN) produced a
|
||||
new CA with the **same intermediate CN** (`Caddy Local Authority -
|
||||
ECC Intermediate` — Caddy hardcodes it). Any browser that had ever
|
||||
trusted an earlier Furtka CA got a cached intermediate with
|
||||
mismatched keys, then Firefox's cert lookup substituted the cached
|
||||
intermediate when validating the new box's leaf → the signature
|
||||
check failed → `SEC_ERROR_BAD_SIGNATURE`, which Firefox has no
|
||||
"Advanced → Accept Risk" bypass for.
|
||||
- Removed the hostname site block from the default Caddyfile.
|
||||
Fresh installs serve `:80` only; visiting `https://furtka.local`
|
||||
now yields a clean connection-refused instead of the crypto
|
||||
fault.
|
||||
- Added top-level `import /etc/caddy/furtka-https.d/*.caddyfile`.
|
||||
The `/settings` HTTPS toggle (via `furtka.https.set_force_https`)
|
||||
now writes TWO snippets atomically — the top-level hostname +
|
||||
`tls internal` block (enables `:443`) and the `:80`-scoped
|
||||
redirect (forces HTTP → HTTPS) — and removes both on disable.
|
||||
Caddy reloads after the pair-swap; failure rolls both back.
|
||||
- Webinstaller creates `/etc/caddy/furtka-https.d/` during
|
||||
post-install alongside the existing `furtka.d/`.
|
||||
- `updater._refresh_caddyfile` runs a 26.14 → 26.15 migration: if
|
||||
the box already had the redirect snippet on disk (user had
|
||||
explicitly enabled "Force HTTPS" under the old regime), the
|
||||
migration also writes the new listener snippet so HTTPS keeps
|
||||
working across the upgrade.
|
||||
- **`status.force_https` now reads the listener snippet, not the
|
||||
redirect snippet.** A lone redirect without a `:443` listener
|
||||
wouldn't actually serve HTTPS, so the listener file is the
|
||||
authoritative "HTTPS is on" signal. The UI on `/settings` sees the
|
||||
correct state as a result.
|
||||
|
||||
Known remaining UX wart: a browser that trusted a previous Furtka box
|
||||
still sees `BAD_SIGNATURE` when visiting this box's `https://` after
|
||||
enabling HTTPS here — the fixed intermediate CN is a Caddy-side
|
||||
limitation we can't fix from Furtka. Fresh installs on a browser that
|
||||
never visited another Furtka box work correctly. Workaround:
|
||||
`about:networking#sts` → Forget → clear `cert9.db`.
|
||||
|
||||
## [26.14-alpha] - 2026-04-21
|
||||
|
||||
### Fixed
|
||||
|
|
@ -307,7 +354,8 @@ First tagged snapshot. Pre-alpha — the installer does not yet boot, but the de
|
|||
- **Containers:** Docker + Compose
|
||||
- **License:** AGPL-3.0
|
||||
|
||||
[Unreleased]: https://forgejo.sourcegate.online/daniel/furtka/compare/26.14-alpha...HEAD
|
||||
[Unreleased]: https://forgejo.sourcegate.online/daniel/furtka/compare/26.15-alpha...HEAD
|
||||
[26.15-alpha]: https://forgejo.sourcegate.online/daniel/furtka/releases/tag/26.15-alpha
|
||||
[26.14-alpha]: https://forgejo.sourcegate.online/daniel/furtka/releases/tag/26.14-alpha
|
||||
[26.13-alpha]: https://forgejo.sourcegate.online/daniel/furtka/releases/tag/26.13-alpha
|
||||
[26.12-alpha]: https://forgejo.sourcegate.online/daniel/furtka/releases/tag/26.12-alpha
|
||||
|
|
|
|||
|
|
@ -1,25 +1,27 @@
|
|||
# Serves the Furtka landing page + live JSON on :80 (plain HTTP) and on
|
||||
# HTTPS via Caddy's built-in `tls internal` — locally-issued certs signed
|
||||
# by a root CA that Caddy generates on first start and stores under
|
||||
# /var/lib/caddy/pki/authorities/local/. Static pages are read from
|
||||
# /opt/furtka/current/ — updates flip the symlink and everything picks up
|
||||
# the new content without a Caddy restart (a `systemctl reload caddy` is
|
||||
# still triggered post-swap to flush the file-server's handle cache).
|
||||
# /apps and /api are reverse-proxied to the resource-manager API
|
||||
# (furtka serve, bound to 127.0.0.1:7000).
|
||||
# Serves the Furtka landing page + live JSON on :80 (plain HTTP). HTTPS
|
||||
# is **opt-in** — Caddy doesn't serve :443 until the user clicks the
|
||||
# "Enable HTTPS" toggle on /settings, which drops an import snippet into
|
||||
# /etc/caddy/furtka-https.d/. Default install has NO tls site block →
|
||||
# Caddy never generates a self-signed CA / leaf cert → no
|
||||
# SEC_ERROR_BAD_SIGNATURE when a user visits https://furtka.local before
|
||||
# they've trusted anything. That was the 26.14-era regression this file
|
||||
# exists to cure: the old Caddyfile always served :443 with a freshly-
|
||||
# generated cert, and a browser that had ever trusted an older Furtka
|
||||
# box's CA would reject the new one with an unbypassable bad-sig error.
|
||||
#
|
||||
# Hostname templating: __FURTKA_HOSTNAME__ gets substituted with the
|
||||
# install-time hostname by webinstaller/app.py on first install and by
|
||||
# furtka.updater._refresh_caddyfile on every self-update. A bare `:443
|
||||
# { tls internal }` (no hostname) never triggers leaf-cert issuance, so
|
||||
# SNI-based handshakes die with `SSL_ERROR_INTERNAL_ERROR_ALERT` — the
|
||||
# 26.4-alpha regression this file exists to cure.
|
||||
# /apps, /api, /login, /logout, / (home), /settings are reverse-proxied
|
||||
# to the resource-manager API (furtka serve, bound to 127.0.0.1:7000).
|
||||
# Static pages are read from /opt/furtka/current/ — updates flip the
|
||||
# symlink and everything picks up the new content without a Caddy
|
||||
# restart (a `systemctl reload caddy` is still triggered post-swap to
|
||||
# flush the file-server's handle cache).
|
||||
#
|
||||
# Force-HTTPS: /etc/caddy/furtka.d/*.caddyfile gets imported into the :80
|
||||
# block. The /api/furtka/https/force endpoint creates or removes
|
||||
# redirect.caddyfile there to toggle the HTTP→HTTPS redirect, then reloads
|
||||
# Caddy. Glob imports silently no-op on an empty/missing directory, so the
|
||||
# toggle-off state is "no file present" rather than "empty file".
|
||||
# Two snippet dirs, both silently no-op when empty:
|
||||
# - /etc/caddy/furtka.d/*.caddyfile → imported inside the :80 block.
|
||||
# The HTTPS toggle's "force HTTP→HTTPS redirect" snippet lands here.
|
||||
# - /etc/caddy/furtka-https.d/*.caddyfile → imported at TOP LEVEL, so
|
||||
# the HTTPS hostname+tls-internal site block can drop in here when
|
||||
# the toggle is on. Hostname is substituted at toggle-time.
|
||||
{
|
||||
# Named-hostname :443 blocks would otherwise make Caddy add its own
|
||||
# HTTP→HTTPS redirect — but we already serve our own `:80` block and
|
||||
|
|
@ -70,8 +72,8 @@
|
|||
file_server
|
||||
}
|
||||
# Download the local root CA cert Caddy generated for `tls internal`.
|
||||
# Available on both :80 and :443 so users can grab it before they've
|
||||
# trusted it. The private key next to it stays 0600 / caddy-owned.
|
||||
# Public because users need to grab it before they've trusted it.
|
||||
# The private key next to it stays 0600 / caddy-owned.
|
||||
handle /rootCA.crt {
|
||||
root * /var/lib/caddy/pki/authorities/local
|
||||
rewrite * /root.crt
|
||||
|
|
@ -89,12 +91,12 @@
|
|||
}
|
||||
}
|
||||
|
||||
# HTTPS opt-in: when /settings toggles HTTPS on, a snippet gets written
|
||||
# into /etc/caddy/furtka-https.d/ that adds the hostname+tls-internal
|
||||
# site block. Empty directory = HTTP-only (default fresh install).
|
||||
import /etc/caddy/furtka-https.d/*.caddyfile
|
||||
|
||||
:80 {
|
||||
import /etc/caddy/furtka.d/*.caddyfile
|
||||
import furtka_routes
|
||||
}
|
||||
|
||||
__FURTKA_HOSTNAME__.local, __FURTKA_HOSTNAME__ {
|
||||
tls internal
|
||||
import furtka_routes
|
||||
}
|
||||
|
|
|
|||
|
|
@ -6,10 +6,25 @@ sets `XDG_DATA_HOME=/var/lib`, so on the target that resolves to
|
|||
/var/lib/caddy/pki/authorities/local/. The private key stays 0600 /
|
||||
caddy-owned; we only ever read the public root.crt next to it.
|
||||
|
||||
This module exposes two operations:
|
||||
- status(): current CA fingerprint + whether force-HTTPS is on
|
||||
- set_force_https(enabled): write/remove the Caddy import snippet that
|
||||
redirects HTTP to HTTPS, reload Caddy, roll back on failure.
|
||||
HTTPS is **opt-in** since 26.15-alpha. Default Caddyfile has no `:443`
|
||||
site block, so `tls internal` never triggers cert issuance. The
|
||||
/settings toggle drops a snippet file into /etc/caddy/furtka-https.d/
|
||||
that adds the hostname+tls-internal block (plus the redirect snippet
|
||||
inside /etc/caddy/furtka.d/ for HTTP→HTTPS). Disabling the toggle
|
||||
removes both snippets and reloads — Caddy falls back to HTTP-only.
|
||||
|
||||
Why opt-in: fresh-install boxes used to always serve a self-signed
|
||||
cert on :443. Any browser that had ever trusted a previous Furtka
|
||||
box's local CA rejected the new cert with an unbypassable
|
||||
SEC_ERROR_BAD_SIGNATURE — Firefox in particular has no "Advanced →
|
||||
Accept" for that case. Making HTTPS explicit means fresh installs
|
||||
never hit that trap; users who want HTTPS download the rootCA.crt
|
||||
first and then click the toggle.
|
||||
|
||||
This module exposes:
|
||||
- status(): CA fingerprint + current toggle state
|
||||
- set_force_https(enabled): write/remove BOTH snippets atomically,
|
||||
reload Caddy, roll back on failure.
|
||||
"""
|
||||
|
||||
import base64
|
||||
|
|
@ -22,6 +37,9 @@ 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"
|
||||
HTTPS_SNIPPET_DIR = Path("/etc/caddy/furtka-https.d")
|
||||
HTTPS_SNIPPET = HTTPS_SNIPPET_DIR / "https.caddyfile"
|
||||
HOSTNAME_FILE = Path("/etc/hostname")
|
||||
|
||||
_PEM_RE = re.compile(
|
||||
r"-----BEGIN CERTIFICATE-----\s*(.+?)\s*-----END CERTIFICATE-----",
|
||||
|
|
@ -33,6 +51,30 @@ class HttpsError(Exception):
|
|||
"""Recoverable failure from set_force_https — the caller should 5xx."""
|
||||
|
||||
|
||||
def _read_hostname(hostname_file: Path = HOSTNAME_FILE) -> str:
|
||||
"""Return the box's hostname, stripped. Falls back to 'furtka' so a
|
||||
missing /etc/hostname doesn't produce an empty site block that Caddy
|
||||
would reject at parse time."""
|
||||
try:
|
||||
value = hostname_file.read_text().strip()
|
||||
except (FileNotFoundError, PermissionError, OSError):
|
||||
return "furtka"
|
||||
return value or "furtka"
|
||||
|
||||
|
||||
def _https_snippet_content(hostname: str) -> str:
|
||||
"""Caddy site block the HTTPS toggle installs at opt-in.
|
||||
|
||||
Serves <hostname>.local and <hostname> on :443 with Caddy's
|
||||
`tls internal` (local CA auto-issuance), and imports the shared
|
||||
furtka_routes snippet so the :443 listener exposes the same
|
||||
routes as :80. Must be written at top-level (not inside another
|
||||
site block) — that's why the Caddyfile imports furtka-https.d at
|
||||
top-level rather than inside :80.
|
||||
"""
|
||||
return f"{hostname}.local, {hostname} {{\n\ttls internal\n\timport furtka_routes\n}}\n"
|
||||
|
||||
|
||||
def _ca_fingerprint(ca_path: Path) -> str | None:
|
||||
try:
|
||||
pem = ca_path.read_text()
|
||||
|
|
@ -54,13 +96,20 @@ def _format_fingerprint(hex_upper: str) -> str:
|
|||
|
||||
def status(
|
||||
ca_path: Path = CA_CERT_PATH,
|
||||
snippet: Path = REDIRECT_SNIPPET,
|
||||
https_snippet: Path = HTTPS_SNIPPET,
|
||||
) -> dict:
|
||||
"""force_https is True iff the HTTPS listener snippet exists.
|
||||
|
||||
Before 26.15-alpha this checked the redirect snippet instead — but
|
||||
the redirect alone without a :443 listener wouldn't actually serve
|
||||
HTTPS, so the listener snippet is the authoritative "HTTPS is on"
|
||||
signal.
|
||||
"""
|
||||
fp = _ca_fingerprint(ca_path)
|
||||
return {
|
||||
"ca_available": fp is not None,
|
||||
"fingerprint_sha256": _format_fingerprint(fp) if fp else None,
|
||||
"force_https": snippet.is_file(),
|
||||
"force_https": https_snippet.is_file(),
|
||||
"ca_download_url": "/rootCA.crt",
|
||||
}
|
||||
|
||||
|
|
@ -78,29 +127,48 @@ def set_force_https(
|
|||
enabled: bool,
|
||||
snippet_dir: Path = SNIPPET_DIR,
|
||||
snippet: Path = REDIRECT_SNIPPET,
|
||||
https_snippet_dir: Path = HTTPS_SNIPPET_DIR,
|
||||
https_snippet: Path = HTTPS_SNIPPET,
|
||||
hostname_file: Path = HOSTNAME_FILE,
|
||||
reload_caddy=_default_reload,
|
||||
) -> bool:
|
||||
"""Toggle the HTTP→HTTPS redirect by writing or removing the snippet
|
||||
Caddy imports. Always reloads Caddy. Rolls the snippet state back on
|
||||
reload failure so a broken config can't leave Caddy wedged on the next
|
||||
restart.
|
||||
"""Toggle HTTPS by writing or removing two snippets atomically:
|
||||
|
||||
1. The top-level HTTPS hostname+tls-internal block (enables :443
|
||||
listener + Caddy's `tls internal` cert issuance)
|
||||
2. The :80-scoped redirect snippet (forces HTTP → HTTPS)
|
||||
|
||||
Reload Caddy after the snippet swap. On reload failure both
|
||||
snippets are reverted to their pre-call state so a bad config
|
||||
can't leave Caddy wedged.
|
||||
"""
|
||||
snippet_dir.mkdir(mode=0o755, parents=True, exist_ok=True)
|
||||
had = snippet.is_file()
|
||||
previous = snippet.read_text() if had else None
|
||||
https_snippet_dir.mkdir(mode=0o755, parents=True, exist_ok=True)
|
||||
|
||||
had_redirect = snippet.is_file()
|
||||
previous_redirect = snippet.read_text() if had_redirect else None
|
||||
had_https = https_snippet.is_file()
|
||||
previous_https = https_snippet.read_text() if had_https else None
|
||||
|
||||
if enabled:
|
||||
snippet.write_text(REDIRECT_CONTENT)
|
||||
elif had:
|
||||
https_snippet.write_text(_https_snippet_content(_read_hostname(hostname_file)))
|
||||
else:
|
||||
if had_redirect:
|
||||
snippet.unlink()
|
||||
if had_https:
|
||||
https_snippet.unlink()
|
||||
|
||||
try:
|
||||
reload_caddy()
|
||||
except subprocess.CalledProcessError as e:
|
||||
_revert(snippet, previous)
|
||||
_revert(snippet, previous_redirect)
|
||||
_revert(https_snippet, previous_https)
|
||||
msg = (e.stderr or e.stdout or "").strip() or f"exit {e.returncode}"
|
||||
raise HttpsError(f"caddy reload failed: {msg}") from e
|
||||
except FileNotFoundError as e:
|
||||
_revert(snippet, previous)
|
||||
_revert(snippet, previous_redirect)
|
||||
_revert(https_snippet, previous_https)
|
||||
raise HttpsError(f"systemctl not available: {e}") from e
|
||||
return enabled
|
||||
|
||||
|
|
|
|||
|
|
@ -49,6 +49,9 @@ _CADDYFILE_LIVE = Path(os.environ.get("FURTKA_CADDYFILE_PATH", "/etc/caddy/Caddy
|
|||
_CADDY_SNIPPET_DIR = Path(
|
||||
os.environ.get("FURTKA_CADDY_SNIPPET_DIR", str(_CADDYFILE_LIVE.parent / "furtka.d"))
|
||||
)
|
||||
_CADDY_HTTPS_SNIPPET_DIR = Path(
|
||||
os.environ.get("FURTKA_CADDY_HTTPS_SNIPPET_DIR", str(_CADDYFILE_LIVE.parent / "furtka-https.d"))
|
||||
)
|
||||
_SYSTEMD_DIR = Path(os.environ.get("FURTKA_SYSTEMD_DIR", "/etc/systemd/system"))
|
||||
_HOSTNAME_FILE = Path(os.environ.get("FURTKA_HOSTNAME_FILE", "/etc/hostname"))
|
||||
_CADDYFILE_HOSTNAME_MARKER = "__FURTKA_HOSTNAME__"
|
||||
|
|
@ -170,6 +173,24 @@ def _current_hostname() -> str:
|
|||
return name or "furtka"
|
||||
|
||||
|
||||
def _maybe_migrate_preserve_https() -> None:
|
||||
"""26.14 → 26.15 migration: if the box already had the force-HTTPS
|
||||
redirect snippet on disk, that means the user explicitly opted
|
||||
into HTTPS under the old regime. Under the new opt-in regime,
|
||||
HTTPS also requires a separate listener snippet — write it here so
|
||||
the user's HTTPS doesn't silently break when the Caddyfile refresh
|
||||
removes the default hostname block.
|
||||
"""
|
||||
redirect_snippet = _CADDY_SNIPPET_DIR / "redirect.caddyfile"
|
||||
https_snippet = _CADDY_HTTPS_SNIPPET_DIR / "https.caddyfile"
|
||||
if not redirect_snippet.is_file() or https_snippet.is_file():
|
||||
return
|
||||
hostname = _current_hostname()
|
||||
https_snippet.write_text(
|
||||
f"{hostname}.local, {hostname} {{\n\ttls internal\n\timport furtka_routes\n}}\n"
|
||||
)
|
||||
|
||||
|
||||
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).
|
||||
|
|
@ -180,10 +201,19 @@ def _refresh_caddyfile(source: Path) -> bool:
|
|||
"""
|
||||
if not source.is_file():
|
||||
return False
|
||||
# Snippet dir for the /api/furtka/https/force toggle. Pre-HTTPS installs
|
||||
# don't have this dir; ensure it so the Caddyfile's glob import can't
|
||||
# trip an older Caddy on a missing path during the first reload.
|
||||
# Snippet dirs for the /api/furtka/https/force toggle. Pre-HTTPS
|
||||
# installs don't have them; ensure both so the Caddyfile's glob
|
||||
# imports can't trip an older Caddy on missing paths during the
|
||||
# first reload. furtka-https.d is new in 26.15-alpha — older boxes
|
||||
# upgrading across this version line won't have it on disk yet.
|
||||
_CADDY_SNIPPET_DIR.mkdir(mode=0o755, parents=True, exist_ok=True)
|
||||
_CADDY_HTTPS_SNIPPET_DIR.mkdir(mode=0o755, parents=True, exist_ok=True)
|
||||
# Migration: pre-26.15 Caddyfile always served :443 via tls internal,
|
||||
# so a box that had the "force HTTPS" redirect toggle ON relied on
|
||||
# HTTPS being there implicitly. After this Caddyfile refresh the
|
||||
# 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()
|
||||
rendered = source.read_text().replace(_CADDYFILE_HOSTNAME_MARKER, _current_hostname())
|
||||
if _CADDYFILE_LIVE.is_file() and rendered == _CADDYFILE_LIVE.read_text():
|
||||
return False
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
[project]
|
||||
name = "furtka"
|
||||
version = "26.14-alpha"
|
||||
version = "26.15-alpha"
|
||||
description = "Open-source home server OS — simple enough for everyone."
|
||||
requires-python = ">=3.11"
|
||||
readme = "README.md"
|
||||
|
|
|
|||
|
|
@ -1,11 +1,15 @@
|
|||
"""Tests for furtka.https — fingerprint extraction + force-HTTPS toggle.
|
||||
"""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 the snippet file is written / removed and that reload
|
||||
failures roll state back.
|
||||
so we assert both snippet files are written / removed together and that
|
||||
reload failures roll BOTH state back.
|
||||
"""
|
||||
|
||||
import subprocess
|
||||
|
|
@ -34,6 +38,22 @@ _TEST_CERT_FP_SHA256 = (
|
|||
)
|
||||
|
||||
|
||||
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)
|
||||
|
|
@ -53,7 +73,7 @@ def test_ca_fingerprint_no_pem_block(tmp_path):
|
|||
|
||||
|
||||
def test_status_no_ca_no_snippet(tmp_path):
|
||||
s = https.status(ca_path=tmp_path / "root.crt", snippet=tmp_path / "redirect.caddyfile")
|
||||
s = https.status(ca_path=tmp_path / "root.crt", https_snippet=tmp_path / "https.caddyfile")
|
||||
assert s == {
|
||||
"ca_available": False,
|
||||
"fingerprint_sha256": None,
|
||||
|
|
@ -62,105 +82,135 @@ def test_status_no_ca_no_snippet(tmp_path):
|
|||
}
|
||||
|
||||
|
||||
def test_status_with_ca_and_snippet(tmp_path):
|
||||
def test_status_with_ca_and_https_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)
|
||||
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_set_force_enable_writes_snippet_and_reloads(tmp_path):
|
||||
snippet_dir = tmp_path / "furtka.d"
|
||||
snippet = snippet_dir / "redirect.caddyfile"
|
||||
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, snippet_dir=snippet_dir, snippet=snippet, reload_caddy=fake_reload
|
||||
)
|
||||
result = https.set_force_https(True, reload_caddy=fake_reload, **p)
|
||||
assert result is True
|
||||
assert snippet.read_text() == https.REDIRECT_CONTENT
|
||||
assert p["snippet"].read_text() == https.REDIRECT_CONTENT
|
||||
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_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)
|
||||
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()
|
||||
|
||||
result = https.set_force_https(
|
||||
False, snippet_dir=snippet_dir, snippet=snippet, reload_caddy=lambda: None
|
||||
)
|
||||
|
||||
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_CONTENT)
|
||||
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 snippet.exists()
|
||||
assert not p["snippet"].exists()
|
||||
assert not p["https_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
|
||||
)
|
||||
p = _paths(tmp_path)
|
||||
result = https.set_force_https(False, reload_caddy=lambda: None, **p)
|
||||
assert result is False
|
||||
assert not snippet.exists()
|
||||
assert not p["snippet"].exists()
|
||||
assert not p["https_snippet"].exists()
|
||||
|
||||
|
||||
def test_reload_failure_rolls_back_enable(tmp_path):
|
||||
snippet_dir = tmp_path / "furtka.d"
|
||||
snippet = snippet_dir / "redirect.caddyfile"
|
||||
_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, 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()
|
||||
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):
|
||||
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)
|
||||
_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, snippet_dir=snippet_dir, snippet=snippet, reload_caddy=failing_reload
|
||||
)
|
||||
# Rollback: snippet is restored to its exact prior contents.
|
||||
assert snippet.read_text() == original
|
||||
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):
|
||||
snippet_dir = tmp_path / "furtka.d"
|
||||
snippet = snippet_dir / "redirect.caddyfile"
|
||||
_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, snippet_dir=snippet_dir, snippet=snippet, reload_caddy=missing_systemctl
|
||||
)
|
||||
assert not snippet.exists()
|
||||
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_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"
|
||||
|
||||
|
||||
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")
|
||||
|
|
|
|||
|
|
@ -122,19 +122,39 @@ def test_caddyfile_asset_serves_from_current():
|
|||
assert "root * /var/lib/furtka" in caddy
|
||||
|
||||
|
||||
def test_caddyfile_serves_both_http_and_https():
|
||||
# :80 stays so users who haven't installed the CA still reach the box;
|
||||
# HTTPS is served via a named-hostname block so Caddy's `tls internal`
|
||||
# has something to issue a leaf cert for. A bare `:443 { tls internal }`
|
||||
# never triggers issuance — that was the 26.4-alpha regression.
|
||||
caddy = (ASSETS / "Caddyfile").read_text()
|
||||
def _strip_caddy_comments(text: str) -> str:
|
||||
"""Remove # comments + blank lines so string-match assertions can
|
||||
target actual Caddyfile directives, not the leading doc block.
|
||||
Comment intro is ``#`` at start-of-line or preceded by whitespace."""
|
||||
out = []
|
||||
for line in text.splitlines():
|
||||
stripped = line.split("#", 1)[0].rstrip()
|
||||
if stripped:
|
||||
out.append(stripped)
|
||||
return "\n".join(out)
|
||||
|
||||
|
||||
def test_caddyfile_serves_http_by_default_https_opt_in():
|
||||
# 26.15-alpha: HTTPS is opt-in. The default Caddyfile has a :80 block
|
||||
# and imports /etc/caddy/furtka-https.d/*.caddyfile at top level —
|
||||
# the /settings HTTPS toggle drops the hostname+tls-internal block
|
||||
# into that dir when the user explicitly enables HTTPS. Default
|
||||
# Caddyfile therefore contains no `tls internal` directive anywhere;
|
||||
# if a future refactor puts it back, every fresh install regresses
|
||||
# to the 26.14-era BAD_SIGNATURE trap. Strip comments first because
|
||||
# the doc-block DOES mention `tls internal` in prose.
|
||||
caddy_full = (ASSETS / "Caddyfile").read_text()
|
||||
caddy = _strip_caddy_comments(caddy_full)
|
||||
assert ":80 {" in caddy
|
||||
assert "__FURTKA_HOSTNAME__.local, __FURTKA_HOSTNAME__ {" in caddy
|
||||
assert "tls internal" in caddy
|
||||
# Shared routes live in a named snippet to avoid drift between the two
|
||||
# listeners — both site blocks must import it.
|
||||
assert "tls internal" not in caddy
|
||||
assert "__FURTKA_HOSTNAME__" not in caddy
|
||||
assert "import /etc/caddy/furtka-https.d/*.caddyfile" in caddy
|
||||
# Shared routes still live in a named snippet so the HTTPS toggle's
|
||||
# snippet can import the same routes without duplication.
|
||||
assert "(furtka_routes)" in caddy
|
||||
assert caddy.count("import furtka_routes") == 2
|
||||
# Default Caddyfile imports it once (inside :80). The HTTPS snippet,
|
||||
# when written by the toggle, imports it a second time.
|
||||
assert caddy.count("import furtka_routes") == 1
|
||||
|
||||
|
||||
def test_caddyfile_disables_caddy_auto_redirects():
|
||||
|
|
@ -167,16 +187,28 @@ def test_caddyfile_exposes_root_ca_download():
|
|||
assert "attachment; filename=furtka-local-rootCA.crt" in caddy
|
||||
|
||||
|
||||
def test_post_install_substitutes_hostname_in_caddyfile(install_cmds):
|
||||
# Fresh installs: the placeholder the asset ships with must be replaced
|
||||
# with the hostname the user picked in the form. The `testhost` value
|
||||
# comes from the install_cmds fixture. Without substitution Caddy's
|
||||
# `tls internal` never issues a leaf cert for the real hostname.
|
||||
def test_post_install_writes_caddyfile_without_hostname_placeholder(install_cmds):
|
||||
# 26.15-alpha: the shipped Caddyfile no longer carries the
|
||||
# __FURTKA_HOSTNAME__ marker — HTTPS + hostname now live in the
|
||||
# opt-in snippet written by set_force_https(), not in the base
|
||||
# Caddyfile. Verify the post-install writes the file as-is (no
|
||||
# substitution expected) and it has the opt-in import glob.
|
||||
caddyfile_cmd = next((c for c in install_cmds if " > /etc/caddy/Caddyfile" in c), None)
|
||||
assert caddyfile_cmd is not None
|
||||
written = _extract_written_content(caddyfile_cmd, "/etc/caddy/Caddyfile")
|
||||
written_full = _extract_written_content(caddyfile_cmd, "/etc/caddy/Caddyfile")
|
||||
written = _strip_caddy_comments(written_full)
|
||||
assert "__FURTKA_HOSTNAME__" not in written
|
||||
assert "testhost.local, testhost {" in written
|
||||
assert "import /etc/caddy/furtka-https.d/*.caddyfile" in written
|
||||
assert "tls internal" not in written
|
||||
|
||||
|
||||
def test_post_install_creates_https_snippet_dir(install_cmds):
|
||||
# The top-level HTTPS opt-in snippet dir must exist before Caddy's
|
||||
# first start — its glob import tolerates an empty directory, but
|
||||
# not a missing one on older Caddy builds. Parallel guarantee to
|
||||
# test_post_install_creates_furtka_d_snippet_dir below.
|
||||
matching = [c for c in install_cmds if "/etc/caddy/furtka-https.d" in c and "install -d" in c]
|
||||
assert matching, "no install -d command creates /etc/caddy/furtka-https.d"
|
||||
|
||||
|
||||
def test_post_install_creates_furtka_d_snippet_dir(install_cmds):
|
||||
|
|
|
|||
|
|
@ -395,6 +395,14 @@ def _post_install_commands(hostname, admin_username, admin_password):
|
|||
# an empty dir but not a missing one on every Caddy version, so we
|
||||
# create it up front and stay on the safe side.
|
||||
"install -d -m 0755 -o root -g root /etc/caddy/furtka.d",
|
||||
# Parallel dir for the top-level HTTPS-listener snippet, written
|
||||
# by /api/furtka/https/force (26.15-alpha+) when the user opts
|
||||
# into HTTPS. Empty by default so fresh installs never generate
|
||||
# a tls internal cert — that was the 26.14 regression where
|
||||
# Firefox hit unbypassable SEC_ERROR_BAD_SIGNATURE because
|
||||
# Caddy's fixed intermediate-CN clashed with any cached trust
|
||||
# from a previously-reinstalled Furtka box.
|
||||
"install -d -m 0755 -o root -g root /etc/caddy/furtka-https.d",
|
||||
# The Caddyfile lives at /etc/caddy/Caddyfile per Caddy's convention
|
||||
# (systemd unit points there). Content comes from the shipped asset,
|
||||
# which we copy in at install time so updates that change routing
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue