1d94b1a9e9
- Auto-refresh: the 60s reload fires only while a live tab is active
(nav data-live-tabs="overview,applications,events,host") AND no form is
dirty (delegated input/change listener; never reset — a reload clears it).
Skipped ticks reschedule; a muted (paused) hint shows next to the toggle on
non-live tabs / dirty forms. Toggle, localStorage key, 60s cadence, and
default-on behavior unchanged. The refresh script resolves the legacy
settings→edit hash alias like the tabs script.
- Rider 4a: every template's stylesheet link is /style.css?v={{hubVersion}}
(the v0.47.0 gotcha: max-age=3600 served stale styling for up to an hour
after a deploy). Red-proof run: a reverted bare link fails the test.
- Rider 4b: CLAUDE.md standing rule — never git add -A in this repo
(the 146d165 sweep incident); explicit paths + pull-rebase + one writing
session per clone.
- Tests: Group C structural pins (attribute read, dirty listeners, alias x2,
hint element, cadence/key survivors) + Group D cache-bust over six pages.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01TZc5w5jDhFLv6qDC32KN5v
220 lines
13 KiB
HTML
220 lines
13 KiB
HTML
<!DOCTYPE html>
|
|
<html lang="en">
|
|
<head>
|
|
<meta charset="UTF-8">
|
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
|
<title>Offsite — Felhom Hub</title>
|
|
<link rel="stylesheet" href="/style.css?v={{hubVersion}}">
|
|
</head>
|
|
<body>
|
|
{{template "icon_sprite"}}
|
|
<div class="container">
|
|
<header>
|
|
<h1>Felhom <span>Hub</span></h1>
|
|
<nav class="nav-links">
|
|
<a href="/" class="nav-link">Dashboard</a>
|
|
<a href="/configs" class="nav-link">Customers</a>
|
|
<a href="/apps" class="nav-link">Apps</a>
|
|
<a href="/hosts" class="nav-link">Hosts</a>
|
|
<a href="/offsite" class="nav-link active">Offsite</a>
|
|
<a href="/configuration" class="nav-link">Configuration</a>
|
|
</nav>
|
|
</header>
|
|
|
|
<h2 style="margin-bottom: 0.5rem;">Offsite connectivity</h2>
|
|
<p class="text-muted" style="margin: 0 0 1rem; font-size: 0.85em;">
|
|
Peer allocation and endpoint sync currently use the lowest endpoint id (ep0).
|
|
Per-endpoint allocation is a future work item.
|
|
</p>
|
|
|
|
{{if eq .Flash "endpoint_saved"}}
|
|
<div class="flash flash-success">Endpoint saved.</div>
|
|
{{end}}
|
|
{{if eq .Flash "endpoint_deleted"}}
|
|
<div class="flash flash-success">Endpoint deleted.</div>
|
|
{{end}}
|
|
|
|
{{if .HasEndpoints}}
|
|
{{range .Endpoints}}
|
|
<section class="card" style="margin-bottom: 1.5rem;"
|
|
data-endpoint-id="{{.EndpointID}}" data-dns-name="{{.DNSName}}" data-wg-port="{{.WGPort}}"
|
|
data-server-pubkey="{{.ServerPubkey}}" data-tunnel-subnet="{{.TunnelSubnet}}" data-pbs-tunnel-ip="{{.PBSTunnelIP}}">
|
|
<div style="display: flex; justify-content: space-between; align-items: center; flex-wrap: wrap; gap: 0.5rem;">
|
|
<h3 style="margin: 0;">Endpoint <code>{{.EndpointID}}</code></h3>
|
|
<div style="display: flex; gap: 0.5rem;">
|
|
<button type="button" class="btn btn-outline btn-sm" onclick="epEdit('{{.EndpointID}}')">Edit</button>
|
|
<button type="button" class="btn btn-danger btn-sm" onclick="epDeleteConfirm('{{.EndpointID}}')">Remove…</button>
|
|
</div>
|
|
</div>
|
|
<table class="detail-table" style="margin-top: 0.75rem;">
|
|
<tr><th>Address</th><td><code>{{.DNSName}}:{{.WGPort}}</code> (WireGuard, UDP)</td></tr>
|
|
<tr><th>Server public key</th><td><code title="{{.ServerPubkey}}">{{.ServerPubkeyShort}}</code></td></tr>
|
|
<tr><th>Tunnel subnet</th><td><code>{{.TunnelSubnet}}</code></td></tr>
|
|
<tr><th>PBS tunnel address</th><td><code>{{.PBSTunnelIP}}:8007</code></td></tr>
|
|
<tr><th>Peers in subnet</th><td>{{.PeerCount}}</td></tr>
|
|
</table>
|
|
<div id="ep-delete-confirm-{{.EndpointID}}" style="display: none; margin-top: 0.75rem; padding: 0.75rem; border: 1px solid var(--crit); background: var(--crit-dim); border-radius: var(--radius); max-width: 44em;">
|
|
<p style="margin: 0 0 0.5rem; font-size: 0.85em;">
|
|
Deleting <code>{{.EndpointID}}</code> removes only the endpoint record — registered peers stay.
|
|
Deletion is refused while any peer is allocated in its subnet. Type the endpoint id to confirm:
|
|
</p>
|
|
<form method="POST" action="/offsite/endpoints/{{.EndpointID}}/delete" id="ep-delete-form-{{.EndpointID}}" style="display: flex; gap: 0.5rem; align-items: center; flex-wrap: wrap;">
|
|
<input type="hidden" name="_csrf" value="{{$.CSRFToken}}">
|
|
<input type="text" id="ep-delete-input-{{.EndpointID}}" placeholder="retype the endpoint id…" style="padding: 0.3em 0.5em; width: 12em;">
|
|
<button type="button" class="btn btn-danger btn-sm" onclick="epDeleteSubmit('{{.EndpointID}}')">Confirm & delete</button>
|
|
<button type="button" class="btn btn-sm btn-outline" onclick="document.getElementById('ep-delete-confirm-{{.EndpointID}}').style.display='none';">Cancel</button>
|
|
<span id="ep-delete-err-{{.EndpointID}}" style="font-size: 0.8em; color: var(--crit);"></span>
|
|
</form>
|
|
</div>
|
|
</section>
|
|
{{end}}
|
|
{{else}}
|
|
<section class="card" style="margin-bottom: 1.5rem;">
|
|
<h3>Endpoint</h3>
|
|
<p class="text-muted">Not configured. Add one below (or via <code>PUT /api/v1/admin/wg/endpoint</code>, runbook: offsite-endpoint.md).</p>
|
|
</section>
|
|
{{end}}
|
|
|
|
<!-- Add / edit endpoint (v0.47.0). Plain form post — server-side validation is
|
|
authoritative; the JS layer only adds the pubkey-change type-to-confirm. -->
|
|
<section class="card" style="margin-bottom: 1.5rem;">
|
|
<h3 id="ep-form-title">Add endpoint</h3>
|
|
<form method="POST" action="/offsite/endpoints" id="ep-form" onsubmit="return epFormSubmitCheck()"
|
|
style="display: grid; grid-template-columns: auto 1fr; gap: 0.5rem; align-items: center; max-width: 44em; margin-top: 0.75rem;">
|
|
<input type="hidden" name="_csrf" value="{{.CSRFToken}}">
|
|
<label style="font-size: 0.9em;">Endpoint id</label>
|
|
<input type="text" name="endpoint_id" id="ep-id" placeholder="ep1" style="padding: 0.3em 0.5em;">
|
|
<label style="font-size: 0.9em;">DNS name</label>
|
|
<input type="text" name="dns_name" id="ep-dns" placeholder="ep1.felhom.eu" style="padding: 0.3em 0.5em;">
|
|
<label style="font-size: 0.9em;">WG port</label>
|
|
<input type="number" name="wg_port" id="ep-port" min="1" max="65535" placeholder="443" style="padding: 0.3em 0.5em;">
|
|
<label style="font-size: 0.9em;">Server public key</label>
|
|
<input type="text" name="server_pubkey" id="ep-pubkey" placeholder="base64 WireGuard pubkey" style="padding: 0.3em 0.5em; font-family: var(--font-data);">
|
|
<label style="font-size: 0.9em;">Tunnel subnet</label>
|
|
<input type="text" name="tunnel_subnet" id="ep-subnet" placeholder="10.78.0.0/24" style="padding: 0.3em 0.5em;">
|
|
<label style="font-size: 0.9em;">PBS tunnel IP</label>
|
|
<input type="text" name="pbs_tunnel_ip" id="ep-pbsip" placeholder="10.78.0.1 (inside the subnet)" style="padding: 0.3em 0.5em;">
|
|
<span></span>
|
|
<div style="display: flex; gap: 0.5rem; align-items: center;">
|
|
<button type="submit" class="btn btn-sm">Save endpoint</button>
|
|
<button type="button" class="btn btn-sm btn-outline" id="ep-form-reset" style="display: none;" onclick="epFormReset()">Cancel edit</button>
|
|
</div>
|
|
</form>
|
|
<div id="ep-pubkey-confirm" style="display: none; margin-top: 0.75rem; padding: 0.75rem; border: 1px solid var(--warn); background: var(--warn-dim); border-radius: var(--radius); max-width: 44em;">
|
|
<p style="margin: 0 0 0.5rem; font-size: 0.85em;">
|
|
The server public key is CHANGING. Peers keep using the old key until they pull their
|
|
next desired-state (they converge on their own cycle — no push). Type the endpoint id to confirm:
|
|
</p>
|
|
<input type="text" id="ep-pubkey-confirm-input" placeholder="retype the endpoint id…" style="padding: 0.3em 0.5em; width: 12em;">
|
|
<button type="button" class="btn btn-sm" onclick="epPubkeyConfirmSubmit()">Confirm & save</button>
|
|
<button type="button" class="btn btn-sm btn-outline" onclick="document.getElementById('ep-pubkey-confirm').style.display='none';">Cancel</button>
|
|
<p id="ep-pubkey-confirm-err" style="margin: 0.4em 0 0; font-size: 0.8em; color: var(--crit);"></p>
|
|
</div>
|
|
</section>
|
|
|
|
{{if .Peers}}
|
|
<section class="card" style="padding: 0; overflow: hidden;">
|
|
<table class="data-table">
|
|
<thead>
|
|
<tr>
|
|
<th>Public key</th>
|
|
<th>Assigned IP</th>
|
|
<th>Endpoint</th>
|
|
<th>Host</th>
|
|
<th>Note</th>
|
|
<th>Created</th>
|
|
</tr>
|
|
</thead>
|
|
<tbody>
|
|
{{range .Peers}}
|
|
<tr>
|
|
<td><code title="{{.Pubkey}}">{{.PubkeyShort}}</code></td>
|
|
<td><code>{{.AssignedIP}}</code></td>
|
|
<td>{{if .EndpointID}}<code>{{.EndpointID}}</code>{{else}}—{{end}}</td>
|
|
<td>{{if .HostID}}<a href="/hosts/{{.HostID}}">{{.HostID}}</a>{{else}}—{{end}}</td>
|
|
<td>{{if .Note}}{{.Note}}{{else}}—{{end}}</td>
|
|
<td>{{.CreatedAt}}</td>
|
|
</tr>
|
|
{{end}}
|
|
</tbody>
|
|
</table>
|
|
</section>
|
|
{{else}}
|
|
<div class="empty-state">
|
|
<p>No WireGuard peers registered.</p>
|
|
<p class="hint">A peer appears here when a box registers via <code>POST /hosts/<id>/wg</code> or the operator adds one via the admin API.</p>
|
|
</div>
|
|
{{end}}
|
|
|
|
<footer style="margin-top: 2rem; color: var(--text-muted); font-size: 0.8rem; text-align: center;">
|
|
Felhom Hub <span style="font-family: var(--font-mono)">{{hubVersion}}</span>
|
|
</footer>
|
|
</div>
|
|
|
|
<script>
|
|
// Endpoint management JS (v0.47.0). The server enforces every guard — this layer only
|
|
// fills the edit form from a card's data attributes and adds the pubkey-change confirm.
|
|
var epOriginalPubkey = null; // non-null = editing an existing endpoint
|
|
|
|
function epEdit(id) {
|
|
var card = document.querySelector('[data-endpoint-id="' + id + '"]');
|
|
if (!card) return;
|
|
document.getElementById('ep-form-title').textContent = 'Edit endpoint ' + id;
|
|
var f = {
|
|
'ep-id': 'data-endpoint-id', 'ep-dns': 'data-dns-name', 'ep-port': 'data-wg-port',
|
|
'ep-pubkey': 'data-server-pubkey', 'ep-subnet': 'data-tunnel-subnet', 'ep-pbsip': 'data-pbs-tunnel-ip'
|
|
};
|
|
for (var el in f) { document.getElementById(el).value = card.getAttribute(f[el]) || ''; }
|
|
// endpoint_id is the primary key — immutable while editing.
|
|
document.getElementById('ep-id').readOnly = true;
|
|
epOriginalPubkey = card.getAttribute('data-server-pubkey') || '';
|
|
document.getElementById('ep-form-reset').style.display = 'inline-block';
|
|
document.getElementById('ep-form').scrollIntoView({behavior: 'smooth', block: 'center'});
|
|
}
|
|
|
|
function epFormReset() {
|
|
document.getElementById('ep-form').reset();
|
|
document.getElementById('ep-form-title').textContent = 'Add endpoint';
|
|
document.getElementById('ep-id').readOnly = false;
|
|
document.getElementById('ep-id').value = '';
|
|
epOriginalPubkey = null;
|
|
document.getElementById('ep-form-reset').style.display = 'none';
|
|
document.getElementById('ep-pubkey-confirm').style.display = 'none';
|
|
}
|
|
|
|
function epFormSubmitCheck() {
|
|
// Editing + pubkey changed → intercept with the type-to-confirm (floor pattern).
|
|
if (epOriginalPubkey !== null && document.getElementById('ep-pubkey').value.trim() !== epOriginalPubkey) {
|
|
document.getElementById('ep-pubkey-confirm-input').value = '';
|
|
document.getElementById('ep-pubkey-confirm-err').textContent = '';
|
|
document.getElementById('ep-pubkey-confirm').style.display = 'block';
|
|
return false;
|
|
}
|
|
return true;
|
|
}
|
|
|
|
function epPubkeyConfirmSubmit() {
|
|
var typed = document.getElementById('ep-pubkey-confirm-input').value.trim();
|
|
var expected = document.getElementById('ep-id').value.trim();
|
|
var err = document.getElementById('ep-pubkey-confirm-err');
|
|
if (typed !== expected) { err.textContent = 'Confirmation does not match the endpoint id.'; return; }
|
|
document.getElementById('ep-form').submit();
|
|
}
|
|
|
|
function epDeleteConfirm(id) {
|
|
var box = document.getElementById('ep-delete-confirm-' + id);
|
|
document.getElementById('ep-delete-input-' + id).value = '';
|
|
document.getElementById('ep-delete-err-' + id).textContent = '';
|
|
box.style.display = 'block';
|
|
}
|
|
|
|
function epDeleteSubmit(id) {
|
|
var typed = document.getElementById('ep-delete-input-' + id).value.trim();
|
|
var err = document.getElementById('ep-delete-err-' + id);
|
|
if (typed !== id) { err.textContent = 'Confirmation does not match the endpoint id.'; return; }
|
|
document.getElementById('ep-delete-form-' + id).submit();
|
|
}
|
|
</script>
|
|
</body>
|
|
</html>
|