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:
parent
91f2d6304c
commit
f88412d9e8
2 changed files with 219 additions and 1 deletions
|
|
@ -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' });
|
||||||
|
|
|
||||||
|
|
@ -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) {
|
||||||
|
|
|
||||||
Loading…
Add table
Reference in a new issue