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:
+34
@@ -352,6 +352,40 @@
|
||||
'Security key prompt was cancelled or timed out.': 'Die Abfrage des Sicherheitsschlüssels wurde abgebrochen oder ist abgelaufen.',
|
||||
'This security key does not support the hmac-secret/PRF extension.': 'Dieser Sicherheitsschlüssel unterstützt die hmac-secret/PRF-Erweiterung nicht.',
|
||||
|
||||
// VPN
|
||||
'— No VPN —': '— Kein VPN —',
|
||||
'Connected automatically before connecting to this host.': 'Wird vor dem Verbinden mit diesem Host automatisch aufgebaut.',
|
||||
'Search VPNs …': 'VPNs suchen …',
|
||||
'New VPN': 'Neues VPN',
|
||||
'No VPNs': 'Keine VPNs',
|
||||
'Add WireGuard or OpenVPN configurations and assign them to hosts. MrTerm connects the VPN automatically when you open such a host.': 'Füge WireGuard- oder OpenVPN-Konfigurationen hinzu und ordne sie Hosts zu. MrTerm baut das VPN automatisch auf, wenn du einen solchen Host öffnest.',
|
||||
'connected': 'verbunden',
|
||||
'disconnected': 'getrennt',
|
||||
'Disconnect': 'Trennen',
|
||||
'Connecting …': 'Verbinde …',
|
||||
'VPN “{name}” connected': 'VPN „{name}“ verbunden',
|
||||
'Delete VPN?': 'VPN löschen?',
|
||||
'“{name}” will be removed. Assigned hosts will connect without VPN.': '„{name}“ wird entfernt. Zugeordnete Hosts verbinden sich dann ohne VPN.',
|
||||
'Configuration': 'Konfiguration',
|
||||
'Certificates and keys must be embedded in the .ovpn file (<ca>, <cert>, <key> …).': 'Zertifikate und Schlüssel müssen in der .ovpn-Datei eingebettet sein (<ca>, <cert>, <key> …).',
|
||||
'Contents of a WireGuard .conf file.': 'Inhalt einer WireGuard-.conf-Datei.',
|
||||
'Edit VPN': 'VPN bearbeiten',
|
||||
'Office VPN': 'Büro-VPN',
|
||||
'Disconnect when MrTerm closes': 'Beim Beenden von MrTerm trennen',
|
||||
'Assigned hosts': 'Zugeordnete Hosts',
|
||||
'Configuration is missing.': 'Konfiguration fehlt.',
|
||||
'This does not look like a WireGuard configuration.': 'Das sieht nicht nach einer WireGuard-Konfiguration aus.',
|
||||
'VPN configuration': 'VPN-Konfiguration',
|
||||
'NetworkManager (nmcli) not found. MrTerm uses NetworkManager for VPN connections.': 'NetworkManager (nmcli) nicht gefunden. MrTerm nutzt NetworkManager für VPN-Verbindungen.',
|
||||
'OpenVPN support for NetworkManager is missing. Install it with: sudo pacman -S networkmanager-openvpn': 'OpenVPN-Unterstützung für NetworkManager fehlt. Installieren mit: sudo pacman -S networkmanager-openvpn',
|
||||
'VPN configuration could not be imported: {err}': 'VPN-Konfiguration konnte nicht importiert werden: {err}',
|
||||
'VPN “{name}” could not be connected: {err}': 'VPN „{name}“ konnte nicht verbunden werden: {err}',
|
||||
'VPN not found': 'VPN nicht gefunden',
|
||||
'OpenVPN GUI not found. Install OpenVPN from openvpn.net.': 'OpenVPN GUI nicht gefunden. Installiere OpenVPN von openvpn.net.',
|
||||
'WireGuard for Windows not found. Install it from wireguard.com.': 'WireGuard für Windows nicht gefunden. Installiere es von wireguard.com.',
|
||||
'timeout': 'Zeitüberschreitung',
|
||||
'Connecting VPN “{name}” …': 'Baue VPN „{name}“ auf …',
|
||||
|
||||
// Main-Prozess
|
||||
'Host key has changed!': 'Host-Schlüssel hat sich geändert!',
|
||||
'Unknown host': 'Unbekannter Host',
|
||||
|
||||
+35
-6
@@ -11,6 +11,7 @@ const { Updater } = require('./updater');
|
||||
const { createEmbed, embedSupported } = require('./rdp-embed');
|
||||
const i18n = require('../i18n');
|
||||
const fido = require('./fido');
|
||||
const { VpnManager } = require('./vpn');
|
||||
const applyLanguage = () => i18n.setLanguage(store.get().settings.language, app.getLocale());
|
||||
|
||||
let win;
|
||||
@@ -59,6 +60,7 @@ function askSecret(sessionId, req) {
|
||||
|
||||
const ssh = new SshManager(store, confirmHostKey, askSecret);
|
||||
const updater = new Updater(store, send);
|
||||
const vpn = new VpnManager(store, app.getPath('userData'));
|
||||
|
||||
function createWindow() {
|
||||
win = new BrowserWindow({
|
||||
@@ -176,8 +178,26 @@ handle('lock:removeFido', (id) => {
|
||||
|
||||
// ---------- Vault ----------
|
||||
handle('vault:get', () => ({ ...store.get(), encrypted: store.encrypted, platform: process.platform }));
|
||||
handle('vault:upsert', (col, item) => store.upsert(col, item));
|
||||
handle('vault:remove', (col, id) => store.remove(col, id));
|
||||
handle('vault:upsert', async (col, item) => {
|
||||
// Geänderte VPN-Konfiguration: alte Systemverbindung entfernen, beim nächsten Verbinden neu importieren
|
||||
if (col === 'vpns' && item.id) {
|
||||
const old = store.get().vpns.find((v) => v.id === item.id);
|
||||
if (old && ['type', 'config', 'username', 'password'].some((k) => (old[k] || '') !== (item[k] || ''))) await vpn.forget(old);
|
||||
}
|
||||
return store.upsert(col, item);
|
||||
});
|
||||
handle('vault:remove', async (col, id) => {
|
||||
if (col === 'vpns') {
|
||||
await vpn.forget(store.get().vpns.find((v) => v.id === id));
|
||||
store.get().hosts.forEach((h) => { if (h.vpnId === id) h.vpnId = null; });
|
||||
}
|
||||
return store.remove(col, id);
|
||||
});
|
||||
|
||||
// ---------- VPN ----------
|
||||
handle('vpn:status', () => vpn.status());
|
||||
handle('vpn:up', (id) => vpn.up(id));
|
||||
handle('vpn:down', (id) => vpn.down(id));
|
||||
handle('vault:settings', (s) => {
|
||||
store.setSettings(s);
|
||||
if ('language' in s) applyLanguage();
|
||||
@@ -274,6 +294,8 @@ function hostWithOverrides(hostOrId) {
|
||||
handle('ssh:open', async (sessionId, hostRef, size) => {
|
||||
const host = hostWithOverrides(hostRef);
|
||||
if (host.id) store.addHistory({ hostId: host.id, at: Date.now() });
|
||||
const onEvent = (type, payload) => send('ssh:event', sessionId, type, payload);
|
||||
if (await vpn.ensureForHost(host, (m) => onEvent('status', m))) send('vpn:changed');
|
||||
await ssh.openShell(sessionId, host, size, (type, payload) => send('ssh:event', sessionId, type, payload));
|
||||
return true;
|
||||
});
|
||||
@@ -286,7 +308,11 @@ ipcMain.on('secret:reply', (_e, reqId, value) => {
|
||||
});
|
||||
|
||||
// ---------- SFTP ----------
|
||||
handle('sftp:open', (sessionId, hostRef) => ssh.openSftp(sessionId, hostWithOverrides(hostRef)));
|
||||
handle('sftp:open', async (sessionId, hostRef) => {
|
||||
const host = hostWithOverrides(hostRef);
|
||||
if (await vpn.ensureForHost(host)) send('vpn:changed');
|
||||
return ssh.openSftp(sessionId, host);
|
||||
});
|
||||
handle('sftp:list', (id, dir) => ssh.sftpList(id, dir));
|
||||
handle('sftp:op', (id, op, a, b) => ssh.sftpOp(id, op, a, b));
|
||||
handle('sftp:transfer', async (id, dir, localPath, remotePath, transferId) => {
|
||||
@@ -320,9 +346,10 @@ handle('local:op', (op, a, b) => {
|
||||
});
|
||||
|
||||
// ---------- Port-Forwarding ----------
|
||||
handle('fw:start', (id) => {
|
||||
handle('fw:start', async (id) => {
|
||||
const fw = store.get().forwards.find((f) => f.id === id);
|
||||
if (!fw) throw new Error(i18n.t('Rule not found'));
|
||||
if (await vpn.ensureForHost(store.resolveHost(fw.hostId))) send('vpn:changed');
|
||||
return ssh.startForward(fw, (ev) => send('fw:event', id, ev));
|
||||
});
|
||||
handle('fw:stop', (id) => ssh.stopForward(id));
|
||||
@@ -330,8 +357,9 @@ handle('fw:active', () => ssh.activeForwards());
|
||||
|
||||
// ---------- RDP ----------
|
||||
handle('rdp:detect', () => rdp.detect(store.get().settings));
|
||||
handle('rdp:launch', (hostId) => {
|
||||
handle('rdp:launch', async (hostId) => {
|
||||
const host = hostWithOverrides(hostId);
|
||||
if (await vpn.ensureForHost(host)) send('vpn:changed');
|
||||
if (host.id) store.addHistory({ hostId: host.id, at: Date.now() });
|
||||
const r = rdp.launch(host, store.get().settings);
|
||||
if (r.process) {
|
||||
@@ -351,6 +379,7 @@ handle('rdp:embedSupported', () => {
|
||||
handle('rdp:open', async (id, hostRef, bounds) => {
|
||||
const host = hostWithOverrides(hostRef);
|
||||
if (host.id) store.addHistory({ hostId: host.id, at: Date.now() });
|
||||
if (await vpn.ensureForHost(host)) send('vpn:changed');
|
||||
const emb = createEmbed(win.getNativeWindowHandle());
|
||||
const sess = { emb, cancelled: false };
|
||||
rdpSessions.set(id, sess);
|
||||
@@ -453,7 +482,7 @@ function scheduleUpdateCheck() {
|
||||
updateScheduled = true;
|
||||
setTimeout(() => updater.check().then((r) => { if (r.available) send('update:available', r); }).catch(() => {}), 6000);
|
||||
}
|
||||
app.on('window-all-closed', () => { ssh.closeAll(); app.quit(); });
|
||||
app.on('window-all-closed', async () => { ssh.closeAll(); await vpn.downOnQuit(); app.quit(); });
|
||||
|
||||
// Smoke-Test: MRTERM_SMOKE=<pfad.png> startet, loggt Renderer-Meldungen, speichert einen Screenshot und beendet.
|
||||
if (process.env.MRTERM_SMOKE) {
|
||||
|
||||
@@ -12,6 +12,7 @@ const DEFAULTS = {
|
||||
keys: [],
|
||||
snippets: [],
|
||||
forwards: [],
|
||||
vpns: [],
|
||||
knownHosts: {},
|
||||
history: [],
|
||||
settings: {
|
||||
|
||||
+183
@@ -0,0 +1,183 @@
|
||||
// VPN-Verbindungen (WireGuard / OpenVPN), die vor dem Verbinden zu zugeordneten Hosts automatisch aufgebaut werden.
|
||||
// Linux: NetworkManager (nmcli) – kein root nötig. MrTerm legt pro VPN eine Verbindung "mrterm-<id>" an
|
||||
// (autoconnect aus). OpenVPN braucht das Plugin networkmanager-openvpn.
|
||||
// Windows: WireGuard als Tunnel-Dienst über wireguard.exe (UAC-Abfrage), OpenVPN über die OpenVPN GUI.
|
||||
const { execFile, spawn } = require('child_process');
|
||||
const fs = require('fs');
|
||||
const os = require('os');
|
||||
const path = require('path');
|
||||
const i18n = require('../i18n');
|
||||
|
||||
function exec(cmd, args) {
|
||||
return new Promise((resolve) => {
|
||||
execFile(cmd, args, { windowsHide: true, timeout: 90000 }, (err, stdout, stderr) => {
|
||||
resolve({ code: err ? (typeof err.code === 'number' ? err.code : err.code === 'ENOENT' ? 'ENOENT' : 1) : 0, stdout: String(stdout || ''), stderr: String(stderr || err?.message || '') });
|
||||
});
|
||||
});
|
||||
}
|
||||
const lastLine = (s) => s.split('\n').map((l) => l.trim()).filter(Boolean).slice(-1)[0] || '';
|
||||
const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
|
||||
|
||||
class VpnManager {
|
||||
constructor(store, userDir) {
|
||||
this.store = store;
|
||||
this.dir = path.join(userDir, 'vpn');
|
||||
this.busy = new Map(); // id -> laufendes up() (parallele Verbindungen zum selben VPN nur einmal aufbauen)
|
||||
this.started = new Set(); // von MrTerm aufgebaute VPNs
|
||||
}
|
||||
|
||||
get(id) { return this.store.get().vpns.find((v) => v.id === id); }
|
||||
nmName(v) { return 'mrterm-' + v.id.slice(0, 8); }
|
||||
// Interface-/Tunnelname: max. 15 Zeichen (Linux), nur [a-z0-9]
|
||||
ifName(v) { return 'mt' + v.id.replace(/-/g, '').slice(0, 10); }
|
||||
|
||||
// ---------------------------------------------------------------- Linux (NetworkManager)
|
||||
async nm(args) {
|
||||
const r = await exec('nmcli', args);
|
||||
if (r.code === 'ENOENT') throw new Error(i18n.t('NetworkManager (nmcli) not found. MrTerm uses NetworkManager for VPN connections.'));
|
||||
return r;
|
||||
}
|
||||
async nmExists(v) {
|
||||
const r = await this.nm(['-t', '-f', 'NAME', 'connection', 'show']);
|
||||
return r.stdout.split('\n').includes(this.nmName(v));
|
||||
}
|
||||
async nmImport(v) {
|
||||
const tmp = fs.mkdtempSync(path.join(os.tmpdir(), 'mrterm-vpn-'));
|
||||
const file = path.join(tmp, this.ifName(v) + (v.type === 'openvpn' ? '.ovpn' : '.conf'));
|
||||
try {
|
||||
fs.writeFileSync(file, v.config || '', { mode: 0o600 });
|
||||
const r = await this.nm(['connection', 'import', 'type', v.type === 'openvpn' ? 'openvpn' : 'wireguard', 'file', file]);
|
||||
if (r.code) {
|
||||
const err = lastLine(r.stderr);
|
||||
if (v.type === 'openvpn' && /plugin|openvpn/i.test(err)) throw new Error(i18n.t('OpenVPN support for NetworkManager is missing. Install it with: sudo pacman -S networkmanager-openvpn'));
|
||||
throw new Error(i18n.t('VPN configuration could not be imported: {err}', { err }));
|
||||
}
|
||||
const uuid = (r.stdout.match(/[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}/) || [])[0];
|
||||
const mod = ['connection', 'modify', uuid || this.ifName(v), 'connection.id', this.nmName(v), 'connection.autoconnect', 'no'];
|
||||
if (v.type === 'openvpn' && v.username) mod.push('+vpn.data', `username=${v.username}`);
|
||||
if (v.type === 'openvpn' && v.password) mod.push('+vpn.data', 'password-flags=0', 'vpn.secrets', `password=${v.password}`);
|
||||
const m = await this.nm(mod);
|
||||
if (m.code) throw new Error(lastLine(m.stderr));
|
||||
} finally {
|
||||
fs.rmSync(tmp, { recursive: true, force: true });
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------- Windows
|
||||
get wgExe() { return path.join(process.env.ProgramFiles || 'C:\\Program Files', 'WireGuard', 'wireguard.exe'); }
|
||||
get ovpnGui() { return path.join(process.env.ProgramFiles || 'C:\\Program Files', 'OpenVPN', 'bin', 'openvpn-gui.exe'); }
|
||||
// Programm mit Administratorrechten starten (UAC) und warten
|
||||
async elevate(exe, args) {
|
||||
const q = (s) => `'${String(s).replace(/'/g, "''")}'`;
|
||||
const cmd = `$p = Start-Process -FilePath ${q(exe)} -ArgumentList @(${args.map((a) => q(`"${a}"`)).join(',')}) -Verb RunAs -Wait -WindowStyle Hidden -PassThru; exit $p.ExitCode`;
|
||||
const r = await exec('powershell.exe', ['-NoProfile', '-NonInteractive', '-Command', cmd]);
|
||||
if (r.code) throw new Error(i18n.t('VPN “{name}” could not be connected: {err}', { name: path.basename(exe), err: lastLine(r.stderr) || r.code }));
|
||||
}
|
||||
winOvpnName(v) { return this.ifName(v) + '.ovpn'; }
|
||||
async winOpenvpnUp() {
|
||||
const r = await exec('powershell.exe', ['-NoProfile', '-NonInteractive', '-Command',
|
||||
"@(Get-NetAdapter | Where-Object { $_.Status -eq 'Up' -and $_.InterfaceDescription -match 'TAP-Windows|Wintun|OpenVPN|ovpn-dco' }).Count"]);
|
||||
return Number(r.stdout.trim()) > 0;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------- Gemeinsame API
|
||||
async isUp(v) {
|
||||
if (process.platform === 'win32') {
|
||||
if (v.type === 'openvpn') return this.winOpenvpnUp();
|
||||
const r = await exec('sc', ['query', `WireGuardTunnel$${this.ifName(v)}`]);
|
||||
return /RUNNING/.test(r.stdout);
|
||||
}
|
||||
const r = await this.nm(['-t', '-f', 'NAME', 'connection', 'show', '--active']);
|
||||
return r.stdout.split('\n').includes(this.nmName(v));
|
||||
}
|
||||
|
||||
async status() {
|
||||
const out = {};
|
||||
for (const v of this.store.get().vpns) { try { out[v.id] = await this.isUp(v); } catch { out[v.id] = false; } }
|
||||
return out;
|
||||
}
|
||||
|
||||
up(id) {
|
||||
if (this.busy.has(id)) return this.busy.get(id);
|
||||
const p = this._up(id).finally(() => this.busy.delete(id));
|
||||
this.busy.set(id, p);
|
||||
return p;
|
||||
}
|
||||
|
||||
async _up(id) {
|
||||
const v = this.get(id);
|
||||
if (!v) throw new Error(i18n.t('VPN not found'));
|
||||
if (await this.isUp(v)) return;
|
||||
const fail = (err) => new Error(i18n.t('VPN “{name}” could not be connected: {err}', { name: v.label, err }));
|
||||
if (process.platform === 'win32') {
|
||||
fs.mkdirSync(this.dir, { recursive: true });
|
||||
if (v.type === 'openvpn') {
|
||||
if (!fs.existsSync(this.ovpnGui)) throw new Error(i18n.t('OpenVPN GUI not found. Install OpenVPN from openvpn.net.'));
|
||||
const dir = path.join(os.homedir(), 'OpenVPN', 'config', this.ifName(v));
|
||||
fs.mkdirSync(dir, { recursive: true });
|
||||
fs.writeFileSync(path.join(dir, this.winOvpnName(v)), v.config || '');
|
||||
spawn(this.ovpnGui, ['--connect', this.winOvpnName(v)], { detached: true, stdio: 'ignore' }).unref();
|
||||
} else {
|
||||
if (!fs.existsSync(this.wgExe)) throw new Error(i18n.t('WireGuard for Windows not found. Install it from wireguard.com.'));
|
||||
const file = path.join(this.dir, this.ifName(v) + '.conf');
|
||||
fs.writeFileSync(file, v.config || '');
|
||||
await this.elevate(this.wgExe, ['/installtunnelservice', file]);
|
||||
}
|
||||
for (let i = 0; i < 60; i++) { if (await this.isUp(v)) { this.started.add(id); return; } await sleep(500); }
|
||||
throw fail(i18n.t('timeout'));
|
||||
}
|
||||
if (!(await this.nmExists(v))) await this.nmImport(v);
|
||||
const r = await this.nm(['--wait', '45', 'connection', 'up', 'id', this.nmName(v)]);
|
||||
if (r.code) throw fail(lastLine(r.stderr));
|
||||
this.started.add(id);
|
||||
}
|
||||
|
||||
// Beim Beenden: von MrTerm aufgebaute VPNs mit Option "beim Beenden trennen" wieder abbauen
|
||||
async downOnQuit() {
|
||||
for (const id of this.started) {
|
||||
const v = this.get(id);
|
||||
if (v && v.disconnectOnQuit !== false) await this.down(id).catch(() => {});
|
||||
}
|
||||
}
|
||||
|
||||
async down(id) {
|
||||
const v = this.get(id);
|
||||
if (!v) return;
|
||||
if (process.platform === 'win32') {
|
||||
if (v.type === 'openvpn') spawn(this.ovpnGui, ['--command', 'disconnect', this.winOvpnName(v)], { detached: true, stdio: 'ignore' }).unref();
|
||||
else if (await this.isUp(v)) await this.elevate(this.wgExe, ['/uninstalltunnelservice', this.ifName(v)]);
|
||||
return;
|
||||
}
|
||||
await this.nm(['connection', 'down', 'id', this.nmName(v)]);
|
||||
}
|
||||
|
||||
// Nach Änderung/Löschen: im System hinterlegte Verbindung entfernen, beim nächsten Verbinden wird neu importiert
|
||||
async forget(v) {
|
||||
if (!v) return;
|
||||
try {
|
||||
if (process.platform === 'win32') {
|
||||
if (v.type !== 'openvpn' && (await this.isUp(v))) await this.elevate(this.wgExe, ['/uninstalltunnelservice', this.ifName(v)]);
|
||||
fs.rmSync(path.join(this.dir, this.ifName(v) + '.conf'), { force: true });
|
||||
fs.rmSync(path.join(os.homedir(), 'OpenVPN', 'config', this.ifName(v)), { recursive: true, force: true });
|
||||
} else if (await this.nmExists(v)) await this.nm(['connection', 'delete', 'id', this.nmName(v)]);
|
||||
} catch { /* VPN-Werkzeuge fehlen – nichts aufzuräumen */ }
|
||||
}
|
||||
|
||||
// Alle VPNs eines Hosts (inkl. Jump-Host-Kette) aufbauen, bevor verbunden wird
|
||||
async ensureForHost(host, onStatus = () => {}) {
|
||||
const ids = [];
|
||||
for (let h = host, n = 0; h && n < 10; h = h.jumpHostId ? this.store.resolveHost(h.jumpHostId) : null, n++) {
|
||||
if (h.vpnId && !ids.includes(h.vpnId)) ids.push(h.vpnId);
|
||||
}
|
||||
for (const id of ids.reverse()) {
|
||||
const v = this.get(id);
|
||||
if (!v) continue;
|
||||
if (await this.isUp(v)) continue;
|
||||
onStatus(i18n.t('Connecting VPN “{name}” …', { name: v.label }));
|
||||
await this.up(id);
|
||||
}
|
||||
return ids.length > 0;
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = { VpnManager };
|
||||
+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>'; });
|
||||
|
||||
|
||||
@@ -34,6 +34,7 @@
|
||||
<button class="nav" data-view="sftp"><svg viewBox="0 0 24 24"><path d="M3 7a2 2 0 0 1 2-2h4l2 2h8a2 2 0 0 1 2 2v8a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2z"/></svg><span>SFTP</span></button>
|
||||
<button class="nav" data-view="keys"><svg viewBox="0 0 24 24"><circle cx="8" cy="15" r="4"/><path d="M11 12l9-9M16 7l3 3M14 9l2 2"/></svg><span>Keychain</span></button>
|
||||
<button class="nav" data-view="forwards"><svg viewBox="0 0 24 24"><path d="M4 8h13l-3-3M20 16H7l3 3"/></svg><span>Port Forwarding</span></button>
|
||||
<button class="nav" data-view="vpns"><svg viewBox="0 0 24 24"><path d="M12 3l8 3v6c0 5-3.5 8-8 9-4.5-1-8-4-8-9V6z"/><path d="M9 12h6M12 9v6"/></svg><span>VPN</span></button>
|
||||
<button class="nav" data-view="snippets"><svg viewBox="0 0 24 24"><path d="M8 7l-5 5 5 5M16 7l5 5-5 5"/></svg><span>Snippets</span></button>
|
||||
<button class="nav" data-view="known"><svg viewBox="0 0 24 24"><path d="M12 3l8 3v6c0 5-3.5 8-8 9-4.5-1-8-4-8-9V6z"/><path d="M9 12l2 2 4-4"/></svg><span>Known Hosts</span></button>
|
||||
<button class="nav" data-view="history"><svg viewBox="0 0 24 24"><circle cx="12" cy="12" r="9"/><path d="M12 7v5l3 2"/></svg><span data-i18n="History">History</span></button>
|
||||
|
||||
@@ -360,3 +360,7 @@ kbd { background: var(--card); border: 1px solid var(--border); border-bottom-wi
|
||||
.lock-row b { display: flex; align-items: center; gap: 6px; }
|
||||
.lock-row b svg { width: 14px; height: 14px; }
|
||||
.lock-row .sub { color: var(--muted); font-size: 12px; margin-top: 2px; }
|
||||
|
||||
/* VPN */
|
||||
.vpn-hosts { display: flex; flex-direction: column; gap: 2px; max-height: 260px; overflow: auto; }
|
||||
.spinner.sm { display: inline-block; width: 12px; height: 12px; border: 2px solid var(--border); border-top-color: var(--accent); border-radius: 50%; animation: spin .9s linear infinite; margin-right: 6px; vertical-align: -2px; }
|
||||
|
||||
Reference in New Issue
Block a user