Compare commits
2 commits
bf86ffaf4c
...
663bd74572
| Author | SHA1 | Date | |
|---|---|---|---|
| 663bd74572 | |||
| a5de3d7622 |
11 changed files with 731 additions and 11 deletions
10
CHANGELOG.md
10
CHANGELOG.md
|
|
@ -7,10 +7,14 @@ This project uses calendar versioning: `YY.N-stage` (e.g. `26.0-alpha` = 2026, r
|
||||||
|
|
||||||
## [Unreleased]
|
## [Unreleased]
|
||||||
|
|
||||||
### Known UX gaps (to fix in the next release)
|
### Added
|
||||||
|
|
||||||
- **Settings page "Installed" field doesn't refresh right after a self-update.** After `/settings` → Update now → success, the browser's `refresh()` against `/status.json` reads a page-load snapshot rather than the new value. A force-reload (Ctrl+F5) shows the correct version. Fix idea: have the update-check endpoint response also drive `upd-current` (we already set `upd-latest` from it).
|
- **Local HTTPS via Caddy `tls internal`** on port 443. Caddy generates a per-box local root CA on first start; the Caddyfile now serves both `:80` and `:443` from the same routes. HTTP stays on by default — no regression for users who haven't trusted the CA yet. New "Local HTTPS" section in `/settings` shows the CA's SHA-256 fingerprint, offers a one-click download of `rootCA.crt`, links to the per-OS install guide at `/https-install/`, and exposes an opt-in "force HTTPS" toggle that only unhides itself once the current browser has already trusted 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 automatically rolls the snippet state back so a bad config can't wedge the next service start.
|
||||||
- **Auto-reload on update completion is unreliable.** The JS polls `/update-state.json` and calls `location.reload()` 5s after seeing `stage: done`. On the 2026-04-16 VM test the browser never auto-reloaded — user reloaded manually. Probable cause: the API restart mid-apply drops the polling connection between the browser and the page before the done state is observed. Fix idea: fallback `setTimeout(reload, 45_000)` on apply-click regardless of poll outcome.
|
|
||||||
|
### Fixed
|
||||||
|
|
||||||
|
- **Settings page "Installed" field now refreshes after a self-update.** The `/api/furtka/update/check` response already carries `current` — the settings JS now drives `upd-current` from it the same way it drives `upd-latest`, so clicking "Check for updates" after a successful update reflects the new installed version without a force-reload.
|
||||||
|
- **Auto-reload on update completion is now reliable.** Clicking "Update now" arms a 45 s fallback `setTimeout(location.reload)` in addition to the existing `/update-state.json` polling loop. If the mid-apply API restart drops the poll connection before `stage: done` is ever observed (as seen on the 2026-04-16 VM test), the fallback still brings the page up on the new version. The fallback is cleared on `done` (5 s reload wins) or `rolled_back` (user needs the error visible).
|
||||||
|
|
||||||
## [26.3-alpha] - 2026-04-16
|
## [26.3-alpha] - 2026-04-16
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1,11 +1,19 @@
|
||||||
# Serves the Furtka landing page + live JSON on :80. Static pages are read
|
# Serves the Furtka landing page + live JSON on :80 (plain HTTP) and :443
|
||||||
# from the current-version directory under /opt/furtka/current/ — updates
|
# (HTTPS via Caddy's built-in `tls internal` — locally-issued certs signed
|
||||||
# flip the symlink and everything picks up the new content without a Caddy
|
# by a root CA that Caddy generates on first start and stores under
|
||||||
# restart (a `systemctl reload caddy` is still triggered post-swap to flush
|
# /var/lib/caddy/.local/share/caddy/pki/authorities/local/). Static pages
|
||||||
# the file-server's handle cache). /apps and /api are reverse-proxied to the
|
# are read from /opt/furtka/current/ — updates flip the symlink and
|
||||||
# resource-manager API (furtka serve, bound to 127.0.0.1:7000). TLS / auth
|
# everything picks up the new content without a Caddy restart (a
|
||||||
# come later when Authentik is wired in.
|
# `systemctl reload caddy` is still triggered post-swap to flush the
|
||||||
:80 {
|
# 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).
|
||||||
|
#
|
||||||
|
# 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".
|
||||||
|
(furtka_routes) {
|
||||||
handle /api/* {
|
handle /api/* {
|
||||||
reverse_proxy localhost:7000
|
reverse_proxy localhost:7000
|
||||||
}
|
}
|
||||||
|
|
@ -26,6 +34,16 @@
|
||||||
root * /var/lib/furtka
|
root * /var/lib/furtka
|
||||||
file_server
|
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.
|
||||||
|
handle /rootCA.crt {
|
||||||
|
root * /var/lib/caddy/.local/share/caddy/pki/authorities/local
|
||||||
|
rewrite * /root.crt
|
||||||
|
file_server
|
||||||
|
header Content-Type "application/x-x509-ca-cert"
|
||||||
|
header Content-Disposition "attachment; filename=furtka-local-rootCA.crt"
|
||||||
|
}
|
||||||
handle {
|
handle {
|
||||||
root * /opt/furtka/current/assets/www
|
root * /opt/furtka/current/assets/www
|
||||||
file_server
|
file_server
|
||||||
|
|
@ -35,3 +53,13 @@
|
||||||
output stdout
|
output stdout
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
:80 {
|
||||||
|
import /etc/caddy/furtka.d/*.caddyfile
|
||||||
|
import furtka_routes
|
||||||
|
}
|
||||||
|
|
||||||
|
:443 {
|
||||||
|
tls internal
|
||||||
|
import furtka_routes
|
||||||
|
}
|
||||||
|
|
|
||||||
159
assets/www/https-install/index.html
Normal file
159
assets/www/https-install/index.html
Normal file
|
|
@ -0,0 +1,159 @@
|
||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta charset="utf-8">
|
||||||
|
<title>Install local HTTPS · Furtka</title>
|
||||||
|
<meta name="viewport" content="width=device-width,initial-scale=1">
|
||||||
|
<link rel="stylesheet" href="/style.css">
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<main class="wrap">
|
||||||
|
<nav class="nav">
|
||||||
|
<a class="brand" href="/">Furtka</a>
|
||||||
|
<div class="nav-links">
|
||||||
|
<a href="/">Home</a>
|
||||||
|
<a href="/apps">Apps</a>
|
||||||
|
<a href="/settings/" aria-current="page">Settings</a>
|
||||||
|
</div>
|
||||||
|
</nav>
|
||||||
|
|
||||||
|
<h1>Install local HTTPS</h1>
|
||||||
|
<p class="lede">
|
||||||
|
Trust the Furtka root CA on your device, then reach this box at
|
||||||
|
<code>https://<span id="hostname">—</span>/</code> with a green padlock.
|
||||||
|
HTTP stays available until you enable the redirect in
|
||||||
|
<a class="inline-link" href="/settings/">Settings</a>.
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<section>
|
||||||
|
<h2>Download the CA</h2>
|
||||||
|
<div class="card">
|
||||||
|
<dl class="kv">
|
||||||
|
<dt>Fingerprint (SHA-256)</dt><dd id="fingerprint">—</dd>
|
||||||
|
</dl>
|
||||||
|
<p class="hint">
|
||||||
|
Check this fingerprint matches what <code>/settings</code> shows before
|
||||||
|
trusting it on another device. The root CA is unique to this box.
|
||||||
|
</p>
|
||||||
|
<div class="update-actions">
|
||||||
|
<button id="download-btn" class="secondary">Download rootCA.crt</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section>
|
||||||
|
<h2>Linux (system-wide)</h2>
|
||||||
|
<div class="card">
|
||||||
|
<p class="hint">Arch / Fedora / RHEL:</p>
|
||||||
|
<pre>sudo cp rootCA.crt /etc/ca-certificates/trust-source/anchors/furtka-local.crt
|
||||||
|
sudo update-ca-trust</pre>
|
||||||
|
<p class="hint">Debian / Ubuntu:</p>
|
||||||
|
<pre>sudo cp rootCA.crt /usr/local/share/ca-certificates/furtka-local.crt
|
||||||
|
sudo update-ca-certificates</pre>
|
||||||
|
<p class="hint">
|
||||||
|
Firefox keeps its own certificate store. After the above, open
|
||||||
|
<code>about:preferences#privacy</code> → <em>View Certificates</em> →
|
||||||
|
<em>Authorities</em> → <em>Import</em>, pick <code>rootCA.crt</code>,
|
||||||
|
tick <em>Trust this CA to identify websites</em>.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section>
|
||||||
|
<h2>macOS</h2>
|
||||||
|
<div class="card">
|
||||||
|
<ol>
|
||||||
|
<li>Double-click <code>rootCA.crt</code>. Keychain Access opens.</li>
|
||||||
|
<li>When prompted, add it to the <strong>System</strong> keychain.</li>
|
||||||
|
<li>Find the <em>Furtka</em> entry, double-click, expand <em>Trust</em>,
|
||||||
|
set <em>When using this certificate</em> to <strong>Always Trust</strong>.</li>
|
||||||
|
<li>Close the window — you will be asked for your password.</li>
|
||||||
|
</ol>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section>
|
||||||
|
<h2>Windows</h2>
|
||||||
|
<div class="card">
|
||||||
|
<ol>
|
||||||
|
<li>Double-click <code>rootCA.crt</code>.</li>
|
||||||
|
<li>Click <strong>Install Certificate</strong>.</li>
|
||||||
|
<li>Choose <strong>Local Machine</strong> (requires admin) and click <em>Next</em>.</li>
|
||||||
|
<li>Select <strong>Place all certificates in the following store</strong> →
|
||||||
|
<em>Browse</em> → <strong>Trusted Root Certification Authorities</strong>.</li>
|
||||||
|
<li>Finish. Chrome and Edge pick this up immediately. Firefox keeps its
|
||||||
|
own store — import the same file via Firefox settings.</li>
|
||||||
|
</ol>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section>
|
||||||
|
<h2>Android</h2>
|
||||||
|
<div class="card">
|
||||||
|
<ol>
|
||||||
|
<li>Transfer <code>rootCA.crt</code> to the device (AirDrop, email,
|
||||||
|
USB — whatever is handy).</li>
|
||||||
|
<li>Settings → <em>Security</em> (or <em>Security & privacy</em>)
|
||||||
|
→ <em>More security settings</em> → <em>Encryption & credentials</em>
|
||||||
|
→ <em>Install a certificate</em> → <strong>CA certificate</strong>.</li>
|
||||||
|
<li>Confirm the warning, then pick the file.</li>
|
||||||
|
</ol>
|
||||||
|
<p class="hint">
|
||||||
|
Android 11+ only trusts user-installed CAs for browsers by default.
|
||||||
|
Some apps (banking, Play services) ignore them. Not a Furtka bug —
|
||||||
|
an Android policy choice.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section>
|
||||||
|
<h2>iOS & iPadOS</h2>
|
||||||
|
<div class="card">
|
||||||
|
<p class="hint">
|
||||||
|
Honest warning: iOS needs a signed configuration profile for a
|
||||||
|
properly trusted CA. What works today:
|
||||||
|
</p>
|
||||||
|
<ol>
|
||||||
|
<li>Email <code>rootCA.crt</code> to yourself and open the attachment
|
||||||
|
in Mail. iOS prompts to install a profile.</li>
|
||||||
|
<li>Settings → <em>General</em> → <em>VPN & Device Management</em>
|
||||||
|
→ tap the Furtka profile → <strong>Install</strong>.</li>
|
||||||
|
<li>Settings → <em>General</em> → <em>About</em> → <em>Certificate
|
||||||
|
Trust Settings</em> → toggle <strong>Furtka</strong> on.</li>
|
||||||
|
</ol>
|
||||||
|
<p class="hint">
|
||||||
|
A packaged <code>.mobileconfig</code> makes this smoother; it's on
|
||||||
|
the roadmap but not in this release.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<footer>
|
||||||
|
<p>Furtka · <a href="https://furtka.org">furtka.org</a></p>
|
||||||
|
</footer>
|
||||||
|
</main>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
document.getElementById('hostname').textContent = location.hostname;
|
||||||
|
|
||||||
|
document.getElementById('download-btn').addEventListener('click', () => {
|
||||||
|
const a = document.createElement('a');
|
||||||
|
a.href = '/rootCA.crt';
|
||||||
|
a.download = 'furtka-local-rootCA.crt';
|
||||||
|
document.body.appendChild(a);
|
||||||
|
a.click();
|
||||||
|
a.remove();
|
||||||
|
});
|
||||||
|
|
||||||
|
(async () => {
|
||||||
|
try {
|
||||||
|
const r = await fetch('/api/furtka/https/status', { cache: 'no-store' });
|
||||||
|
if (!r.ok) return;
|
||||||
|
const s = await r.json();
|
||||||
|
document.getElementById('fingerprint').textContent =
|
||||||
|
s.fingerprint_sha256 || 'waiting for Caddy…';
|
||||||
|
} catch (e) { /* keep the placeholder */ }
|
||||||
|
})();
|
||||||
|
</script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
|
|
@ -50,6 +50,35 @@
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
|
<section>
|
||||||
|
<h2>Local HTTPS</h2>
|
||||||
|
<div class="card">
|
||||||
|
<p class="lede">
|
||||||
|
Serve this box over <code>https://<span id="https-host">—</span>/</code>
|
||||||
|
with a green padlock. Install the Furtka root CA once per device, then
|
||||||
|
optionally force every HTTP request to redirect.
|
||||||
|
</p>
|
||||||
|
<dl class="kv">
|
||||||
|
<dt>CA fingerprint (SHA-256)</dt><dd id="https-fingerprint">—</dd>
|
||||||
|
<dt>Reachable from this browser</dt><dd id="https-reachable">checking…</dd>
|
||||||
|
</dl>
|
||||||
|
<div class="update-actions">
|
||||||
|
<button id="https-download-btn" class="secondary">Download CA (.crt)</button>
|
||||||
|
<a href="/https-install/" class="inline-link">Per-OS install guide</a>
|
||||||
|
</div>
|
||||||
|
<label class="https-toggle" hidden id="https-force-wrap">
|
||||||
|
<input type="checkbox" id="https-force">
|
||||||
|
<span>Force HTTPS (redirect plain HTTP to HTTPS)</span>
|
||||||
|
</label>
|
||||||
|
<p class="hint" id="https-force-hint" hidden>
|
||||||
|
Enable this only after you've installed the CA and confirmed
|
||||||
|
<code>https://</code> works in this browser — otherwise the redirect
|
||||||
|
will leave you with a scary certificate warning.
|
||||||
|
</p>
|
||||||
|
<p id="https-status" class="hint"></p>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
<section>
|
<section>
|
||||||
<h2>Appearance</h2>
|
<h2>Appearance</h2>
|
||||||
<div class="card">
|
<div class="card">
|
||||||
|
|
@ -113,6 +142,7 @@
|
||||||
};
|
};
|
||||||
|
|
||||||
let pollHandle = null;
|
let pollHandle = null;
|
||||||
|
let fallbackReloadHandle = null;
|
||||||
const statusEl = document.getElementById('update-status');
|
const statusEl = document.getElementById('update-status');
|
||||||
const checkBtn = document.getElementById('check-updates-btn');
|
const checkBtn = document.getElementById('check-updates-btn');
|
||||||
const applyBtn = document.getElementById('apply-update-btn');
|
const applyBtn = document.getElementById('apply-update-btn');
|
||||||
|
|
@ -135,6 +165,7 @@
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
document.getElementById('upd-latest').textContent = data.latest || '—';
|
document.getElementById('upd-latest').textContent = data.latest || '—';
|
||||||
|
document.getElementById('upd-current').textContent = data.current || '—';
|
||||||
if (data.update_available) {
|
if (data.update_available) {
|
||||||
applyBtn.hidden = false;
|
applyBtn.hidden = false;
|
||||||
applyBtn.textContent = `Update to ${data.latest}`;
|
applyBtn.textContent = `Update to ${data.latest}`;
|
||||||
|
|
@ -169,6 +200,10 @@
|
||||||
// Poll /update-state.json (served by Caddy, unaffected by the
|
// Poll /update-state.json (served by Caddy, unaffected by the
|
||||||
// API restart the updater is about to trigger) every 2s.
|
// API restart the updater is about to trigger) every 2s.
|
||||||
pollHandle = setInterval(pollUpdateState, 2000);
|
pollHandle = setInterval(pollUpdateState, 2000);
|
||||||
|
// Fallback: reload regardless of whether polling observes 'done'.
|
||||||
|
// The mid-apply API restart can drop the poll connection before
|
||||||
|
// the terminal state is ever seen by this page.
|
||||||
|
fallbackReloadHandle = setTimeout(() => location.reload(), 45000);
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
setStatus(`Network error: ${e.message}`, true);
|
setStatus(`Network error: ${e.message}`, true);
|
||||||
applyBtn.disabled = false;
|
applyBtn.disabled = false;
|
||||||
|
|
@ -176,6 +211,111 @@
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// --- Local HTTPS --------------------------------------------------
|
||||||
|
|
||||||
|
const httpsFingerprintEl = document.getElementById('https-fingerprint');
|
||||||
|
const httpsReachableEl = document.getElementById('https-reachable');
|
||||||
|
const httpsHostEl = document.getElementById('https-host');
|
||||||
|
const httpsDownloadBtn = document.getElementById('https-download-btn');
|
||||||
|
const httpsForceWrap = document.getElementById('https-force-wrap');
|
||||||
|
const httpsForceHint = document.getElementById('https-force-hint');
|
||||||
|
const httpsForce = document.getElementById('https-force');
|
||||||
|
const httpsStatusEl = document.getElementById('https-status');
|
||||||
|
|
||||||
|
httpsHostEl.textContent = location.hostname;
|
||||||
|
|
||||||
|
httpsDownloadBtn.addEventListener('click', () => {
|
||||||
|
// Use an anchor with the download attr so the browser treats
|
||||||
|
// the cert as a download rather than rendering it.
|
||||||
|
const a = document.createElement('a');
|
||||||
|
a.href = '/rootCA.crt';
|
||||||
|
a.download = 'furtka-local-rootCA.crt';
|
||||||
|
document.body.appendChild(a);
|
||||||
|
a.click();
|
||||||
|
a.remove();
|
||||||
|
});
|
||||||
|
|
||||||
|
async function refreshHttpsStatus() {
|
||||||
|
try {
|
||||||
|
const r = await fetch('/api/furtka/https/status', { cache: 'no-store' });
|
||||||
|
if (!r.ok) return;
|
||||||
|
const s = await r.json();
|
||||||
|
httpsFingerprintEl.textContent = s.fingerprint_sha256 || 'waiting for Caddy…';
|
||||||
|
httpsDownloadBtn.disabled = !s.ca_available;
|
||||||
|
httpsForce.checked = !!s.force_https;
|
||||||
|
updateForceToggleVisibility(s);
|
||||||
|
} catch (e) {
|
||||||
|
/* next refresh will retry */
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function probeHttpsReachable() {
|
||||||
|
if (location.protocol === 'https:') {
|
||||||
|
httpsReachableEl.textContent = 'yes — you are on HTTPS now';
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
// no-cors: we don't need the response body, just whether the
|
||||||
|
// TLS handshake + fetch succeed. Browsers reject on untrusted
|
||||||
|
// cert with a TypeError, which is exactly the signal we want.
|
||||||
|
await fetch('https://' + location.hostname + '/furtka.json',
|
||||||
|
{ cache: 'no-store', mode: 'no-cors' });
|
||||||
|
httpsReachableEl.textContent = 'yes — CA already trusted';
|
||||||
|
return true;
|
||||||
|
} catch (e) {
|
||||||
|
httpsReachableEl.textContent = 'no — install the CA first';
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
let httpsReachableCache = false;
|
||||||
|
function updateForceToggleVisibility(status) {
|
||||||
|
// Show the force-redirect toggle only when both:
|
||||||
|
// - Caddy's CA exists (otherwise there's no HTTPS to redirect to)
|
||||||
|
// - the current browser already trusts the cert (otherwise the
|
||||||
|
// user would lock themselves out of this very page)
|
||||||
|
const show = status.ca_available && httpsReachableCache;
|
||||||
|
httpsForceWrap.hidden = !show;
|
||||||
|
httpsForceHint.hidden = !show;
|
||||||
|
}
|
||||||
|
|
||||||
|
httpsForce.addEventListener('change', async () => {
|
||||||
|
httpsForce.disabled = true;
|
||||||
|
const desired = httpsForce.checked;
|
||||||
|
httpsStatusEl.textContent = desired
|
||||||
|
? 'Enabling HTTP→HTTPS redirect…'
|
||||||
|
: 'Disabling HTTP→HTTPS redirect…';
|
||||||
|
httpsStatusEl.style.color = 'var(--muted)';
|
||||||
|
try {
|
||||||
|
const r = await fetch('/api/furtka/https/force', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({ enabled: desired }),
|
||||||
|
});
|
||||||
|
const data = await r.json();
|
||||||
|
if (!r.ok) {
|
||||||
|
httpsStatusEl.textContent = data.error || `HTTP ${r.status}`;
|
||||||
|
httpsStatusEl.style.color = 'var(--danger)';
|
||||||
|
httpsForce.checked = !desired;
|
||||||
|
} else {
|
||||||
|
httpsStatusEl.textContent = data.force_https
|
||||||
|
? 'Redirect on — new HTTP requests will jump to HTTPS.'
|
||||||
|
: 'Redirect off — HTTP serves the content directly.';
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
httpsStatusEl.textContent = `Network error: ${e.message}`;
|
||||||
|
httpsStatusEl.style.color = 'var(--danger)';
|
||||||
|
httpsForce.checked = !desired;
|
||||||
|
} finally {
|
||||||
|
httpsForce.disabled = false;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
(async () => {
|
||||||
|
httpsReachableCache = await probeHttpsReachable();
|
||||||
|
await refreshHttpsStatus();
|
||||||
|
})();
|
||||||
|
|
||||||
async function pollUpdateState() {
|
async function pollUpdateState() {
|
||||||
try {
|
try {
|
||||||
const r = await fetch('/update-state.json', { cache: 'no-store' });
|
const r = await fetch('/update-state.json', { cache: 'no-store' });
|
||||||
|
|
@ -185,9 +325,11 @@
|
||||||
setStatus(label, s.stage === 'rolled_back');
|
setStatus(label, s.stage === 'rolled_back');
|
||||||
if (s.stage === 'done') {
|
if (s.stage === 'done') {
|
||||||
clearInterval(pollHandle);
|
clearInterval(pollHandle);
|
||||||
|
clearTimeout(fallbackReloadHandle);
|
||||||
setTimeout(() => location.reload(), 5000);
|
setTimeout(() => location.reload(), 5000);
|
||||||
} else if (s.stage === 'rolled_back') {
|
} else if (s.stage === 'rolled_back') {
|
||||||
clearInterval(pollHandle);
|
clearInterval(pollHandle);
|
||||||
|
clearTimeout(fallbackReloadHandle);
|
||||||
if (s.reason) {
|
if (s.reason) {
|
||||||
setStatus(`${label} — ${s.reason}`, true);
|
setStatus(`${label} — ${s.reason}`, true);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -311,8 +311,31 @@ details.log-details[open] > summary { color: var(--fg); }
|
||||||
gap: 0.5rem;
|
gap: 0.5rem;
|
||||||
flex-wrap: wrap;
|
flex-wrap: wrap;
|
||||||
margin-top: 1rem;
|
margin-top: 1rem;
|
||||||
|
align-items: center;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* Inline link rendered alongside a button (e.g. next to "Download CA"
|
||||||
|
on /settings). No button chrome — just accent colour + underline on
|
||||||
|
hover — so the distinction between primary action and secondary
|
||||||
|
resource stays visually clear. */
|
||||||
|
.inline-link {
|
||||||
|
color: var(--accent);
|
||||||
|
text-decoration: none;
|
||||||
|
font-size: 0.9rem;
|
||||||
|
}
|
||||||
|
.inline-link:hover { text-decoration: underline; }
|
||||||
|
|
||||||
|
/* Checkbox + label row for the /settings HTTPS-force toggle. */
|
||||||
|
.https-toggle {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.55rem;
|
||||||
|
margin-top: 1rem;
|
||||||
|
font-size: 0.95rem;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
.https-toggle input { cursor: pointer; }
|
||||||
|
|
||||||
/* -- Shared primitives for later slices ------------------------ */
|
/* -- Shared primitives for later slices ------------------------ */
|
||||||
.chip {
|
.chip {
|
||||||
display: inline-block;
|
display: inline-block;
|
||||||
|
|
|
||||||
|
|
@ -550,6 +550,27 @@ def _do_furtka_apply():
|
||||||
return 202, {"status": "dispatched", "unit": "furtka-update"}
|
return 202, {"status": "dispatched", "unit": "furtka-update"}
|
||||||
|
|
||||||
|
|
||||||
|
def _do_https_status():
|
||||||
|
"""Return CA fingerprint + force-redirect state for /api/furtka/https/status."""
|
||||||
|
from furtka import https
|
||||||
|
|
||||||
|
return 200, https.status()
|
||||||
|
|
||||||
|
|
||||||
|
def _do_https_force(payload):
|
||||||
|
"""Toggle HTTP→HTTPS redirect for /api/furtka/https/force."""
|
||||||
|
from furtka import https
|
||||||
|
|
||||||
|
enabled = payload.get("enabled")
|
||||||
|
if not isinstance(enabled, bool):
|
||||||
|
return 400, {"error": "'enabled' must be a boolean"}
|
||||||
|
try:
|
||||||
|
result = https.set_force_https(enabled)
|
||||||
|
except https.HttpsError as e:
|
||||||
|
return 500, {"error": str(e)}
|
||||||
|
return 200, {"force_https": result}
|
||||||
|
|
||||||
|
|
||||||
def _do_furtka_status():
|
def _do_furtka_status():
|
||||||
"""Return the latest update-state.json written by the updater.
|
"""Return the latest update-state.json written by the updater.
|
||||||
|
|
||||||
|
|
@ -636,6 +657,9 @@ class _Handler(BaseHTTPRequestHandler):
|
||||||
if self.path == "/api/furtka/update/status":
|
if self.path == "/api/furtka/update/status":
|
||||||
status, body = _do_furtka_status()
|
status, body = _do_furtka_status()
|
||||||
return self._json(status, body)
|
return self._json(status, body)
|
||||||
|
if self.path == "/api/furtka/https/status":
|
||||||
|
status, body = _do_https_status()
|
||||||
|
return self._json(status, body)
|
||||||
# /api/apps/<name>/settings
|
# /api/apps/<name>/settings
|
||||||
if self.path.startswith("/api/apps/") and self.path.endswith("/settings"):
|
if self.path.startswith("/api/apps/") and self.path.endswith("/settings"):
|
||||||
name = self.path[len("/api/apps/") : -len("/settings")]
|
name = self.path[len("/api/apps/") : -len("/settings")]
|
||||||
|
|
@ -681,6 +705,9 @@ class _Handler(BaseHTTPRequestHandler):
|
||||||
if self.path == "/api/furtka/update/apply":
|
if self.path == "/api/furtka/update/apply":
|
||||||
status, body = _do_furtka_apply()
|
status, body = _do_furtka_apply()
|
||||||
return self._json(status, body)
|
return self._json(status, body)
|
||||||
|
if self.path == "/api/furtka/https/force":
|
||||||
|
status, body = _do_https_force(payload)
|
||||||
|
return self._json(status, body)
|
||||||
|
|
||||||
name = payload.get("name")
|
name = payload.get("name")
|
||||||
if not isinstance(name, str) or not name:
|
if not isinstance(name, str) or not name:
|
||||||
|
|
|
||||||
117
furtka/https.py
Normal file
117
furtka/https.py
Normal file
|
|
@ -0,0 +1,117 @@
|
||||||
|
"""Local-CA HTTPS helpers for the `tls internal` setup.
|
||||||
|
|
||||||
|
Caddy generates the local root CA lazily on first start and keeps it under
|
||||||
|
$XDG_DATA_HOME/caddy/pki/authorities/local/ — on the target that's
|
||||||
|
/var/lib/caddy/.local/share/caddy/pki/authorities/local/ (the caddy system
|
||||||
|
user's XDG_DATA_HOME resolves there). 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.
|
||||||
|
"""
|
||||||
|
|
||||||
|
import base64
|
||||||
|
import hashlib
|
||||||
|
import re
|
||||||
|
import subprocess
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
CA_CERT_PATH = Path(
|
||||||
|
"/var/lib/caddy/.local/share/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"
|
||||||
|
|
||||||
|
_PEM_RE = re.compile(
|
||||||
|
r"-----BEGIN CERTIFICATE-----\s*(.+?)\s*-----END CERTIFICATE-----",
|
||||||
|
re.DOTALL,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class HttpsError(Exception):
|
||||||
|
"""Recoverable failure from set_force_https — the caller should 5xx."""
|
||||||
|
|
||||||
|
|
||||||
|
def _ca_fingerprint(ca_path: Path) -> str | None:
|
||||||
|
try:
|
||||||
|
pem = ca_path.read_text()
|
||||||
|
except (FileNotFoundError, PermissionError, IsADirectoryError):
|
||||||
|
return None
|
||||||
|
match = _PEM_RE.search(pem)
|
||||||
|
if not match:
|
||||||
|
return None
|
||||||
|
try:
|
||||||
|
der = base64.b64decode("".join(match.group(1).split()))
|
||||||
|
except (ValueError, base64.binascii.Error):
|
||||||
|
return None
|
||||||
|
return hashlib.sha256(der).hexdigest().upper()
|
||||||
|
|
||||||
|
|
||||||
|
def _format_fingerprint(hex_upper: str) -> str:
|
||||||
|
return ":".join(hex_upper[i : i + 2] for i in range(0, len(hex_upper), 2))
|
||||||
|
|
||||||
|
|
||||||
|
def status(
|
||||||
|
ca_path: Path = CA_CERT_PATH,
|
||||||
|
snippet: Path = REDIRECT_SNIPPET,
|
||||||
|
) -> dict:
|
||||||
|
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(),
|
||||||
|
"ca_download_url": "/rootCA.crt",
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def _default_reload() -> None:
|
||||||
|
subprocess.run(
|
||||||
|
["systemctl", "reload", "caddy"],
|
||||||
|
check=True,
|
||||||
|
capture_output=True,
|
||||||
|
text=True,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def set_force_https(
|
||||||
|
enabled: bool,
|
||||||
|
snippet_dir: Path = SNIPPET_DIR,
|
||||||
|
snippet: Path = REDIRECT_SNIPPET,
|
||||||
|
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.
|
||||||
|
"""
|
||||||
|
snippet_dir.mkdir(mode=0o755, parents=True, exist_ok=True)
|
||||||
|
had = snippet.is_file()
|
||||||
|
previous = snippet.read_text() if had else None
|
||||||
|
if enabled:
|
||||||
|
snippet.write_text(REDIRECT_CONTENT)
|
||||||
|
elif had:
|
||||||
|
snippet.unlink()
|
||||||
|
|
||||||
|
try:
|
||||||
|
reload_caddy()
|
||||||
|
except subprocess.CalledProcessError as e:
|
||||||
|
_revert(snippet, previous)
|
||||||
|
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)
|
||||||
|
raise HttpsError(f"systemctl not available: {e}") from e
|
||||||
|
return enabled
|
||||||
|
|
||||||
|
|
||||||
|
def _revert(snippet: Path, previous: str | None) -> None:
|
||||||
|
if previous is None:
|
||||||
|
try:
|
||||||
|
snippet.unlink()
|
||||||
|
except FileNotFoundError:
|
||||||
|
pass
|
||||||
|
else:
|
||||||
|
snippet.write_text(previous)
|
||||||
|
|
@ -46,6 +46,9 @@ FORGEJO_REPO = os.environ.get("FURTKA_FORGEJO_REPO", "daniel/furtka")
|
||||||
_FURTKA_ROOT = Path(os.environ.get("FURTKA_ROOT", "/opt/furtka"))
|
_FURTKA_ROOT = Path(os.environ.get("FURTKA_ROOT", "/opt/furtka"))
|
||||||
_STATE_DIR = Path(os.environ.get("FURTKA_STATE_DIR", "/var/lib/furtka"))
|
_STATE_DIR = Path(os.environ.get("FURTKA_STATE_DIR", "/var/lib/furtka"))
|
||||||
_CADDYFILE_LIVE = Path(os.environ.get("FURTKA_CADDYFILE_PATH", "/etc/caddy/Caddyfile"))
|
_CADDYFILE_LIVE = Path(os.environ.get("FURTKA_CADDYFILE_PATH", "/etc/caddy/Caddyfile"))
|
||||||
|
_CADDY_SNIPPET_DIR = Path(
|
||||||
|
os.environ.get("FURTKA_CADDY_SNIPPET_DIR", str(_CADDYFILE_LIVE.parent / "furtka.d"))
|
||||||
|
)
|
||||||
_SYSTEMD_DIR = Path(os.environ.get("FURTKA_SYSTEMD_DIR", "/etc/systemd/system"))
|
_SYSTEMD_DIR = Path(os.environ.get("FURTKA_SYSTEMD_DIR", "/etc/systemd/system"))
|
||||||
|
|
||||||
|
|
||||||
|
|
@ -218,6 +221,10 @@ def _refresh_caddyfile(source: Path) -> bool:
|
||||||
if the file changed (so caddy needs more than a bare reload)."""
|
if the file changed (so caddy needs more than a bare reload)."""
|
||||||
if not source.is_file():
|
if not source.is_file():
|
||||||
return False
|
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.
|
||||||
|
_CADDY_SNIPPET_DIR.mkdir(mode=0o755, parents=True, exist_ok=True)
|
||||||
if _CADDYFILE_LIVE.is_file() and source.read_bytes() == _CADDYFILE_LIVE.read_bytes():
|
if _CADDYFILE_LIVE.is_file() and source.read_bytes() == _CADDYFILE_LIVE.read_bytes():
|
||||||
return False
|
return False
|
||||||
_CADDYFILE_LIVE.parent.mkdir(parents=True, exist_ok=True)
|
_CADDYFILE_LIVE.parent.mkdir(parents=True, exist_ok=True)
|
||||||
|
|
|
||||||
166
tests/test_https.py
Normal file
166
tests/test_https.py
Normal file
|
|
@ -0,0 +1,166 @@
|
||||||
|
"""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"
|
||||||
|
|
@ -121,6 +121,46 @@ def test_caddyfile_asset_serves_from_current():
|
||||||
assert "root * /var/lib/furtka" in caddy
|
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;
|
||||||
|
# :443 uses Caddy's built-in local CA (tls internal) so users who have
|
||||||
|
# installed it get the green padlock.
|
||||||
|
caddy = (ASSETS / "Caddyfile").read_text()
|
||||||
|
assert ":80 {" in caddy
|
||||||
|
assert ":443 {" 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 "(furtka_routes)" in caddy
|
||||||
|
assert caddy.count("import furtka_routes") == 2
|
||||||
|
|
||||||
|
|
||||||
|
def test_caddyfile_imports_force_redirect_snippet_dir():
|
||||||
|
# The /api/furtka/https/force endpoint toggles HTTP→HTTPS by writing or
|
||||||
|
# removing a snippet file in this dir; the Caddyfile must glob-import it
|
||||||
|
# inside the :80 block for the toggle to take effect.
|
||||||
|
caddy = (ASSETS / "Caddyfile").read_text()
|
||||||
|
assert "import /etc/caddy/furtka.d/*.caddyfile" in caddy
|
||||||
|
|
||||||
|
|
||||||
|
def test_caddyfile_exposes_root_ca_download():
|
||||||
|
# /rootCA.crt is the download handle the UI uses. It must map to the
|
||||||
|
# Caddy local-CA pki path and set a Content-Disposition so the browser
|
||||||
|
# treats it as a download rather than trying to render it.
|
||||||
|
caddy = (ASSETS / "Caddyfile").read_text()
|
||||||
|
assert "handle /rootCA.crt" in caddy
|
||||||
|
assert "/var/lib/caddy/.local/share/caddy/pki/authorities/local" in caddy
|
||||||
|
assert 'attachment; filename=furtka-local-rootCA.crt' in caddy
|
||||||
|
|
||||||
|
|
||||||
|
def test_post_install_creates_furtka_d_snippet_dir(install_cmds):
|
||||||
|
# Pre-existing installs pick up the import path via updater._refresh_caddyfile,
|
||||||
|
# but fresh installs never run that — this command is the only guarantee
|
||||||
|
# that the first Caddy start on a brand-new box has a dir to glob-import.
|
||||||
|
matching = [c for c in install_cmds if "/etc/caddy/furtka.d" in c and "install -d" in c]
|
||||||
|
assert matching, "no install -d command creates /etc/caddy/furtka.d"
|
||||||
|
|
||||||
|
|
||||||
def test_systemd_units_reference_current_paths():
|
def test_systemd_units_reference_current_paths():
|
||||||
for unit in ("furtka-status.service", "furtka-welcome.service"):
|
for unit in ("furtka-status.service", "furtka-welcome.service"):
|
||||||
body = (ASSETS / "systemd" / unit).read_text()
|
body = (ASSETS / "systemd" / unit).read_text()
|
||||||
|
|
|
||||||
|
|
@ -320,6 +320,13 @@ def _post_install_commands(hostname):
|
||||||
"/etc/nsswitch.conf"
|
"/etc/nsswitch.conf"
|
||||||
)
|
)
|
||||||
return [
|
return [
|
||||||
|
# Import dir for the HTTP→HTTPS force-redirect snippet. The
|
||||||
|
# /api/furtka/https/force endpoint writes/removes a .caddyfile here
|
||||||
|
# to toggle the redirect. Must exist before Caddy starts — the
|
||||||
|
# Caddyfile's glob `import /etc/caddy/furtka.d/*.caddyfile` tolerates
|
||||||
|
# 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",
|
||||||
# The Caddyfile lives at /etc/caddy/Caddyfile per Caddy's convention
|
# The Caddyfile lives at /etc/caddy/Caddyfile per Caddy's convention
|
||||||
# (systemd unit points there). Content comes from the shipped asset,
|
# (systemd unit points there). Content comes from the shipped asset,
|
||||||
# which we copy in at install time so updates that change routing
|
# which we copy in at install time so updates that change routing
|
||||||
|
|
|
||||||
Loading…
Add table
Reference in a new issue