dev_gateway #22
2 changed files with 219 additions and 1 deletions
|
|
@ -80,6 +80,45 @@
|
|||
</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">
|
||||
|
|
@ -112,7 +151,6 @@
|
|||
<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>
|
||||
<a href="https://furtka.org/#planned">Remote access</a>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
|
|
@ -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' });
|
||||
|
|
|
|||
|
|
@ -358,6 +358,12 @@ async function refresh() {
|
|||
const openBtn = openHref
|
||||
? `<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 `
|
||||
<div class="app">
|
||||
<div class="left">
|
||||
|
|
@ -369,6 +375,7 @@ async function refresh() {
|
|||
</div>
|
||||
<div class="buttons">
|
||||
${openBtn}
|
||||
${internetSlot}
|
||||
${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="reinstall" data-name="${esc(a.name)}">Reinstall</button>
|
||||
|
|
@ -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 =
|
||||
`<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) {
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue