b6efcff25e
wg_iface.removePeer() previously just did `wg set ... remove` -- the kernel routes setPeer() adds for a peer's AllowedIPs (since wg itself only configures crypto-routing, not kernel routes -- see setPeer's own comment) were never cleaned up, a real TODO flagged in code but never exercised because nothing removed a mesh peer at all. - removePeer() now queries the peer's current AllowedIPs (`wg show <iface> allowed-ips`) BEFORE removing it -- once gone, wg no longer knows what to clean up -- and issues `ip route del` for each. - New DELETE /api/mesh/gateways/:id (models/mesh_gateway.js gained remove()) actually calls removePeer(), so the fix has a real caller; previously there was no removal path anywhere in the mesh feature at all. Refuses to remove the local "(self)" entry. Does not reach out to the remote gateway to remove the reciprocal peer -- that side needs the same action taken independently. - Mesh UI: remove button per non-self peer row, using app.messages.confirm (not native confirm() -- caught by this repo's own no-native-dialogs test, which failed on first pass and is now green). Verified for real with a live WireGuard interface in a container: routes for a peer's AllowedIPs present after setPeer, confirmed gone after removePeer, while the interface's own local route correctly survives.
168 lines
7.7 KiB
Plaintext
168 lines
7.7 KiB
Plaintext
<%- include('top') %>
|
|
<script type="text/javascript">app.auth.forceLogin();</script>
|
|
|
|
<div class="container mt-4 mb-5">
|
|
<div class="d-flex align-items-center justify-content-between mb-4">
|
|
<div>
|
|
<h3 class="mb-1"><i class="fa-solid fa-diagram-project text-primary me-2"></i>Gateway Mesh</h3>
|
|
<p class="text-muted small mb-0">Site-to-site WireGuard tunnels between theta-gateway instances. Different from <a href="/wireguard">WireGuard</a>, which manages individual roaming-client peers and exit nodes.</p>
|
|
</div>
|
|
</div>
|
|
|
|
<div class="card shadow-sm mb-4">
|
|
<div class="card-header bg-dark text-light border-secondary">
|
|
<i class="fa-solid fa-server me-2"></i>This Gateway
|
|
</div>
|
|
<div class="card-body">
|
|
<div class="row g-3">
|
|
<div class="col-md-4">
|
|
<div class="p-3 border rounded bg-light dark-bg-subtle">
|
|
<div class="text-muted small fw-semibold">MESH INTERFACE</div>
|
|
<div class="font-monospace fw-bold text-primary mt-1" id="mesh-iface">Loading...</div>
|
|
</div>
|
|
</div>
|
|
<div class="col-md-4">
|
|
<div class="p-3 border rounded bg-light dark-bg-subtle">
|
|
<div class="text-muted small fw-semibold">WIREGUARD MODE</div>
|
|
<div class="fw-bold mt-1" id="mesh-kernel-mode">Loading...</div>
|
|
</div>
|
|
</div>
|
|
<div class="col-md-4">
|
|
<div class="p-3 border rounded bg-light dark-bg-subtle">
|
|
<div class="text-muted small fw-semibold">MESHED GATEWAYS</div>
|
|
<div class="fw-bold mt-1" id="mesh-gateway-count">Loading...</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<div class="row g-4">
|
|
<div class="col-md-6">
|
|
<div class="card shadow-sm h-100">
|
|
<div class="card-header"><i class="fa-solid fa-key me-2"></i>Mint a Join Token</div>
|
|
<div class="card-body">
|
|
<p class="small text-muted">Give this to a new gateway so it can join this one's mesh (single-use, expires in 15 minutes). It calls this gateway's <code>/api/mesh/register</code> with it.</p>
|
|
<button class="btn btn-sm btn-success" onclick="mintMeshJoinToken()"><i class="fa-solid fa-plus me-1"></i> Mint Join Token</button>
|
|
<div id="mesh-join-token-result" class="mt-2"></div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
<div class="col-md-6">
|
|
<div class="card shadow-sm h-100">
|
|
<div class="card-header"><i class="fa-solid fa-right-to-bracket me-2"></i>Join a Remote Gateway's Mesh</div>
|
|
<div class="card-body">
|
|
<p class="small text-muted">Have a join token from another gateway? Use it here to mesh THIS gateway into that one.</p>
|
|
<div class="mb-2">
|
|
<input type="text" id="mesh-remote-endpoint" class="form-control form-control-sm" placeholder="Remote gateway URL (e.g. https://jump.master.example.com)">
|
|
</div>
|
|
<div class="mb-2">
|
|
<input type="text" id="mesh-remote-token" class="form-control form-control-sm font-monospace" placeholder="Join token (mjt_...)">
|
|
</div>
|
|
<button class="btn btn-sm btn-primary" onclick="joinRemoteMesh()"><i class="fa-solid fa-link me-1"></i> Join</button>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<div class="card shadow-sm mt-4">
|
|
<div class="card-header"><i class="fa-solid fa-network-wired me-2"></i>Meshed Gateways</div>
|
|
<div class="card-body p-0" id="mesh-gateways-wrap">
|
|
<div class="text-center py-4 text-muted">Loading...</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<%- include('bottom') %>
|
|
<script type="text/javascript">
|
|
function esc(s) {
|
|
return String(s||'').replace(/&/g,'&').replace(/</g,'<').replace(/>/g,'>').replace(/"/g,'"');
|
|
}
|
|
|
|
$(document).ready(function(){
|
|
loadMeshStatus();
|
|
});
|
|
|
|
function loadMeshStatus() {
|
|
app.api.get('mesh/gateways', function(err, data){
|
|
if (err || !data) {
|
|
$('#mesh-iface').text('(unavailable)');
|
|
$('#mesh-kernel-mode').text('(unavailable)');
|
|
$('#mesh-gateway-count').text('—');
|
|
$('#mesh-gateways-wrap').html('<div class="text-center py-4 text-danger">Could not load mesh status: ' + esc((err && err.message) || 'unknown error') + '</div>');
|
|
return;
|
|
}
|
|
$('#mesh-iface').text(data.iface || 'wg-mesh');
|
|
$('#mesh-kernel-mode').html(data.kernelWireguard
|
|
? '<span class="badge bg-success"><i class="fa-solid fa-microchip me-1"></i> In-kernel</span>'
|
|
: '<span class="badge bg-warning text-dark"><i class="fa-solid fa-layer-group me-1"></i> Userspace (wireguard-go)</span>');
|
|
var gateways = data.gateways || [];
|
|
$('#mesh-gateway-count').text(gateways.length + ' gateway' + (gateways.length === 1 ? '' : 's'));
|
|
renderGatewaysTable(gateways);
|
|
});
|
|
}
|
|
|
|
function renderGatewaysTable(gateways) {
|
|
var $w = $('#mesh-gateways-wrap');
|
|
if (!gateways.length) {
|
|
$w.html('<div class="text-center py-4 text-muted"><i class="fa-solid fa-diagram-project me-2"></i>No meshed gateways yet.<br><small>Mint a join token above and have another gateway join, or join this one into a remote gateway\'s mesh.</small></div>');
|
|
return;
|
|
}
|
|
var html = '<table class="table table-striped table-hover mb-0 align-middle"><thead><tr>'
|
|
+ '<th>Site</th><th>Mesh Index</th><th>Mesh Address</th><th>Endpoint</th><th>Public Key</th><th>Last Seen</th><th class="text-end">Actions</th>'
|
|
+ '</tr></thead><tbody>';
|
|
gateways.forEach(function(g){
|
|
var isSelf = g.siteSlug === '(self)';
|
|
html += '<tr' + (isSelf ? ' class="table-active"' : '') + '>'
|
|
+ '<td>' + (isSelf ? '<em>This gateway</em>' : esc(g.siteSlug || '(unlabeled)')) + '</td>'
|
|
+ '<td><span class="badge bg-primary">' + esc(g.meshIndex) + '</span></td>'
|
|
+ '<td><code class="small">172.24.' + esc(g.meshIndex) + '.0/24</code></td>'
|
|
+ '<td><code class="small text-primary">' + esc(g.endpoint || '—') + '</code></td>'
|
|
+ '<td><code class="small text-truncate d-inline-block" style="max-width:220px;" title="' + esc(g.publicKey) + '">' + esc(g.publicKey) + '</code></td>'
|
|
+ '<td class="small text-muted">' + (g.lastSeenAt ? new Date(Number(g.lastSeenAt)).toLocaleString() : '—') + '</td>'
|
|
+ '<td class="text-end">' + (isSelf ? '' :
|
|
'<button class="btn btn-sm btn-outline-danger" onclick="removeMeshGateway(\'' + esc(g.id) + '\', \'' + esc(g.siteSlug || g.id) + '\')" title="Remove peer"><i class="fa-solid fa-trash"></i></button>')
|
|
+ '</td>'
|
|
+ '</tr>';
|
|
});
|
|
html += '</tbody></table>';
|
|
$w.html(html);
|
|
}
|
|
|
|
async function removeMeshGateway(id, label) {
|
|
var ok = await app.messages.confirm('Remove mesh peer "' + label + '"? This tears down the local WireGuard peer + routes. The other side keeps its half until removed there too.', $('#mesh-gateways-wrap'), 'danger');
|
|
if (!ok) return;
|
|
app.api.delete('mesh/gateways/' + id, function(err){
|
|
if (err) return app.messages.toast('Failed to remove gateway: ' + err.message, 'danger');
|
|
app.messages.toast('Gateway removed', 'success');
|
|
loadMeshStatus();
|
|
});
|
|
}
|
|
|
|
function mintMeshJoinToken() {
|
|
app.api.post('mesh/join-tokens', {}, function(err, data){
|
|
if (err) return app.messages.toast('Failed to mint join token: ' + err.message, 'danger');
|
|
$('#mesh-join-token-result').html(
|
|
'<div class="alert alert-success small mb-0">' +
|
|
'<strong>Shown once — copy it now:</strong><br>' +
|
|
'<code class="user-select-all">' + esc(data.token) + '</code>' +
|
|
'<br><span class="text-muted">Expires in ' + Math.round((data.expiresInSeconds || 0) / 60) + ' minutes.</span>' +
|
|
'</div>'
|
|
);
|
|
});
|
|
}
|
|
|
|
function joinRemoteMesh() {
|
|
var remoteEndpoint = ($('#mesh-remote-endpoint').val() || '').trim();
|
|
var joinToken = ($('#mesh-remote-token').val() || '').trim();
|
|
if (!remoteEndpoint || !joinToken) {
|
|
return app.messages.toast('Enter the remote gateway URL and a join token', 'warning');
|
|
}
|
|
app.api.post('mesh/join', { remoteEndpoint: remoteEndpoint, joinToken: joinToken }, function(err, data){
|
|
if (err) return app.messages.toast('Join failed: ' + err.message, 'danger');
|
|
app.messages.toast('Meshed successfully — this gateway is mesh index ' + data.meshIndex + ', peer is index ' + data.peerMeshIndex, 'success');
|
|
loadMeshStatus();
|
|
});
|
|
}
|
|
</script>
|