222 lines
10 KiB
JavaScript
222 lines
10 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, abortController, entry) {
|
|
if (state.peers?.length) state.refreshing = true;
|
|
else state.loading = true;
|
|
try {
|
|
const myId = entry ? entry.requestId : 0;
|
|
const sig = abortController?.signal;
|
|
const stR = await apiFetch('/api/wireguard/status', { signal: sig });
|
|
if (abortController?.signal.aborted || (entry && entry.requestId !== myId)) return;
|
|
if (stR.ok) state.status = stR.data || {};
|
|
const pR = await apiFetch('/api/wireguard/peers', { signal: sig });
|
|
if (abortController?.signal.aborted || (entry && entry.requestId !== myId)) return;
|
|
if (pR.ok) state.peers = pR.data || [];
|
|
const cfgR = await apiFetch('/api/wireguard/config', { signal: sig });
|
|
if (abortController?.signal.aborted || (entry && entry.requestId !== myId)) return;
|
|
if (cfgR.ok) state.config = cfgR.data || {};
|
|
} catch (e) {
|
|
if (abortController?.signal.aborted) return;
|
|
state.error = String(e);
|
|
}
|
|
state.loading = false;
|
|
state.refreshing = false;
|
|
}
|
|
|
|
export default definePage({
|
|
init() {
|
|
return { status: {}, peers: [], config: {}, loading: true, refreshing: false, error: null };
|
|
},
|
|
subscribe: ['wireguard'],
|
|
load,
|
|
render(state) {
|
|
if (state.loading && !state.refreshing) {
|
|
return [
|
|
PageHeader({ title: 'WireGuard' }),
|
|
h('div', { class: 'card', key: 'loading' },
|
|
h('div', { class: 'card-body loading' }, state.refreshing ? 'Refreshing...' : '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.' }),
|
|
];
|
|
},
|
|
});
|