Compare commits

..
2 Commits
10 changed files with 371 additions and 11 deletions
+11
View File
@@ -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 - **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 - **Keychain**: generate Ed25519/ECDSA/RSA keys, import existing ones and copy the public key
- **Port forwarding**: local (-L), remote (-R) and dynamic/SOCKS5 (-D) - **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 - **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 - **Known hosts**: MrTerm warns you if a server's host key changes
- **History** of recent connections - **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. `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 ## 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: 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:
+2 -2
View File
@@ -1,12 +1,12 @@
{ {
"name": "mrterm", "name": "mrterm",
"version": "0.5.0", "version": "0.6.0",
"lockfileVersion": 3, "lockfileVersion": 3,
"requires": true, "requires": true,
"packages": { "packages": {
"": { "": {
"name": "mrterm", "name": "mrterm",
"version": "0.5.0", "version": "0.6.0",
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"@xterm/addon-fit": "^0.11.0", "@xterm/addon-fit": "^0.11.0",
+1 -1
View File
@@ -1,7 +1,7 @@
{ {
"name": "mrterm", "name": "mrterm",
"productName": "MrTerm", "productName": "MrTerm",
"version": "0.5.0", "version": "0.6.0",
"description": "Moderner SSH-, SFTP- und RDP-Client", "description": "Moderner SSH-, SFTP- und RDP-Client",
"main": "src/main/main.js", "main": "src/main/main.js",
"author": "MrBlake", "author": "MrBlake",
+34
View File
@@ -352,6 +352,40 @@
'Security key prompt was cancelled or timed out.': 'Die Abfrage des Sicherheitsschlüssels wurde abgebrochen oder ist abgelaufen.', '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.', '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 // Main-Prozess
'Host key has changed!': 'Host-Schlüssel hat sich geändert!', 'Host key has changed!': 'Host-Schlüssel hat sich geändert!',
'Unknown host': 'Unbekannter Host', 'Unknown host': 'Unbekannter Host',
+35 -6
View File
@@ -11,6 +11,7 @@ const { Updater } = require('./updater');
const { createEmbed, embedSupported } = require('./rdp-embed'); const { createEmbed, embedSupported } = require('./rdp-embed');
const i18n = require('../i18n'); const i18n = require('../i18n');
const fido = require('./fido'); const fido = require('./fido');
const { VpnManager } = require('./vpn');
const applyLanguage = () => i18n.setLanguage(store.get().settings.language, app.getLocale()); const applyLanguage = () => i18n.setLanguage(store.get().settings.language, app.getLocale());
let win; let win;
@@ -59,6 +60,7 @@ function askSecret(sessionId, req) {
const ssh = new SshManager(store, confirmHostKey, askSecret); const ssh = new SshManager(store, confirmHostKey, askSecret);
const updater = new Updater(store, send); const updater = new Updater(store, send);
const vpn = new VpnManager(store, app.getPath('userData'));
function createWindow() { function createWindow() {
win = new BrowserWindow({ win = new BrowserWindow({
@@ -176,8 +178,26 @@ handle('lock:removeFido', (id) => {
// ---------- Vault ---------- // ---------- Vault ----------
handle('vault:get', () => ({ ...store.get(), encrypted: store.encrypted, platform: process.platform })); handle('vault:get', () => ({ ...store.get(), encrypted: store.encrypted, platform: process.platform }));
handle('vault:upsert', (col, item) => store.upsert(col, item)); handle('vault:upsert', async (col, item) => {
handle('vault:remove', (col, id) => store.remove(col, id)); // 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) => { handle('vault:settings', (s) => {
store.setSettings(s); store.setSettings(s);
if ('language' in s) applyLanguage(); if ('language' in s) applyLanguage();
@@ -274,6 +294,8 @@ function hostWithOverrides(hostOrId) {
handle('ssh:open', async (sessionId, hostRef, size) => { handle('ssh:open', async (sessionId, hostRef, size) => {
const host = hostWithOverrides(hostRef); const host = hostWithOverrides(hostRef);
if (host.id) store.addHistory({ hostId: host.id, at: Date.now() }); 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)); await ssh.openShell(sessionId, host, size, (type, payload) => send('ssh:event', sessionId, type, payload));
return true; return true;
}); });
@@ -286,7 +308,11 @@ ipcMain.on('secret:reply', (_e, reqId, value) => {
}); });
// ---------- SFTP ---------- // ---------- 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:list', (id, dir) => ssh.sftpList(id, dir));
handle('sftp:op', (id, op, a, b) => ssh.sftpOp(id, op, a, b)); handle('sftp:op', (id, op, a, b) => ssh.sftpOp(id, op, a, b));
handle('sftp:transfer', async (id, dir, localPath, remotePath, transferId) => { handle('sftp:transfer', async (id, dir, localPath, remotePath, transferId) => {
@@ -320,9 +346,10 @@ handle('local:op', (op, a, b) => {
}); });
// ---------- Port-Forwarding ---------- // ---------- Port-Forwarding ----------
handle('fw:start', (id) => { handle('fw:start', async (id) => {
const fw = store.get().forwards.find((f) => f.id === id); const fw = store.get().forwards.find((f) => f.id === id);
if (!fw) throw new Error(i18n.t('Rule not found')); 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)); return ssh.startForward(fw, (ev) => send('fw:event', id, ev));
}); });
handle('fw:stop', (id) => ssh.stopForward(id)); handle('fw:stop', (id) => ssh.stopForward(id));
@@ -330,8 +357,9 @@ handle('fw:active', () => ssh.activeForwards());
// ---------- RDP ---------- // ---------- RDP ----------
handle('rdp:detect', () => rdp.detect(store.get().settings)); handle('rdp:detect', () => rdp.detect(store.get().settings));
handle('rdp:launch', (hostId) => { handle('rdp:launch', async (hostId) => {
const host = hostWithOverrides(hostId); const host = hostWithOverrides(hostId);
if (await vpn.ensureForHost(host)) send('vpn:changed');
if (host.id) store.addHistory({ hostId: host.id, at: Date.now() }); if (host.id) store.addHistory({ hostId: host.id, at: Date.now() });
const r = rdp.launch(host, store.get().settings); const r = rdp.launch(host, store.get().settings);
if (r.process) { if (r.process) {
@@ -351,6 +379,7 @@ handle('rdp:embedSupported', () => {
handle('rdp:open', async (id, hostRef, bounds) => { handle('rdp:open', async (id, hostRef, bounds) => {
const host = hostWithOverrides(hostRef); const host = hostWithOverrides(hostRef);
if (host.id) store.addHistory({ hostId: host.id, at: Date.now() }); 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 emb = createEmbed(win.getNativeWindowHandle());
const sess = { emb, cancelled: false }; const sess = { emb, cancelled: false };
rdpSessions.set(id, sess); rdpSessions.set(id, sess);
@@ -453,7 +482,7 @@ function scheduleUpdateCheck() {
updateScheduled = true; updateScheduled = true;
setTimeout(() => updater.check().then((r) => { if (r.available) send('update:available', r); }).catch(() => {}), 6000); 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. // Smoke-Test: MRTERM_SMOKE=<pfad.png> startet, loggt Renderer-Meldungen, speichert einen Screenshot und beendet.
if (process.env.MRTERM_SMOKE) { if (process.env.MRTERM_SMOKE) {
+1
View File
@@ -12,6 +12,7 @@ const DEFAULTS = {
keys: [], keys: [],
snippets: [], snippets: [],
forwards: [], forwards: [],
vpns: [],
knownHosts: {}, knownHosts: {},
history: [], history: [],
settings: { settings: {
+183
View File
@@ -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
View File
@@ -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).'); 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(); applyAppTheme();
S.activeForwards = new Set(await call('fw:active')); S.activeForwards = new Set(await call('fw:active'));
if (S.vault.vpns.length) S.vpnStatus = await call('vpn:status').catch(() => ({}));
render(); render();
} }
@@ -272,7 +273,7 @@ function renderTree() {
function render() { function render() {
const page = $('#homeView'); 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; const scroll = page.scrollTop;
page.innerHTML = ''; page.innerHTML = '';
views[S.view](page); views[S.view](page);
@@ -533,6 +534,7 @@ function editHost(hst = {}) {
row(field(T('Address'), 'address', hst.address, { placeholder: T('IP or hostname') }), portField), 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('Label'), 'label', hst.label, { placeholder: T('My server') }),
field(T('Group'), 'groupId', hst.groupId, { type: 'select', options: groupOptions() }), 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' }), field('Tags', 'tags', (hst.tags || []).join(', '), { placeholder: 'prod, web, db' }),
heading(T('Authentication')), heading(T('Authentication')),
field(T('Username'), 'username', hst.username, { placeholder: 'root' }), field(T('Username'), 'username', hst.username, { placeholder: 'root' }),
@@ -548,7 +550,7 @@ function editHost(hst = {}) {
address: v.address.trim(), address: v.address.trim(),
port: v.port ? Number(v.port) : undefined, port: v.port ? Number(v.port) : undefined,
tags: v.tags.split(',').map((t) => t.trim()).filter(Boolean), 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); await call('vault:upsert', 'hosts', item);
reload(); 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 // ============================================================ Known Hosts & Verlauf
function viewKnown(page) { function viewKnown(page) {
page.append(toolbar(T('Search known hosts …'))); 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.replySecret(req.reqId, r ? r.v : null);
}); });
api.on('fw:event', () => reload()); 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('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>'; }); 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>'; });
+1
View File
@@ -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="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="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="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="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="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> <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>
+4
View File
@@ -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 { display: flex; align-items: center; gap: 6px; }
.lock-row b svg { width: 14px; height: 14px; } .lock-row b svg { width: 14px; height: 14px; }
.lock-row .sub { color: var(--muted); font-size: 12px; margin-top: 2px; } .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; }