From f88412d9e85332cbe5becd90777c218c30d70d09 Mon Sep 17 00:00:00 2001 From: Robert Syrnicki Date: Mon, 24 Aug 2026 14:57:11 +0200 Subject: [PATCH] Add gateway-connect UI and per-app internet-exposure toggle MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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//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. --- assets/www/settings/index.html | 154 ++++++++++++++++++++++++++++++++- furtka/api.py | 66 ++++++++++++++ 2 files changed, 219 insertions(+), 1 deletion(-) diff --git a/assets/www/settings/index.html b/assets/www/settings/index.html index 46fe2ac..f3727ad 100644 --- a/assets/www/settings/index.html +++ b/assets/www/settings/index.html @@ -80,6 +80,45 @@ +
+

Furtka Gateway

+
+

+ 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 Apps page — connecting here only + opens the tunnel, nothing is exposed automatically. +

+
+
+ + +
+
+ + +
+
+ + +
+
+ +
+
+ +

+
+
+

Appearance

@@ -339,6 +377,120 @@ 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' }); diff --git a/furtka/api.py b/furtka/api.py index 1639c3e..f913574 100644 --- a/furtka/api.py +++ b/furtka/api.py @@ -358,6 +358,12 @@ async function refresh() { const openBtn = openHref ? `Open` : ''; + // Filled in async by loadInternetSlots() after this markup lands — + // the toggle's current state needs its own fetch per app + // (/api/apps//internet), so it can't be known at row-build time. + const internetSlot = (a.internet && a.internet.viable) + ? `` + : ''; return `
@@ -369,6 +375,7 @@ async function refresh() {
${openBtn} + ${internetSlot} ${hasSettings ? `` : ''} @@ -393,6 +400,65 @@ async function refresh() { for (const btn of document.querySelectorAll('button[data-op]')) { btn.addEventListener('click', () => handleButton(btn.dataset.op, btn.dataset.name, btn)); } + loadInternetSlots(); +} + +// Populates each installed internet-viable app's "Expose to Internet" / +// "Public …" control. Separate from the main refresh() render because the +// current publish state needs its own per-app fetch — the /api/apps list +// only carries whether an app is *capable* of being exposed, not whether +// it currently is. +async function loadInternetSlots() { + for (const slot of document.querySelectorAll('.internet-slot')) { + const name = slot.dataset.name; + try { + const r = await fetch(`/api/apps/${encodeURIComponent(name)}/internet`); + if (!r.ok) continue; + renderInternetSlot(slot, name, await r.json()); + } catch (e) { + // Leave the slot empty — next refresh() retries. + } + } +} + +function renderInternetSlot(slot, name, data) { + if (data.enabled) { + slot.innerHTML = + `Public ↗` + + ``; + } else { + slot.innerHTML = ``; + } + slot.querySelector('[data-internet]').addEventListener('click', () => handleInternetToggle(name, !!data.enabled)); +} + +async function handleInternetToggle(name, currentlyEnabled) { + const logEl = document.getElementById('log'); + if (currentlyEnabled) { + if (!confirm(`Stop exposing ${name} to the internet?`)) return; + } + let subdomain = null; + if (!currentlyEnabled) { + subdomain = prompt( + `Public hostname for ${name} — needs its own DNS record already pointing at your gateway:` + ); + if (!subdomain) return; + } + try { + const r = await fetch(`/api/apps/${encodeURIComponent(name)}/internet`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(currentlyEnabled ? { enabled: false } : { enabled: true, subdomain }), + }); + const data = await r.json(); + if (!r.ok) { + logEl.textContent = `[internet ${name}] HTTP ${r.status}\\n` + JSON.stringify(data, null, 2); + return; + } + await refresh(); + } catch (e) { + logEl.textContent = `[internet ${name}] Network error: ${e.message}`; + } } async function handleButton(op, name, btn) {