// 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 };