Files
vacuum-wall/webui/static/pages/wireguard.js
T

213 lines
9.7 KiB
JavaScript

import { h, PageHeader, Badge, StatusDot, Empty, Card, esc, enc, att_esc, $val, apiFetch, toast, dismissToast, openModal, closeModal, formModal, definePage, hComp, connect, reactive, requestUpdate, Link, createRouter, ToastContainer, render, parseZones } from '/static/hoover/index.js';
function addPeerModal(state) {
openModal((inner, idx) => {
formModal(inner, 'Add WireGuard Peer',
[
{ label: 'Name', id: 'wg-name', placeholder: 'client-name' },
{ label: 'Endpoint (optional)', id: 'wg-endpoint', placeholder: '1.2.3.4:51820' },
{ label: 'Allowed IPs (optional)', id: 'wg-allowed', placeholder: '10.0.0.2/32' },
{ label: 'Persistent Keepalive (optional)', id: 'wg-keepalive', type: 'number', placeholder: '25' },
],
[
{ label: 'Cancel', cls: 'btn-outline', action: 'c', handler: () => closeModal(idx) },
{
label: 'Add', cls: 'btn-primary', action: 's', handler: async () => {
const body = {
name: ($val('wg-name') || '').trim(),
endpoint: ($val('wg-endpoint') || '').trim() || undefined,
allowed_ips: ($val('wg-allowed') || '').trim() ? [($val('wg-allowed') || '').trim()] : [],
persistent_keepalive: $val('wg-keepalive') ? parseInt($val('wg-keepalive')) : undefined,
};
if (!body.name) { toast('Name is required', 'error'); return; }
const resp = await apiFetch('/api/wireguard/peers', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(body),
});
if (resp.ok) {
toast('Peer added', 'success');
closeModal(idx);
await load(state);
} else {
toast(resp.error || 'Failed', 'error');
}
},
},
],
);
});
}
function downloadConfigModal(peerName, config, state) {
openModal((inner, idx) => {
formModal(inner, 'Download Config for ' + peerName,
[{ label: 'Server Endpoint', id: 'wg-srv-endpoint', placeholder: 'vpn.example.com:51820' }],
[
{ label: 'Cancel', cls: 'btn-outline', action: 'c', handler: () => closeModal(idx) },
{
label: 'Generate', cls: 'btn-primary', action: 's', handler: async () => {
const endpoint = ($val('wg-srv-endpoint') || '').trim();
if (!endpoint) { toast('Server endpoint is required', 'error'); return; }
const resp = await apiFetch('/api/wireguard/generate-client', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ name: peerName, server_endpoint: endpoint }),
});
if (resp.ok && resp.data?.config) {
const blob = new Blob([resp.data.config], { type: 'text/plain' });
const url = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
a.download = peerName + '.conf';
document.body.appendChild(a);
a.click();
document.body.removeChild(a);
URL.revokeObjectURL(url);
toast('Config downloaded', 'success');
closeModal(idx);
} else {
toast(resp.error || 'Failed', 'error');
}
},
},
],
);
});
}
async function load(state) {
try {
const stR = await apiFetch('/api/wireguard/status');
if (stR.ok) state.status = stR.data || {};
const pR = await apiFetch('/api/wireguard/peers');
if (pR.ok) state.peers = pR.data || [];
const cfgR = await apiFetch('/api/wireguard/config');
if (cfgR.ok) state.config = cfgR.data || {};
} catch (e) {
state.error = String(e);
}
state.loading = false;
}
export default definePage({
init() {
return { status: {}, peers: [], config: {}, loading: true, error: null };
},
subscribe: ['wireguard'],
load,
render(state) {
if (state.loading) {
return [
PageHeader({ title: 'WireGuard' }),
h('div', { class: 'card', key: 'loading' },
h('div', { class: 'card-body loading' }, 'Loading...'),
),
];
}
if (state.error) {
return [
PageHeader({ title: 'WireGuard' }),
h('div', { class: 'card', key: 'error' },
h('div', { class: 'card-body error-msg' }, state.error),
),
];
}
const st = state.status || {};
const isUp = st.state === 'up';
const listenPort = (state.config?.interface || {}).listen_port || '-';
const peerRows = state.peers.map(p => {
const hasHandshake = !!p.latest_handshake;
return h('tr', { key: p.name },
h('td', null,
StatusDot({ status: hasHandshake ? 'success' : 'danger' }),
h('strong', null, esc(p.name || 'unnamed')),
),
h('td', { style: 'font-family:monospace;font-size:11px;' },
esc((p.public_key || 'N/A').substring(0, 20)) +
(p.public_key && p.public_key.length > 20 ? '...' : ''),
),
h('td', { class: 'text-sm' }, esc(p.allowed_ips || '-')),
h('td', { class: 'text-sm' }, esc(p.endpoint || '-')),
h('td', { class: 'text-sm' }, esc(p.latest_handshake || 'Never')),
h('td', { class: 'text-sm' },
'Recv: ' + esc(p.transfer_recv || '0'),
h('br'),
'Sent: ' + esc(p.transfer_sent || '0'),
),
h('td', null,
h('button', { class: 'btn btn-sm btn-outline', style: 'margin-right:4px;',
'on:click': () => downloadConfigModal(p.name, state.config, state) }, 'Config'),
h('button', { class: 'btn btn-sm btn-danger',
'on:click': async () => {
if (!confirm('Remove peer ' + p.name + '?')) return;
const resp = await apiFetch('/api/wireguard/peers/' + enc(p.name), { method: 'DELETE' });
if (resp.ok) {
toast('Peer removed', 'success');
await load(state);
} else {
toast(resp.error || 'Failed', 'error');
}
}}, 'Remove'),
),
);
});
const actions = h('div', { style: 'display:flex;gap:8px;' },
h('button', { class: 'btn btn-primary', 'on:click': () => addPeerModal(state) }, 'Add Peer'),
h('button', { class: 'btn btn-outline',
'on:click': async () => {
const resp = await apiFetch('/api/wireguard/' + (isUp ? 'down' : 'up'), { method: 'POST' });
if (resp.ok) {
toast('Tunnel ' + (isUp ? 'stopped' : 'started'), 'success');
await load(state);
} else {
toast(resp.error || 'Failed', 'error');
}
}}, isUp ? 'Stop' : 'Start'),
h('button', { class: 'btn btn-outline',
'on:click': async () => {
const resp = await apiFetch('/api/wireguard/apply', { method: 'POST' });
if (resp.ok) {
toast('Config applied', 'success');
await load(state);
} else {
toast(resp.error || 'Failed', 'error');
}
}}, 'Apply'),
);
return [
PageHeader({
title: 'WireGuard',
subtitle: 'Tunnel: ' + (st.state || 'unknown') + ', Listen: ' + listenPort,
actions,
}),
h('div', null,
StatusDot({ status: isUp ? 'success' : 'danger' }),
' ',
Badge({ text: st.state || 'down', variant: isUp ? 'success' : 'danger' }),
),
peerRows.length
? h('div', { class: 'card' }, h('table', { class: 'table' },
h('thead', null,
h('tr', null,
h('th', null, 'Peer'),
h('th', null, 'Public Key'),
h('th', null, 'Allowed IPs'),
h('th', null, 'Endpoint'),
h('th', null, 'Handshake'),
h('th', null, 'Transfer'),
h('th', { style: 'width:120px;' }, 'Actions'),
),
),
h('tbody', null, ...peerRows),
))
: Empty({ text: 'No peers configured. Add a peer above.' }),
];
},
});