furtka/assets/www/settings/index.html
Robert Syrnicki f88412d9e8 Add gateway-connect UI and per-app internet-exposure toggle
Settings page gets a "Furtka Gateway" section: connect (gateway URL +
registration token + box name, polls /api/gateway/connect/status the same
way the update-apply flow polls /update-state.json) and disconnect, plus
a status readout (gateway URL, tunnel state). Removed "Remote access"
from the "Coming next" list now that it exists.

The Apps page's installed-app rows get an "Expose to Internet" control,
shown only when the manifest declares internet.viable — fetched lazily
per app (loadInternetSlots()) since the publish state isn't part of the
main /api/apps listing. Toggling calls the existing
GET/POST /api/apps/<name>/internet endpoints; the subdomain prompt uses a
plain window.prompt() rather than extending the settings modal, since
this needs a live network round-trip and a returned public URL that the
generic settings form has no way to express.

Verified end-to-end in a real browser against a running furtka serve +
a stub gateway server (login, publish, unpublish, connect-with-missing-
systemd-run, disconnect, and the client-side required-field validation),
not just by reading the generated HTML/JS.
2026-08-24 14:57:11 +02:00

599 lines
26 KiB
HTML
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8">
<title>Settings · 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>
<a href="#" id="logout-link" onclick="return doLogout(event)">Logout</a>
</div>
</nav>
<h1>Settings</h1>
<p class="lede">What this box knows about itself.</p>
<section>
<h2>About this box</h2>
<div class="card">
<dl class="kv">
<dt>Hostname</dt><dd id="set-hostname">—</dd>
<dt>IP address</dt><dd id="set-ip">—</dd>
<dt>Furtka version</dt><dd id="set-version">—</dd>
<dt>Kernel</dt><dd id="set-kernel">—</dd>
<dt>RAM</dt><dd id="set-ram">—</dd>
<dt>Docker</dt><dd id="set-docker">—</dd>
<dt>Uptime</dt><dd id="set-uptime">—</dd>
</dl>
</div>
</section>
<section>
<h2>Furtka updates</h2>
<div class="card">
<dl class="kv">
<dt>Installed</dt><dd id="upd-current">—</dd>
<dt>Latest available</dt><dd id="upd-latest">—</dd>
</dl>
<div class="update-actions">
<button id="check-updates-btn" class="secondary">Check for updates</button>
<button id="apply-update-btn" hidden>Update now</button>
</div>
<p id="update-status" class="hint"></p>
</div>
</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>
<h2>Furtka Gateway</h2>
<div class="card">
<p class="lede">
Connect this box to a furtka-gateway to expose individual apps to the
internet over a WireGuard tunnel. Apps stay private until you explicitly
publish one from the <a href="/apps">Apps</a> page — connecting here only
opens the tunnel, nothing is exposed automatically.
</p>
<div id="gateway-disconnected">
<div class="field">
<label for="gw-url">Gateway URL</label>
<input type="text" id="gw-url" placeholder="https://gw.example.com">
</div>
<div class="field">
<label for="gw-token">Registration token</label>
<input type="password" id="gw-token" placeholder="from the gateway operator">
</div>
<div class="field">
<label for="gw-name">Box name</label>
<input type="text" id="gw-name" placeholder="my-box">
</div>
<div class="update-actions">
<button id="gateway-connect-btn">Connect</button>
</div>
</div>
<div id="gateway-connected" hidden>
<dl class="kv">
<dt>Gateway</dt><dd id="gw-connected-url">—</dd>
<dt>Tunnel</dt><dd id="gw-tunnel-status">—</dd>
</dl>
<div class="update-actions">
<button class="danger" id="gateway-disconnect-btn">Disconnect</button>
</div>
</div>
<p id="gateway-status" class="hint"></p>
</div>
</section>
<section>
<h2>Appearance</h2>
<div class="card">
<dl class="kv">
<dt>Theme</dt><dd>Follows your system setting</dd>
<dt>Language</dt><dd>English</dd>
</dl>
</div>
</section>
<section>
<h2>Power</h2>
<div class="card">
<p class="lede">
Reboot or shut down the whole Furtka box. Takes a few seconds to
finish; the UI will reconnect itself after a reboot.
</p>
<div class="power-actions">
<button type="button" id="power-reboot" class="secondary">Reboot</button>
<button type="button" id="power-poweroff" class="danger">Shut down</button>
</div>
<p id="power-status" class="hint"></p>
</div>
</section>
<section>
<h2>Coming next</h2>
<div class="coming">
<p class="hint">Controls we're building — follow progress on <a href="https://furtka.org">furtka.org</a>.</p>
<a href="https://furtka.org/#planned">Change hostname</a>
<a href="https://furtka.org/#planned">Backup</a>
<a href="https://furtka.org/#planned">User accounts</a>
</div>
</section>
<footer>
<p>Furtka · <a href="https://furtka.org">furtka.org</a></p>
</footer>
</main>
<script>
// Logout button in the nav — same shape as /apps and / pages.
async function doLogout(ev) {
ev.preventDefault();
try { await fetch('/logout', { method: 'POST', credentials: 'same-origin' }); }
catch (e) { /* server may already be down */ }
window.location.href = '/login';
return false;
}
async function refresh() {
try {
const r = await fetch('/status.json', { cache: 'no-store' });
if (!r.ok) return;
const s = await r.json();
document.getElementById('set-hostname').textContent = s.hostname || '—';
document.getElementById('set-ip').textContent = s.ip_primary || '—';
document.getElementById('set-version').textContent = s.furtka_version || '—';
document.getElementById('set-kernel').textContent = s.kernel || '—';
document.getElementById('set-ram').textContent = s.ram_total || '—';
document.getElementById('set-docker').textContent = s.docker_version || '—';
document.getElementById('set-uptime').textContent = s.uptime || '—';
document.getElementById('upd-current').textContent = s.furtka_version || '—';
} catch (e) {
/* next tick will retry */
}
}
refresh();
setInterval(refresh, 15000);
// --- Furtka updates -----------------------------------------------
const STAGE_LABELS = {
downloading: 'Downloading release…',
verifying: 'Verifying signature…',
extracting: 'Unpacking update…',
swapping: 'Switching to new version…',
restarting: 'Restarting services…',
done: 'Update complete — reloading…',
rolled_back: 'Update failed, rolled back to the previous version',
rolled_back_manual: 'Rolled back manually',
};
let pollHandle = null;
let fallbackReloadHandle = null;
const statusEl = document.getElementById('update-status');
const checkBtn = document.getElementById('check-updates-btn');
const applyBtn = document.getElementById('apply-update-btn');
function setStatus(msg, isError = false) {
statusEl.textContent = msg;
statusEl.style.color = isError ? 'var(--danger)' : 'var(--muted)';
}
checkBtn.addEventListener('click', async () => {
checkBtn.disabled = true;
const original = checkBtn.textContent;
checkBtn.textContent = 'Checking…';
setStatus('');
try {
const r = await fetch('/api/furtka/update/check', { method: 'POST' });
const data = await r.json();
if (!r.ok) {
setStatus(data.error || `HTTP ${r.status}`, true);
return;
}
document.getElementById('upd-latest').textContent = data.latest || '—';
document.getElementById('upd-current').textContent = data.current || '—';
if (data.update_available) {
applyBtn.hidden = false;
applyBtn.textContent = `Update to ${data.latest}`;
setStatus(`Update available: ${data.current} → ${data.latest}`);
} else {
applyBtn.hidden = true;
setStatus('Already up to date');
}
} catch (e) {
setStatus(`Network error: ${e.message}`, true);
} finally {
checkBtn.disabled = false;
checkBtn.textContent = original;
}
});
applyBtn.addEventListener('click', async () => {
applyBtn.disabled = true;
checkBtn.disabled = true;
setStatus('Starting update…');
try {
const r = await fetch('/api/furtka/update/apply', { method: 'POST' });
const data = await r.json();
if (r.status === 409) {
setStatus('Another update is already running — watching it', true);
} else if (!r.ok) {
setStatus(data.error || `HTTP ${r.status}`, true);
applyBtn.disabled = false;
checkBtn.disabled = false;
return;
}
// Poll /update-state.json (served by Caddy, unaffected by the
// API restart the updater is about to trigger) every 2s.
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) {
setStatus(`Network error: ${e.message}`, true);
applyBtn.disabled = false;
checkBtn.disabled = false;
}
});
// --- 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();
})();
// --- Furtka Gateway -------------------------------------------------
const CONNECT_STAGE_LABELS = {
registering: 'Registering with the gateway…',
starting_tunnel: 'Starting the WireGuard tunnel…',
done: 'Connected.',
};
const gatewayDisconnectedEl = document.getElementById('gateway-disconnected');
const gatewayConnectedEl = document.getElementById('gateway-connected');
const gwConnectedUrlEl = document.getElementById('gw-connected-url');
const gwTunnelStatusEl = document.getElementById('gw-tunnel-status');
const gatewayStatusEl = document.getElementById('gateway-status');
const gatewayConnectBtn = document.getElementById('gateway-connect-btn');
const gatewayDisconnectBtn = document.getElementById('gateway-disconnect-btn');
let gatewayPollHandle = null;
function setGatewayStatus(msg, isError = false) {
gatewayStatusEl.textContent = msg;
gatewayStatusEl.style.color = isError ? 'var(--danger)' : 'var(--muted)';
}
function describeTunnel(tunnel) {
if (!tunnel || !tunnel.configured) return 'not configured';
return tunnel.latest_handshake ? `up (last handshake ${tunnel.latest_handshake})` : 'configured, no handshake yet';
}
async function refreshGatewayStatus() {
try {
const r = await fetch('/api/gateway/status', { cache: 'no-store' });
if (!r.ok) return;
const s = await r.json();
gatewayDisconnectedEl.hidden = !!s.connected;
gatewayConnectedEl.hidden = !s.connected;
if (s.connected) {
gwConnectedUrlEl.textContent = s.gateway_url;
gwTunnelStatusEl.textContent = describeTunnel(s.tunnel);
}
} catch (e) {
/* next refresh will retry */
}
}
refreshGatewayStatus();
gatewayConnectBtn.addEventListener('click', async () => {
const gateway_url = document.getElementById('gw-url').value.trim();
const registration_token = document.getElementById('gw-token').value;
const box_name = document.getElementById('gw-name').value.trim();
if (!gateway_url || !registration_token || !box_name) {
setGatewayStatus('Gateway URL, registration token, and box name are all required.', true);
return;
}
gatewayConnectBtn.disabled = true;
setGatewayStatus('Connecting…');
try {
const r = await fetch('/api/gateway/connect', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ gateway_url, registration_token, box_name }),
});
const data = await r.json();
if (!r.ok) {
setGatewayStatus(data.error || `HTTP ${r.status}`, true);
gatewayConnectBtn.disabled = false;
return;
}
gatewayPollHandle = setInterval(pollGatewayConnectState, 2000);
} catch (e) {
setGatewayStatus(`Network error: ${e.message}`, true);
gatewayConnectBtn.disabled = false;
}
});
async function pollGatewayConnectState() {
try {
const r = await fetch('/api/gateway/connect/status', { cache: 'no-store' });
if (!r.ok) return;
const s = await r.json();
const label = CONNECT_STAGE_LABELS[s.stage] || `Stage: ${s.stage}`;
setGatewayStatus(label, s.stage === 'error');
if (s.stage === 'done') {
clearInterval(gatewayPollHandle);
gatewayConnectBtn.disabled = false;
await refreshGatewayStatus();
} else if (s.stage === 'error') {
clearInterval(gatewayPollHandle);
gatewayConnectBtn.disabled = false;
if (s.error) setGatewayStatus(`${label} — ${s.error}`, true);
}
} catch (e) {
/* keep polling; transient blip expected */
}
}
gatewayDisconnectBtn.addEventListener('click', async () => {
if (!confirm('Disconnect from the gateway? Any apps currently exposed to the internet will stop being reachable.')) return;
gatewayDisconnectBtn.disabled = true;
setGatewayStatus('Disconnecting…');
try {
const r = await fetch('/api/gateway/disconnect', { method: 'POST' });
const data = await r.json();
if (!r.ok) {
setGatewayStatus(data.error || `HTTP ${r.status}`, true);
return;
}
setGatewayStatus('Disconnected.');
await refreshGatewayStatus();
} catch (e) {
setGatewayStatus(`Network error: ${e.message}`, true);
} finally {
gatewayDisconnectBtn.disabled = false;
}
});
async function pollUpdateState() {
try {
const r = await fetch('/update-state.json', { cache: 'no-store' });
if (!r.ok) return;
const s = await r.json();
const label = STAGE_LABELS[s.stage] || `Stage: ${s.stage}`;
setStatus(label, s.stage === 'rolled_back');
if (s.stage === 'done') {
clearInterval(pollHandle);
clearTimeout(fallbackReloadHandle);
setTimeout(() => location.reload(), 5000);
} else if (s.stage === 'rolled_back') {
clearInterval(pollHandle);
clearTimeout(fallbackReloadHandle);
if (s.reason) {
setStatus(`${label} — ${s.reason}`, true);
}
applyBtn.disabled = false;
checkBtn.disabled = false;
}
} catch (e) {
/* keep polling; restart blip expected */
}
}
// Power buttons: confirm, POST, then swap the whole card into a
// "going down" state so the user doesn't keep clicking. After a
// reboot we try to reconnect after ~45s; for shutdown we just
// tell the user the box is off — no auto-reconnect attempt.
const powerStatusEl = document.getElementById('power-status');
const rebootBtn = document.getElementById('power-reboot');
const poweroffBtn = document.getElementById('power-poweroff');
function setPowerStatus(msg, tone = 'muted') {
powerStatusEl.textContent = msg;
powerStatusEl.style.color =
tone === 'error' ? 'var(--danger)' : 'var(--muted)';
}
async function triggerPower(action, confirmMsg, inflightLabel) {
if (!confirm(confirmMsg)) return;
rebootBtn.disabled = true;
poweroffBtn.disabled = true;
setPowerStatus(inflightLabel);
try {
const r = await fetch('/api/furtka/power', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ action }),
});
if (!r.ok) {
const data = await r.json().catch(() => ({}));
setPowerStatus(data.error || `HTTP ${r.status}`, 'error');
rebootBtn.disabled = false;
poweroffBtn.disabled = false;
return;
}
if (action === 'reboot') {
setPowerStatus('Rebooting… this page will reload when the box is back.');
// Try reconnecting after a generous delay. archinstall
// + boot + services typically takes 30–45 s; give it 30
// before the first poke so we don't just spin against
// a down kernel.
setTimeout(pollForReconnect, 30000);
} else {
setPowerStatus(
'Shutdown scheduled. Press the physical power button to turn it back on.'
);
}
} catch (e) {
setPowerStatus(`Network error: ${e.message}`, 'error');
rebootBtn.disabled = false;
poweroffBtn.disabled = false;
}
}
async function pollForReconnect() {
// Fetch a tiny static file; when it comes back 200 the box is up.
try {
const r = await fetch('/furtka.json', { cache: 'no-store' });
if (r.ok) {
setPowerStatus('Back up — reloading…');
setTimeout(() => location.reload(), 1500);
return;
}
} catch (e) { /* still down */ }
setTimeout(pollForReconnect, 3000);
}
rebootBtn.addEventListener('click', () =>
triggerPower(
'reboot',
"Wirklich neu starten? Die Box ist für ~30 Sekunden nicht erreichbar.",
'Rebooting…'
)
);
poweroffBtn.addEventListener('click', () =>
triggerPower(
'poweroff',
"Wirklich ausschalten? Du kannst die Box erst wieder starten, wenn du den physischen Power-Knopf drückst.",
'Shutting down…'
)
);
</script>
</body>
</html>