From b38837d4e08d79917cce0bbbb1847c63fa3a946b Mon Sep 17 00:00:00 2001 From: Louis Date: Fri, 25 Sep 2026 19:05:33 +0200 Subject: [PATCH] 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 --- README.md | 11 +++ src/i18n.js | 34 ++++++++ src/main/main.js | 41 +++++++-- src/main/store.js | 1 + src/main/vpn.js | 183 ++++++++++++++++++++++++++++++++++++++++ src/renderer/app.js | 101 +++++++++++++++++++++- src/renderer/index.html | 1 + src/renderer/styles.css | 4 + 8 files changed, 368 insertions(+), 8 deletions(-) create mode 100644 src/main/vpn.js diff --git a/README.md b/README.md index e622d73..794906c 100644 --- a/README.md +++ b/README.md @@ -33,6 +33,7 @@ sudo pacman -S freerdp gnome-keyring # use kwallet instead of gnome-keyring on - **RDP in a tab** right inside MrTerm (Windows: `mstsc`, Linux: FreeRDP), or in a separate window if you prefer - **Keychain**: generate Ed25519/ECDSA/RSA keys, import existing ones and copy the public key - **Port forwarding**: local (-L), remote (-R) and dynamic/SOCKS5 (-D) +- **VPN**: add WireGuard or OpenVPN configurations and assign them to hosts. MrTerm connects the VPN automatically when you open such a host - **Snippets**: save frequently used commands and send them to a terminal with one click - **Known hosts**: MrTerm warns you if a server's host key changes - **History** of recent connections @@ -66,6 +67,16 @@ For a quick one-off connection, press `Ctrl+Shift+K` and type `user@host` (or `r `Ctrl+W`, `Ctrl+K` and `Ctrl+T` still reach the terminal, so editors like nano work as usual. +## VPN + +Under **VPN** in the sidebar you can add WireGuard (`.conf`) and OpenVPN (`.ovpn`) configurations. Paste them or load them from a file, then assign hosts, either in the VPN itself or through the *VPN* field of a host. + +When you open an assigned host (terminal, SFTP, RDP or port forwarding), MrTerm connects the VPN first if it isn't already connected. You can also connect and disconnect manually. By default, MrTerm disconnects the VPNs it started when you close it. + +- **Linux**: uses NetworkManager, so no root password is needed. For OpenVPN, install the plugin with `sudo pacman -S networkmanager-openvpn`. +- **Windows**: WireGuard requires [WireGuard for Windows](https://www.wireguard.com/install/) and asks for administrator permission when connecting. OpenVPN requires the [OpenVPN GUI](https://openvpn.net/community-downloads/). +- OpenVPN certificates and keys must be embedded in the `.ovpn` file. + ## App lock Under **Settings → App lock** you can protect MrTerm with a password, one or more FIDO2 security keys (e.g. YubiKey), or both. Once a method is set up: diff --git a/src/i18n.js b/src/i18n.js index f8e3ea2..d2a8687 100644 --- a/src/i18n.js +++ b/src/i18n.js @@ -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 (, , …).': 'Zertifikate und Schlüssel müssen in der .ovpn-Datei eingebettet sein (, , …).', + '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', diff --git a/src/main/main.js b/src/main/main.js index a8fa2f5..d19841a 100644 --- a/src/main/main.js +++ b/src/main/main.js @@ -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= startet, loggt Renderer-Meldungen, speichert einen Screenshot und beendet. if (process.env.MRTERM_SMOKE) { diff --git a/src/main/store.js b/src/main/store.js index abb3577..a1c58b0 100644 --- a/src/main/store.js +++ b/src/main/store.js @@ -12,6 +12,7 @@ const DEFAULTS = { keys: [], snippets: [], forwards: [], + vpns: [], knownHosts: {}, history: [], settings: { diff --git a/src/main/vpn.js b/src/main/vpn.js new file mode 100644 index 0000000..1319413 --- /dev/null +++ b/src/main/vpn.js @@ -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-" 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 }; diff --git a/src/renderer/app.js b/src/renderer/app.js index 636435b..71c72e3 100644 --- a/src/renderer/app.js +++ b/src/renderer/app.js @@ -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('
'); 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('
'); + 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(`
${ICONS.shield}
${esc(v.label)} ${on ? T('connected') : T('disconnected')}
${VPN_TYPES[v.type] || v.type} · ${n} Host${n === 1 ? '' : 's'}
+
`); + 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 = `${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('
'); + const ovpnBox = h('
'); + 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…' : '[Interface]\nPrivateKey = …\nAddress = 10.0.0.2/32\n\n[Peer]\n…'; + const hint = $('.hint', cfg) || cfg.appendChild(h('
')); + hint.textContent = t === 'openvpn' ? T('Certificates and keys must be embedded in the .ovpn file (, , …).') : T('Contents of a WireGuard .conf file.'); + }; + $$('button', seg).forEach((b) => (b.onclick = () => setType(b.dataset.t))); + + // Hosts zuordnen + const hostBox = h(`
`); + const sorted = [...S.vault.hosts].sort((a, b) => (a.label || a.address).localeCompare(b.label || b.address)); + if (!sorted.length) hostBox.append(h(`
${T('No hosts yet')}
`)); + sorted.forEach((x) => hostBox.append(h(``))); + + 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(``); 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 ? '' : ''; }); diff --git a/src/renderer/index.html b/src/renderer/index.html index e0f2973..2b6645e 100644 --- a/src/renderer/index.html +++ b/src/renderer/index.html @@ -34,6 +34,7 @@ + diff --git a/src/renderer/styles.css b/src/renderer/styles.css index 8e28abf..155c29c 100644 --- a/src/renderer/styles.css +++ b/src/renderer/styles.css @@ -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; }