VPN: WireGuard-/OpenVPN-Konfigurationen verwalten, Hosts zuordnen, automatischer Aufbau vor dem Verbinden (Linux: NetworkManager, Windows: WireGuard-Dienst/OpenVPN GUI)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
+99
-2
@@ -148,6 +148,7 @@ async function reload() {
|
||||
b.title = S.vault.encrypted ? T('Data is encrypted with the system keyring.') : T('No system keyring available (e.g. install gnome-keyring/kwallet).');
|
||||
applyAppTheme();
|
||||
S.activeForwards = new Set(await call('fw:active'));
|
||||
if (S.vault.vpns.length) S.vpnStatus = await call('vpn:status').catch(() => ({}));
|
||||
render();
|
||||
}
|
||||
|
||||
@@ -272,7 +273,7 @@ function renderTree() {
|
||||
|
||||
function render() {
|
||||
const page = $('#homeView');
|
||||
const views = { hosts: viewHosts, sftp: viewSftpPicker, keys: viewKeys, forwards: viewForwards, snippets: viewSnippets, known: viewKnown, history: viewHistory, settings: viewSettings };
|
||||
const views = { hosts: viewHosts, sftp: viewSftpPicker, keys: viewKeys, forwards: viewForwards, vpns: viewVpns, snippets: viewSnippets, known: viewKnown, history: viewHistory, settings: viewSettings };
|
||||
const scroll = page.scrollTop;
|
||||
page.innerHTML = '';
|
||||
views[S.view](page);
|
||||
@@ -533,6 +534,7 @@ function editHost(hst = {}) {
|
||||
row(field(T('Address'), 'address', hst.address, { placeholder: T('IP or hostname') }), portField),
|
||||
field(T('Label'), 'label', hst.label, { placeholder: T('My server') }),
|
||||
field(T('Group'), 'groupId', hst.groupId, { type: 'select', options: groupOptions() }),
|
||||
...(S.vault.vpns.length ? [field(T('VPN'), 'vpnId', hst.vpnId, { type: 'select', options: [['', T('— No VPN —')], ...S.vault.vpns.map((v) => [v.id, v.label])], hint: T('Connected automatically before connecting to this host.') })] : []),
|
||||
field('Tags', 'tags', (hst.tags || []).join(', '), { placeholder: 'prod, web, db' }),
|
||||
heading(T('Authentication')),
|
||||
field(T('Username'), 'username', hst.username, { placeholder: 'root' }),
|
||||
@@ -548,7 +550,7 @@ function editHost(hst = {}) {
|
||||
address: v.address.trim(),
|
||||
port: v.port ? Number(v.port) : undefined,
|
||||
tags: v.tags.split(',').map((t) => t.trim()).filter(Boolean),
|
||||
groupId: v.groupId || null, keyId: v.keyId || null, jumpHostId: v.jumpHostId || null,
|
||||
groupId: v.groupId || null, keyId: v.keyId || null, jumpHostId: v.jumpHostId || null, vpnId: v.vpnId || null,
|
||||
};
|
||||
await call('vault:upsert', 'hosts', item);
|
||||
reload();
|
||||
@@ -729,6 +731,100 @@ function editForward(f = {}) {
|
||||
});
|
||||
}
|
||||
|
||||
// ============================================================ VPN (WireGuard / OpenVPN)
|
||||
S.vpnStatus = {};
|
||||
const VPN_TYPES = { wireguard: 'WireGuard', openvpn: 'OpenVPN' };
|
||||
|
||||
function viewVpns(page) {
|
||||
page.append(toolbar(T('Search VPNs …'), [
|
||||
{ label: T('Import'), icon: 'download', run: () => editVpn({}, true) },
|
||||
{ label: T('New VPN'), icon: 'plus', cls: 'primary', run: () => editVpn() },
|
||||
]));
|
||||
const c = h('<div class="content"></div>'); page.append(c);
|
||||
const list = S.vault.vpns.filter((v) => matches(v.label, VPN_TYPES[v.type]));
|
||||
if (!list.length) return c.append(emptyState('shield', T('No VPNs'), T('Add WireGuard or OpenVPN configurations and assign them to hosts. MrTerm connects the VPN automatically when you open such a host.'), T('New VPN'), () => editVpn()));
|
||||
const l = h('<div class="list"></div>');
|
||||
for (const v of list) {
|
||||
const on = !!S.vpnStatus[v.id];
|
||||
const n = S.vault.hosts.filter((x) => x.vpnId === v.id).length;
|
||||
const card = h(`<div class="card"><div class="avatar" style="background:var(--icon-bg);color:${on ? 'var(--green)' : 'var(--muted)'}">${ICONS.shield}</div><div class="meta"><div class="title">${esc(v.label)} <span class="status-pill ${on ? 'on' : ''}">${on ? T('connected') : T('disconnected')}</span></div><div class="sub">${VPN_TYPES[v.type] || v.type} · ${n} Host${n === 1 ? '' : 's'}</div></div>
|
||||
<button class="btn sm ${on ? '' : 'primary'}" data-a="toggle">${on ? ICONS.stop + T('Disconnect') : ICONS.play + T('Connect')}</button><div class="actions"><button data-a="edit" title="${T('Edit')}">${ICONS.edit}</button><button data-a="del" title="${T('Delete')}">${ICONS.trash}</button></div></div>`);
|
||||
card.onclick = async (e) => {
|
||||
const a = e.target.closest('[data-a]')?.dataset.a;
|
||||
if (a === 'toggle') {
|
||||
const b = e.target.closest('button'); b.disabled = true; b.innerHTML = `<span class="spinner sm"></span>${on ? T('Disconnect') : T('Connecting …')}`;
|
||||
try { await call(on ? 'vpn:down' : 'vpn:up', v.id); if (!on) toast(T('VPN “{name}” connected', { name: v.label }), 'ok'); } catch {}
|
||||
S.vpnStatus = await call('vpn:status').catch(() => ({}));
|
||||
render();
|
||||
} else if (a === 'del') {
|
||||
if (await confirmBox(T('Delete VPN?'), T('“{name}” will be removed. Assigned hosts will connect without VPN.', { name: v.label }))) { await call('vault:remove', 'vpns', v.id); reload(); }
|
||||
} else editVpn(v);
|
||||
};
|
||||
l.append(card);
|
||||
}
|
||||
c.append(l);
|
||||
}
|
||||
|
||||
function editVpn(v = {}, pick = false) {
|
||||
let type = v.type || 'wireguard';
|
||||
const seg = h('<div class="seg"><button data-t="wireguard">WireGuard</button><button data-t="openvpn">OpenVPN</button></div>');
|
||||
const ovpnBox = h('<div></div>');
|
||||
ovpnBox.append(
|
||||
heading(T('Authentication')),
|
||||
field(T('Username'), 'username', v.username, { placeholder: T('optional') }),
|
||||
field(T('Password'), 'password', v.password, { type: 'password', placeholder: T('stored encrypted') }),
|
||||
);
|
||||
const cfg = field(T('Configuration'), 'config', v.config, { type: 'textarea', placeholder: '[Interface]\nPrivateKey = …', hint: '' });
|
||||
$('textarea', cfg).style.minHeight = '180px';
|
||||
const setType = (t) => {
|
||||
type = t;
|
||||
$$('button', seg).forEach((b) => b.classList.toggle('active', b.dataset.t === t));
|
||||
ovpnBox.style.display = t === 'openvpn' ? '' : 'none';
|
||||
$('textarea', cfg).placeholder = t === 'openvpn' ? 'client\ndev tun\nremote vpn.example.com 1194\n<ca>…</ca>' : '[Interface]\nPrivateKey = …\nAddress = 10.0.0.2/32\n\n[Peer]\n…';
|
||||
const hint = $('.hint', cfg) || cfg.appendChild(h('<div class="hint"></div>'));
|
||||
hint.textContent = t === 'openvpn' ? T('Certificates and keys must be embedded in the .ovpn file (<ca>, <cert>, <key> …).') : T('Contents of a WireGuard .conf file.');
|
||||
};
|
||||
$$('button', seg).forEach((b) => (b.onclick = () => setType(b.dataset.t)));
|
||||
|
||||
// Hosts zuordnen
|
||||
const hostBox = h(`<div class="vpn-hosts"></div>`);
|
||||
const sorted = [...S.vault.hosts].sort((a, b) => (a.label || a.address).localeCompare(b.label || b.address));
|
||||
if (!sorted.length) hostBox.append(h(`<div class="hint">${T('No hosts yet')}</div>`));
|
||||
sorted.forEach((x) => hostBox.append(h(`<label class="check"><input type="checkbox" data-host="${x.id}" ${x.vpnId && x.vpnId === v.id ? 'checked' : ''}/>${esc(x.label || x.address)}<span style="color:var(--faint);margin-left:6px;font-size:11px">${esc(hostSub(x))}</span></label>`)));
|
||||
|
||||
const form = openDrawer(v.id ? T('Edit VPN') : T('New VPN'), [
|
||||
seg,
|
||||
field(T('Name'), 'label', v.label, { placeholder: T('Office VPN') }),
|
||||
cfg,
|
||||
ovpnBox,
|
||||
check(T('Disconnect when MrTerm closes'), 'disconnectOnQuit', v.disconnectOnQuit !== false),
|
||||
heading(T('Assigned hosts')),
|
||||
hostBox,
|
||||
], async () => {
|
||||
const f = formValues(form);
|
||||
if (!f.config.trim()) throw new Error(T('Configuration is missing.'));
|
||||
if (type === 'wireguard' && !/\[Interface\]/i.test(f.config)) throw new Error(T('This does not look like a WireGuard configuration.'));
|
||||
const item = { ...v, type, label: f.label.trim() || VPN_TYPES[type], config: f.config, disconnectOnQuit: f.disconnectOnQuit,
|
||||
username: type === 'openvpn' ? f.username : '', password: type === 'openvpn' ? f.password : '' };
|
||||
const saved = await call('vault:upsert', 'vpns', item);
|
||||
for (const cb of $$('[data-host]', hostBox)) {
|
||||
const hst = S.vault.hosts.find((x) => x.id === cb.dataset.host);
|
||||
const want = cb.checked ? saved.id : (hst.vpnId === saved.id ? null : hst.vpnId || null);
|
||||
if ((hst.vpnId || null) !== want) await call('vault:upsert', 'hosts', { ...hst, vpnId: want });
|
||||
}
|
||||
reload();
|
||||
}, (() => { const b = h(`<button class="btn">${ICONS.folder}${T('File…')}</button>`); b.onclick = loadFile; return b; })());
|
||||
async function loadFile() {
|
||||
const f = await call('import:pickFile', T('VPN configuration'), ['conf', 'ovpn']);
|
||||
if (!f) return;
|
||||
$('[name=config]', form).value = f.content;
|
||||
if (!$('[name=label]', form).value) $('[name=label]', form).value = f.name.replace(/\.(conf|ovpn)$/i, '');
|
||||
setType(/\.ovpn$/i.test(f.name) || /^\s*(client|remote)\b/m.test(f.content) ? 'openvpn' : 'wireguard');
|
||||
}
|
||||
setType(type);
|
||||
if (pick) loadFile();
|
||||
}
|
||||
|
||||
// ============================================================ Known Hosts & Verlauf
|
||||
function viewKnown(page) {
|
||||
page.append(toolbar(T('Search known hosts …')));
|
||||
@@ -1653,6 +1749,7 @@ api.on('secret:request', async (req) => {
|
||||
api.replySecret(req.reqId, r ? r.v : null);
|
||||
});
|
||||
api.on('fw:event', () => reload());
|
||||
api.on('vpn:changed', async () => { S.vpnStatus = await call('vpn:status').catch(() => ({})); if (S.view === 'vpns' && S.active === 'home') render(); });
|
||||
api.on('toast', (m, t) => toast(m, t));
|
||||
api.on('win:state', (max) => { $('#winMax').innerHTML = max ? '<svg viewBox="0 0 12 12"><rect x="2" y="4" width="6" height="6"/><path d="M4 4V2h6v6H8"/></svg>' : '<svg viewBox="0 0 12 12"><rect x="2" y="2" width="8" height="8"/></svg>'; });
|
||||
|
||||
|
||||
Reference in New Issue
Block a user