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.
This commit is contained in:
Robert Syrnicki 2026-08-24 14:57:11 +02:00
parent 91f2d6304c
commit f88412d9e8
2 changed files with 219 additions and 1 deletions

View file

@ -80,6 +80,45 @@
</div> </div>
</section> </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> <section>
<h2>Appearance</h2> <h2>Appearance</h2>
<div class="card"> <div class="card">
@ -112,7 +151,6 @@
<a href="https://furtka.org/#planned">Change hostname</a> <a href="https://furtka.org/#planned">Change hostname</a>
<a href="https://furtka.org/#planned">Backup</a> <a href="https://furtka.org/#planned">Backup</a>
<a href="https://furtka.org/#planned">User accounts</a> <a href="https://furtka.org/#planned">User accounts</a>
<a href="https://furtka.org/#planned">Remote access</a>
</div> </div>
</section> </section>
@ -339,6 +377,120 @@
await refreshHttpsStatus(); 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() { 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' });

View file

@ -358,6 +358,12 @@ async function refresh() {
const openBtn = openHref const openBtn = openHref
? `<a class="btn" href="${esc(openHref)}" target="_blank" rel="noopener">Open</a>` ? `<a class="btn" href="${esc(openHref)}" target="_blank" rel="noopener">Open</a>`
: ''; : '';
// Filled in async by loadInternetSlots() after this markup lands —
// the toggle's current state needs its own fetch per app
// (/api/apps/<name>/internet), so it can't be known at row-build time.
const internetSlot = (a.internet && a.internet.viable)
? `<span class="internet-slot" data-name="${esc(a.name)}"></span>`
: '';
return ` return `
<div class="app"> <div class="app">
<div class="left"> <div class="left">
@ -369,6 +375,7 @@ async function refresh() {
</div> </div>
<div class="buttons"> <div class="buttons">
${openBtn} ${openBtn}
${internetSlot}
${hasSettings ? `<button data-op="edit" data-name="${esc(a.name)}">Settings</button>` : ''} ${hasSettings ? `<button data-op="edit" data-name="${esc(a.name)}">Settings</button>` : ''}
<button class="secondary" data-op="update" data-name="${esc(a.name)}">Update</button> <button class="secondary" data-op="update" data-name="${esc(a.name)}">Update</button>
<button class="secondary" data-op="reinstall" data-name="${esc(a.name)}">Reinstall</button> <button class="secondary" data-op="reinstall" data-name="${esc(a.name)}">Reinstall</button>
@ -393,6 +400,65 @@ async function refresh() {
for (const btn of document.querySelectorAll('button[data-op]')) { for (const btn of document.querySelectorAll('button[data-op]')) {
btn.addEventListener('click', () => handleButton(btn.dataset.op, btn.dataset.name, btn)); 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 =
`<a class="btn" href="${esc(data.public_url)}" target="_blank" rel="noopener">Public ↗</a>` +
`<button class="secondary" data-internet="${esc(name)}">Stop exposing</button>`;
} else {
slot.innerHTML = `<button class="secondary" data-internet="${esc(name)}">Expose to Internet</button>`;
}
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) { async function handleButton(op, name, btn) {