Compare commits

..
4 Commits
12 changed files with 378 additions and 28 deletions
+1 -1
View File
@@ -62,7 +62,7 @@ RDP, SFTP, port forwarding and VPN are only available in the desktop app.
- **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
- **Import** from `~/.ssh/config` and from **Devolutions Remote Desktop Manager** (`.rdm`/XML, JSON or CSV) - **Import** from `~/.ssh/config` and from **Devolutions Remote Desktop Manager** (`.rdm`/XML, JSON or CSV)
- **Backup** export and import - **Backup** of everything (hosts, keys, passwords, snippets, forwards, VPNs, known hosts, settings), optionally encrypted with a password (scrypt + AES-256-GCM); import can merge or replace
- **Web version** for your own server (Docker), usable in any browser. See [Web version (Docker)](#web-version-docker) - **Web version** for your own server (Docker), usable in any browser. See [Web version (Docker)](#web-version-docker)
- **LAN sync**: keep several MrTerm devices in sync over your local network, end-to-end encrypted and without a server - **LAN sync**: keep several MrTerm devices in sync over your local network, end-to-end encrypted and without a server
- **7 app themes** (Midnight, Navy, Nord, Dracula, Catppuccin, Forest, Light) plus a custom accent color - **7 app themes** (Midnight, Navy, Nord, Dracula, Catppuccin, Forest, Light) plus a custom accent color
+2 -2
View File
@@ -1,12 +1,12 @@
{ {
"name": "mrterm", "name": "mrterm",
"version": "0.13.2", "version": "0.15.0",
"lockfileVersion": 3, "lockfileVersion": 3,
"requires": true, "requires": true,
"packages": { "packages": {
"": { "": {
"name": "mrterm", "name": "mrterm",
"version": "0.13.2", "version": "0.15.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.13.2", "version": "0.15.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",
+7 -5
View File
@@ -12,6 +12,7 @@ const { DockerManager } = require('../main/docker');
const { FirewallManager } = require('../main/firewall'); const { FirewallManager } = require('../main/firewall');
const { NetworkConfigManager } = require('../main/network'); const { NetworkConfigManager } = require('../main/network');
const i18n = require('../i18n'); const i18n = require('../i18n');
const backup = require('./backup');
function createBackend({ store, send, platform, version = '0.0.0', withSync = false, onLanguage = () => {} }) { function createBackend({ store, send, platform, version = '0.0.0', withSync = false, onLanguage = () => {} }) {
// Rückfragen an die Oberfläche (Hostschlüssel, Passwörter, Kopplungscode) // Rückfragen an die Oberfläche (Hostschlüssel, Passwörter, Kopplungscode)
@@ -107,11 +108,12 @@ function createBackend({ store, send, platform, version = '0.0.0', withSync = fa
}); });
handle('vault:settings', (s) => { store.setSettings(s); if ('language' in s) onLanguage(s.language); }); handle('vault:settings', (s) => { store.setSettings(s); if ('language' in s) onLanguage(s.language); });
handle('vault:forgetHost', (id) => store.forgetKnownHost(id)); handle('vault:forgetHost', (id) => store.forgetKnownHost(id));
handle('vault:exportData', () => publicData()); handle('backup:export', (opts) => backup.createBackup(store, { ...opts, version }));
handle('vault:importData', (data) => { handle('backup:inspect', (content) => backup.inspectBackup(content));
for (const col of ['groups', 'hosts', 'keys', 'snippets', 'forwards', 'vpns']) handle('backup:import', (content, password, opts) => {
for (const item of data?.[col] || []) store.upsert(col, item); const r = backup.importBackup(store, content, password, opts);
return true; if (opts?.settings) onLanguage(store.get().settings.language);
return r;
}); });
// entries: [{ folder: ['A','B'], host?: {...} }] – wie am Desktop (Import aus Remote Desktop Manager) // entries: [{ folder: ['A','B'], host?: {...} }] – wie am Desktop (Import aus Remote Desktop Manager)
handle('vault:bulkImport', (entries) => { handle('vault:bulkImport', (entries) => {
+99
View File
@@ -0,0 +1,99 @@
// Vollständiges Backup (Desktop, Web, Android): alle Collections, bekannte Hosts, Verlauf und Einstellungen.
// Optional mit Passwort verschlüsselt: scrypt (N=2^16, r=8, p=1) → AES-256-GCM.
// Gerätebezogenes (LAN-Sync-Kopplungen, Löschvermerke, App-Sperre) wird nicht exportiert.
const crypto = require('crypto');
const FORMAT = 'mrterm-backup';
const COLLECTIONS = ['groups', 'hosts', 'keys', 'snippets', 'forwards', 'vpns'];
const KDF = { N: 1 << 16, r: 8, p: 1 };
const scrypt = (password, salt, k) => crypto.scryptSync(String(password).normalize('NFC'), salt, 32, { ...k, maxmem: 256 * 1024 * 1024 });
function snapshot(store, { settings = true } = {}) {
const d = store.get();
const out = { knownHosts: d.knownHosts || {}, history: d.history || [] };
for (const c of COLLECTIONS) out[c] = d[c] || [];
if (settings) out.settings = d.settings;
return out;
}
// Liefert den Dateiinhalt (JSON-Text)
function createBackup(store, { password = '', settings = true, version = '' } = {}) {
const data = snapshot(store, { settings });
const head = { format: FORMAT, version: 2, app: version, createdAt: new Date().toISOString() };
if (!password) return JSON.stringify({ ...head, encrypted: false, data }, null, 2);
const salt = crypto.randomBytes(16);
const key = scrypt(password, salt, KDF);
const iv = crypto.randomBytes(12);
const c = crypto.createCipheriv('aes-256-gcm', key, iv);
c.setAAD(Buffer.from(FORMAT));
const ct = Buffer.concat([c.update(JSON.stringify(data)), c.final()]);
return JSON.stringify({
...head, encrypted: true,
kdf: { name: 'scrypt', salt: salt.toString('base64'), ...KDF },
cipher: { name: 'aes-256-gcm', iv: iv.toString('base64'), tag: c.getAuthTag().toString('base64') },
ct: ct.toString('base64'),
}, null, 2);
}
function parse(content) {
let j;
try { j = JSON.parse(String(content).replace(/^/, '')); } catch { throw new Error('INVALID'); }
if (!j || typeof j !== 'object') throw new Error('INVALID');
// Altes Format (bis 0.14): unverschlüsseltes JSON mit den Collections auf oberster Ebene
if (j.format !== FORMAT) {
if (!COLLECTIONS.some((c) => Array.isArray(j[c]))) throw new Error('INVALID');
return { encrypted: false, data: j, createdAt: null, app: '' };
}
return { encrypted: !!j.encrypted, raw: j, data: j.encrypted ? null : j.data, createdAt: j.createdAt, app: j.app };
}
const counts = (data) => Object.fromEntries([...COLLECTIONS.map((c) => [c, (data[c] || []).length]), ['knownHosts', Object.keys(data.knownHosts || {}).length], ['settings', data.settings ? 1 : 0]]);
// Vorabinfo für die Oberfläche (ohne Passwort)
function inspectBackup(content) {
const b = parse(content);
return { encrypted: b.encrypted, createdAt: b.createdAt, app: b.app, counts: b.data ? counts(b.data) : null };
}
function decrypt(content, password) {
const b = parse(content);
if (!b.encrypted) return b.data;
if (!password) throw new Error('PASSWORD_REQUIRED');
const { kdf, cipher, ct } = b.raw;
const key = scrypt(password, Buffer.from(kdf.salt, 'base64'), { N: kdf.N, r: kdf.r, p: kdf.p });
try {
const d = crypto.createDecipheriv('aes-256-gcm', key, Buffer.from(cipher.iv, 'base64'));
d.setAAD(Buffer.from(FORMAT));
d.setAuthTag(Buffer.from(cipher.tag, 'base64'));
return JSON.parse(Buffer.concat([d.update(Buffer.from(ct, 'base64')), d.final()]).toString('utf8'));
} catch { throw new Error('WRONG_PASSWORD'); }
}
// opts: { replace: vorhandene Einträge entfernen, die nicht im Backup sind; settings: Einstellungen übernehmen }
function importBackup(store, content, password, { replace = false, settings = false } = {}) {
const data = decrypt(content, password);
const d = store.get();
store.muted = true;
try {
for (const c of COLLECTIONS) {
const items = Array.isArray(data[c]) ? data[c].filter((x) => x && typeof x === 'object') : [];
if (replace) {
const keep = new Set(items.map((x) => x.id).filter(Boolean));
for (const x of [...(d[c] || [])]) if (!keep.has(x.id)) store.remove(c, x.id);
}
for (const item of items) store.upsert(c, { ...item });
}
if (data.knownHosts && typeof data.knownHosts === 'object') d.knownHosts = replace ? { ...data.knownHosts } : { ...d.knownHosts, ...data.knownHosts };
if (Array.isArray(data.history)) {
const seen = new Set();
d.history = [...(replace ? [] : d.history || []), ...data.history].filter((h) => h && !seen.has(h.hostId) && seen.add(h.hostId)).slice(0, 30);
}
if (settings && data.settings && typeof data.settings === 'object') d.settings = { ...d.settings, ...data.settings };
} finally {
store.muted = false;
}
store.save();
return counts(data);
}
module.exports = { createBackup, inspectBackup, importBackup };
+32
View File
@@ -351,6 +351,38 @@
'Touch your security key to unlock MrTerm.': 'Berühre deinen Sicherheitsschlüssel, um MrTerm zu entsperren.', 'Touch your security key to unlock MrTerm.': 'Berühre deinen Sicherheitsschlüssel, um MrTerm zu entsperren.',
'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.',
'This file is not a MrTerm backup.': 'Diese Datei ist kein MrTerm-Backup.',
'This backup is encrypted. Please enter the password.': 'Dieses Backup ist verschlüsselt. Bitte gib das Passwort ein.',
'Exports all hosts, groups, SSH keys, passwords, snippets, port forwards, VPNs, known hosts and settings.': 'Exportiert alle Hosts, Gruppen, SSH-Keys, Passwörter, Snippets, Portweiterleitungen, VPNs, bekannten Hosts und Einstellungen.',
'Recommended. Without a password, keys and passwords are stored in plain text in the file.': 'Empfohlen. Ohne Passwort stehen Schlüssel und Passwörter im Klartext in der Datei.',
'Include settings': 'Einstellungen einschließen',
'Export': 'Exportieren',
'Export without password?': 'Ohne Passwort exportieren?',
'Private keys and passwords will be readable by anyone who gets the file.': 'Private Schlüssel und Passwörter sind dann für jeden lesbar, der die Datei erhält.',
'{hosts} hosts, {keys} keys, {snippets} snippets, {vpns} VPNs': '{hosts} Hosts, {keys} Schlüssel, {snippets} Snippets, {vpns} VPNs',
'Contents are encrypted.': 'Inhalt ist verschlüsselt.',
'Import settings': 'Einstellungen übernehmen',
'Replace existing data (entries not contained in the backup are deleted)': 'Vorhandene Daten ersetzen (Einträge, die nicht im Backup sind, werden gelöscht)',
'Replace existing data?': 'Vorhandene Daten ersetzen?',
'Hosts, keys, snippets and other entries that are not in the backup will be deleted.': 'Hosts, Schlüssel, Snippets und andere Einträge, die nicht im Backup sind, werden gelöscht.',
'Replace': 'Ersetzen',
'Import complete: {hosts} hosts, {keys} keys, {snippets} snippets': 'Import abgeschlossen: {hosts} Hosts, {keys} Schlüssel, {snippets} Snippets',
'Templates': 'Vorlagen',
'Run': 'Ausführen',
'Authorize SSH key for a user': 'SSH-Key für Benutzer freischalten',
'Adds a public key from the keychain to ~/.ssh/authorized_keys of the user.': 'Trägt einen öffentlichen Schlüssel aus dem Schlüsselbund in ~/.ssh/authorized_keys des Benutzers ein.',
'Key': 'Schlüssel',
'Public key is missing.': 'Öffentlicher Schlüssel fehlt.',
'User is missing.': 'Benutzer fehlt.',
'Install QEMU guest agent': 'QEMU Guest Agent installieren',
'Installs and starts qemu-guest-agent (apt, dnf, yum, pacman, zypper or apk).': 'Installiert und startet qemu-guest-agent (apt, dnf, yum, pacman, zypper oder apk).',
'Use APT cache server': 'APT-Cache-Server verwenden',
'Routes APT downloads through a cache proxy such as apt-cacher-ng (/etc/apt/apt.conf.d/00proxy).': 'Leitet APT-Downloads über einen Cache-Proxy wie apt-cacher-ng (/etc/apt/apt.conf.d/00proxy).',
'Proxy URL': 'Proxy-URL',
'Invalid URL.': 'Ungültige URL.',
'Remove APT cache server': 'APT-Cache-Server entfernen',
'Removes the APT proxy setting again.': 'Entfernt die APT-Proxy-Einstellung wieder.',
'Updates all packages with the system package manager.': 'Aktualisiert alle Pakete mit dem Paketmanager des Systems.',
'Connecting to security key …': 'Verbinde mit dem Sicherheitsschlüssel …', 'Connecting to security key …': 'Verbinde mit dem Sicherheitsschlüssel …',
'Enter the PIN of your security key.': 'Gib die PIN deines Sicherheitsschlüssels ein.', 'Enter the PIN of your security key.': 'Gib die PIN deines Sicherheitsschlüssels ein.',
'Wrong PIN.': 'Falsche PIN.', 'Wrong PIN.': 'Falsche PIN.',
+45 -1
View File
@@ -210,4 +210,48 @@ async function makeCredential(ui, { timeoutMs = 60000 } = {}) {
} }
} }
module.exports = { makeCredential, CtapError, CANCEL_CODES, PIN }; // Entspricht WebAuthn-PRF (evalByCredential) über hmac-secret, ohne PIN (wie Chromium mit
// userVerification "discouraged"). creds: [{ credId: Buffer, salt: Buffer }] – salt ist der rohe PRF-Eingabewert.
// Liefert { credId: Buffer, secret: Buffer(32) }, oder null, wenn kein FIDO2-Gerät per hidraw erreichbar ist.
async function getHmacSecret(ui, creds, { timeoutMs = 60000 } = {}) {
let dev;
try { dev = Device.open(); } catch { return null; }
if (!dev) return null;
let timer;
try {
await dev.init();
const info = await dev.cbor(0x04);
if (!(info.get(1) || []).some((v) => String(v).startsWith('FIDO_2'))) return null;
const rpId = 'localhost';
const desc = (c) => new Map([['id', c.credId], ['type', 'public-key']]);
// Welches Credential liegt auf diesem Schlüssel? (Vorabprüfung ohne Berühren, up=false)
let cred = null;
for (const c of creds) {
try {
await dev.cbor(0x02, new Map([[1, rpId], [2, crypto.randomBytes(32)], [3, [desc(c)]], [5, new Map([['up', false]])]]));
cred = c; break;
} catch (e) { if (e.code !== 0x2e) throw e; }
}
if (!cred) throw new CtapError(0x2e);
const { key, platformKey } = await sharedSecret(dev);
const salt = crypto.createHash('sha256').update(Buffer.concat([Buffer.from('WebAuthn PRF\0', 'latin1'), cred.salt])).digest();
const saltEnc = aes('enc', key, salt);
const saltAuth = crypto.createHmac('sha256', key).update(saltEnc).digest().subarray(0, 16);
ui.touch(() => dev.cancel());
timer = setTimeout(() => dev.cancel(), timeoutMs);
const r = await dev.cbor(0x02, new Map([
[1, rpId], [2, crypto.randomBytes(32)], [3, [desc(cred)]],
[4, new Map([['hmac-secret', new Map([[1, platformKey], [2, saltEnc], [3, saltAuth]])]])],
]));
const authData = r.get(2);
if (!(authData[32] & 0x80)) throw new CtapError(-1);
const ext = dec(authData, 37)[0].get('hmac-secret');
if (!ext) throw new CtapError(-1);
return { credId: Buffer.from(cred.credId), secret: aes('dec', key, ext).subarray(0, 32) };
} finally {
clearTimeout(timer);
dev.close();
}
}
module.exports = { makeCredential, getHmacSecret, CtapError, CANCEL_CODES, PIN };
+24
View File
@@ -99,6 +99,26 @@ async function registerNative(parent) {
} }
} }
// PRF-Abfrage direkt per CTAP2 (Linux); null, wenn kein FIDO2-Gerät per hidraw erreichbar ist
async function deriveNative(parent, creds, text) {
const w = await openWindow(parent, text || i18n.t('Touch your security key to unlock MrTerm.'));
let abort = () => {};
let cancelled = false;
w.once('closed', () => { cancelled = true; abort(); });
try {
const r = await ctap2.getHmacSecret({ touch(a) { abort = a; if (cancelled) a(); } },
creds.map((c) => ({ credId: Buffer.from(c.credId, 'base64url'), salt: Buffer.from(c.prfSalt, 'base64url') })));
return r && { credId: b64url(r.credId), secret: r.secret };
} catch (e) {
if (cancelled || ctap2.CANCEL_CODES.has(e.code)) throw new Error(i18n.t('Security key prompt was cancelled or timed out.'));
if (e.code === 0x2e) throw new Error(i18n.t('Unknown security key.'));
if (e.code === -1) throw new Error(i18n.t('This security key does not support the hmac-secret/PRF extension.'));
throw e;
} finally {
if (!w.isDestroyed()) w.destroy();
}
}
const b64url = (buf) => Buffer.from(buf).toString('base64url'); const b64url = (buf) => Buffer.from(buf).toString('base64url');
// Neuen Schlüssel registrieren; liefert die Credential-ID (base64url) // Neuen Schlüssel registrieren; liefert die Credential-ID (base64url)
@@ -124,6 +144,10 @@ async function register(parent) {
// PRF-Wert für einen der Schlüssel abfragen. creds: [{ credId, prfSalt }] (base64url) // PRF-Wert für einen der Schlüssel abfragen. creds: [{ credId, prfSalt }] (base64url)
// Liefert { credId, secret: Buffer(32) } // Liefert { credId, secret: Buffer(32) }
async function derive(parent, creds, text) { async function derive(parent, creds, text) {
if (process.platform === 'linux') {
const r = await deriveNative(parent, creds, text);
if (r) return r;
}
const r = await ceremony(parent, text || i18n.t('Touch your security key to unlock MrTerm.'), ` const r = await ceremony(parent, text || i18n.t('Touch your security key to unlock MrTerm.'), `
const creds = ${JSON.stringify(creds)}; const creds = ${JSON.stringify(creds)};
const evalByCredential = Object.fromEntries(creds.map((c) => [c.credId, { first: unb64(c.prfSalt) }])); const evalByCredential = Object.fromEntries(creds.map((c) => [c.credId, { first: unb64(c.prfSalt) }]));
+12 -11
View File
@@ -16,6 +16,7 @@ const { DockerManager } = require('./docker');
const { FirewallManager } = require('./firewall'); const { FirewallManager } = require('./firewall');
const { NetworkConfigManager } = require('./network'); const { NetworkConfigManager } = require('./network');
const { SyncService } = require('./sync'); const { SyncService } = require('./sync');
const backup = require('../core/backup');
const applyLanguage = () => i18n.setLanguage(store.get().settings.language, app.getLocale()); const applyLanguage = () => i18n.setLanguage(store.get().settings.language, app.getLocale());
let win; let win;
@@ -280,19 +281,19 @@ handle('vault:settings', (s) => {
if ('rdpEmbed' in s) fs.writeFileSync(launchFile, JSON.stringify({ ...readLaunch(), x11: s.rdpEmbed !== false })); if ('rdpEmbed' in s) fs.writeFileSync(launchFile, JSON.stringify({ ...readLaunch(), x11: s.rdpEmbed !== false }));
}); });
handle('vault:forgetHost', (id) => store.forgetKnownHost(id)); handle('vault:forgetHost', (id) => store.forgetKnownHost(id));
handle('vault:export', async () => { // Backup: vollständig, optional per Passwort verschlüsselt (src/core/backup.js)
const r = await dialog.showSaveDialog(win, { defaultPath: 'mrterm-backup.json', filters: [{ name: 'JSON', extensions: ['json'] }] }); handle('backup:export', (opts) => backup.createBackup(store, { ...opts, version: app.getVersion() }));
if (r.canceled) return false; handle('backup:inspect', (content) => backup.inspectBackup(content));
fs.writeFileSync(r.filePath, JSON.stringify(publicData(), null, 2), { mode: 0o600 }); handle('backup:import', (content, password, opts) => {
return r.filePath; const r = backup.importBackup(store, content, password, opts);
if (opts?.settings) applyLanguage();
return r;
}); });
handle('vault:import', async () => { handle('backup:saveFile', async (content, name) => {
const r = await dialog.showOpenDialog(win, { filters: [{ name: 'JSON', extensions: ['json'] }], properties: ['openFile'] }); const r = await dialog.showSaveDialog(win, { defaultPath: name, filters: [{ name: 'MrTerm Backup', extensions: ['json'] }] });
if (r.canceled) return false; if (r.canceled) return false;
const data = JSON.parse(fs.readFileSync(r.filePaths[0], 'utf8')); fs.writeFileSync(r.filePath, content, { mode: 0o600 });
for (const col of ['groups', 'hosts', 'keys', 'snippets', 'forwards', 'vpns']) return r.filePath;
for (const item of data[col] || []) store.upsert(col, item);
return true;
}); });
// Import aus ~/.ssh/config // Import aus ~/.ssh/config
+148 -5
View File
@@ -658,7 +658,7 @@ function viewSnippets(page) {
page.append(toolbar(T('Search snippets …'), [{ label: T('New snippet'), icon: 'plus', cls: 'primary', run: () => editSnippet() }])); page.append(toolbar(T('Search snippets …'), [{ label: T('New snippet'), icon: 'plus', cls: 'primary', run: () => editSnippet() }]));
const c = h('<div class="content"></div>'); page.append(c); const c = h('<div class="content"></div>'); page.append(c);
const list = S.vault.snippets.filter((s) => matches(s.label, s.command)); const list = S.vault.snippets.filter((s) => matches(s.label, s.command));
if (!list.length) return c.append(emptyState('code', T('No snippets'), T('Save frequently used commands and send them to a terminal with one click.'), T('New snippet'), () => editSnippet())); if (!list.length) { c.append(emptyState('code', T('No snippets'), T('Save frequently used commands and send them to a terminal with one click.'), T('New snippet'), () => editSnippet())); c.append(templateList()); return; }
const l = h('<div class="list"></div>'); const l = h('<div class="list"></div>');
for (const s of list) { for (const s of list) {
const card = h(`<div class="card"><div class="avatar" style="background:var(--icon-bg);color:var(--green)">${ICONS.code}</div><div class="meta"><div class="title">${esc(s.label)}</div><div class="sub mono">${esc(s.command.split('\n')[0])}${s.command.includes('\n') ? ' …' : ''}</div></div><div class="actions"><button data-a="run" title="${T('Run in active terminal')}">${ICONS.play}</button><button data-a="copy" title="${T('Copy')}">${ICONS.copy}</button><button data-a="del" title="${T('Delete')}">${ICONS.trash}</button></div></div>`); const card = h(`<div class="card"><div class="avatar" style="background:var(--icon-bg);color:var(--green)">${ICONS.code}</div><div class="meta"><div class="title">${esc(s.label)}</div><div class="sub mono">${esc(s.command.split('\n')[0])}${s.command.includes('\n') ? ' …' : ''}</div></div><div class="actions"><button data-a="run" title="${T('Run in active terminal')}">${ICONS.play}</button><button data-a="copy" title="${T('Copy')}">${ICONS.copy}</button><button data-a="del" title="${T('Delete')}">${ICONS.trash}</button></div></div>`);
@@ -671,7 +671,7 @@ function viewSnippets(page) {
}; };
l.append(card); l.append(card);
} }
c.append(l); c.append(l, templateList());
} }
function editSnippet(s = {}) { function editSnippet(s = {}) {
const form = openDrawer(s.id ? T('Edit snippet') : T('New snippet'), [ const form = openDrawer(s.id ? T('Edit snippet') : T('New snippet'), [
@@ -685,6 +685,86 @@ function editSnippet(s = {}) {
reload(); reload();
}); });
} }
// Eingebaute Vorlagen (nicht im Vault). Parameter werden vor dem Ausführen abgefragt; build() liefert eine Zeile.
// $S = sudo, außer man ist bereits root.
const shq = (v) => `'${String(v).replace(/'/g, `'\\''`)}'`;
const SUDO = 'S=$([ "$(id -u)" = 0 ] || echo sudo);';
const SNIPPET_TEMPLATES = [
{
label: () => T('Authorize SSH key for a user'),
desc: () => T('Adds a public key from the keychain to ~/.ssh/authorized_keys of the user.'),
params: () => [
S.vault.keys.some((k) => k.publicKey)
? field(T('Key'), 'key', '', { type: 'select', options: S.vault.keys.filter((k) => k.publicKey).map((k) => [k.id, k.label]) })
: field('Public Key', 'pub', '', { type: 'textarea', placeholder: 'ssh-ed25519 AAAA…' }),
field(T('User'), 'user', 'root'),
],
build: (v) => {
const pub = (v.key ? S.vault.keys.find((k) => k.id === v.key)?.publicKey : v.pub || '').trim();
if (!pub) throw new Error(T('Public key is missing.'));
if (!v.user.trim()) throw new Error(T('User is missing.'));
return `${SUDO} u=${shq(v.user.trim())}; k=${shq(pub)}; d="$(getent passwd "$u" | cut -d: -f6)/.ssh"; `
+ `$S install -d -m 700 -o "$u" -g "$(id -gn "$u")" "$d" && { $S grep -qxF "$k" "$d/authorized_keys" 2>/dev/null || echo "$k" | $S tee -a "$d/authorized_keys" >/dev/null; } `
+ `&& $S chown "$u": "$d/authorized_keys" && $S chmod 600 "$d/authorized_keys" && echo "OK: $d/authorized_keys"`;
},
},
{
label: () => T('Install QEMU guest agent'),
desc: () => T('Installs and starts qemu-guest-agent (apt, dnf, yum, pacman, zypper or apk).'),
build: () => `${SUDO} if command -v apt-get >/dev/null; then $S apt-get update && $S apt-get install -y qemu-guest-agent; `
+ 'elif command -v dnf >/dev/null; then $S dnf install -y qemu-guest-agent; elif command -v yum >/dev/null; then $S yum install -y qemu-guest-agent; '
+ 'elif command -v pacman >/dev/null; then $S pacman -S --needed --noconfirm qemu-guest-agent; elif command -v zypper >/dev/null; then $S zypper -n install qemu-guest-agent; '
+ 'elif command -v apk >/dev/null; then $S apk add qemu-guest-agent && $S rc-update add qemu-guest-agent && $S rc-service qemu-guest-agent start; fi; '
+ 'command -v systemctl >/dev/null && { $S systemctl enable --now qemu-guest-agent 2>/dev/null || $S systemctl start qemu-guest-agent; }; '
+ 'command -v systemctl >/dev/null && systemctl is-active qemu-guest-agent',
},
{
label: () => T('Use APT cache server'),
desc: () => T('Routes APT downloads through a cache proxy such as apt-cacher-ng (/etc/apt/apt.conf.d/00proxy).'),
params: () => [field(T('Proxy URL'), 'url', 'http://', { placeholder: 'http://192.168.1.10:3142' })],
build: (v) => {
const url = v.url.trim();
if (!/^https?:\/\/[^\s/]+/.test(url)) throw new Error(T('Invalid URL.'));
return `${SUDO} echo ${shq(`Acquire::http::Proxy "${url}";`)} | $S tee /etc/apt/apt.conf.d/00proxy && $S apt-get update`;
},
},
{
label: () => T('Remove APT cache server'),
desc: () => T('Removes the APT proxy setting again.'),
build: () => `${SUDO} $S rm -f /etc/apt/apt.conf.d/00proxy && $S apt-get update`,
},
{
label: () => T('System update'),
desc: () => T('Updates all packages with the system package manager.'),
build: () => `${SUDO} if command -v apt-get >/dev/null; then $S apt-get update && $S apt-get -y full-upgrade; elif command -v dnf >/dev/null; then $S dnf -y upgrade; `
+ 'elif command -v pacman >/dev/null; then $S pacman -Syu --noconfirm; elif command -v zypper >/dev/null; then $S zypper -n update; elif command -v apk >/dev/null; then $S apk upgrade --update; fi',
},
];
// Fragt die Parameter einer Vorlage ab und schickt den Befehl ins Terminal
async function runTemplate(t, session) {
const params = t.params ? t.params() : [];
let v = {};
if (params.length) {
const body = `<p style="margin-top:0;color:var(--muted)">${esc(t.desc())}</p>${params.map((el) => el.outerHTML).join('')}`;
v = await modal({ title: t.label(), body, buttons: [{ label: T('Cancel'), value: null, cls: 'ghost' }, { label: T('Run'), value: 'form', cls: 'primary' }] });
if (!v) return;
}
let command;
try { command = t.build(v); } catch (e) { return toast(e.message, 'error'); }
runSnippet({ command, autoRun: true }, session);
}
function templateList(session) {
const d = h(`<details class="snip-tpl"><summary>${T('Templates')}</summary><div class="tpl-items"></div></details>`);
for (const t of SNIPPET_TEMPLATES) {
const it = h(`<div class="it"><div>${esc(t.label())}</div><div class="hint">${esc(t.desc())}</div></div>`);
it.onclick = () => runTemplate(t, session);
$('.tpl-items', d).append(it);
}
return d;
}
function runSnippet(s, session) { function runSnippet(s, session) {
const t = session || S.tabs.find((x) => x.id === S.active && x.kind === 'ssh') || [...S.tabs].reverse().find((x) => x.kind === 'ssh'); const t = session || S.tabs.find((x) => x.id === S.active && x.kind === 'ssh') || [...S.tabs].reverse().find((x) => x.kind === 'ssh');
if (!t) return toast(T('No open terminal'), 'error'); if (!t) return toast(T('No open terminal'), 'error');
@@ -992,8 +1072,8 @@ async function viewSettings(page) {
const dataCard = h(`<div class="settings-card"><h3>${T('Data')}</h3><p style="color:var(--muted);margin-top:0">${WEB ? T('Your vault is stored on the server, encrypted with a key that only your login password can unlock.') : S.vault.encrypted ? T('The vault is encrypted with the operating system keyring (Windows DPAPI / libsecret or KWallet).') : T('The vault is not encrypted because no keyring is available. On Arch/CachyOS: install <code>gnome-keyring</code> or <code>kwallet</code>.')}</p><div class="row" style="flex-wrap:wrap"></div></div>`); const dataCard = h(`<div class="settings-card"><h3>${T('Data')}</h3><p style="color:var(--muted);margin-top:0">${WEB ? T('Your vault is stored on the server, encrypted with a key that only your login password can unlock.') : S.vault.encrypted ? T('The vault is encrypted with the operating system keyring (Windows DPAPI / libsecret or KWallet).') : T('The vault is not encrypted because no keyring is available. On Arch/CachyOS: install <code>gnome-keyring</code> or <code>kwallet</code>.')}</p><div class="row" style="flex-wrap:wrap"></div></div>`);
const btns = [ const btns = [
...(WEB ? [] : [[T('Import ~/.ssh/config'), importSshConfig]]), ...(WEB ? [] : [[T('Import ~/.ssh/config'), importSshConfig]]),
[T('Export backup'), async () => { const p = await call('vault:export'); if (p) toast(T('Exported to {path}', { path: p }), 'ok'); }], [T('Export backup'), exportBackup],
[T('Import backup'), async () => { if (await call('vault:import')) { toast(T('Import complete'), 'ok'); reload(); } }], [T('Import backup'), importBackup],
]; ];
btns.forEach(([l, fn]) => { const b = h(`<button class="btn">${esc(l)}</button>`); b.onclick = fn; $('.row', dataCard).append(b); }); btns.forEach(([l, fn]) => { const b = h(`<button class="btn">${esc(l)}</button>`); b.onclick = fn; $('.row', dataCard).append(b); });
@@ -1336,13 +1416,75 @@ async function importRdm() {
$('[data-view=hosts]').click(); $('[data-view=hosts]').click();
} }
// ============================================================ Backup
const BACKUP_ERRORS = () => ({ INVALID: T('This file is not a MrTerm backup.'), WRONG_PASSWORD: T('Wrong password.'), PASSWORD_REQUIRED: T('This backup is encrypted. Please enter the password.') });
const backupError = (e) => BACKUP_ERRORS()[e.message] || e.message;
async function exportBackup() {
let err = '';
for (;;) {
const r = await modal({
title: T('Export backup'),
body: `<p style="margin-top:0;color:var(--muted)">${T('Exports all hosts, groups, SSH keys, passwords, snippets, port forwards, VPNs, known hosts and settings.')}</p>
${err ? `<p style="color:var(--red)">${esc(err)}</p>` : ''}
${field(T('Password'), 'pw', '', { type: 'password', hint: T('Recommended. Without a password, keys and passwords are stored in plain text in the file.') }).outerHTML}
${field(T('Repeat password'), 'pw2', '', { type: 'password' }).outerHTML}
${check(T('Include settings'), 'settings', true).outerHTML}`,
buttons: [{ label: T('Cancel'), value: null, cls: 'ghost' }, { label: T('Export'), value: 'form', cls: 'primary' }],
});
if (!r) return;
if (r.pw !== r.pw2) { err = T('The passwords do not match.'); continue; }
if (r.pw && r.pw.length < 8) { err = T('The password must be at least 8 characters long.'); continue; }
if (!r.pw && !(await confirmBox(T('Export without password?'), T('Private keys and passwords will be readable by anyone who gets the file.'), T('Export')))) continue;
try {
const content = await api.call('backup:export', { password: r.pw, settings: r.settings });
const name = `mrterm-backup-${new Date().toISOString().slice(0, 10)}${r.pw ? '-encrypted' : ''}.json`;
const p = await call('backup:saveFile', content, name);
if (p) toast(T('Exported to {path}', { path: p }), 'ok');
} catch (e) { toast(backupError(e), 'error'); }
return;
}
}
async function importBackup() {
const f = await call('import:pickFile', 'MrTerm Backup', ['json']);
if (!f) return;
let info;
try { info = await api.call('backup:inspect', f.content); } catch (e) { return toast(backupError(e), 'error'); }
const c = info.counts;
const summary = c ? T('{hosts} hosts, {keys} keys, {snippets} snippets, {vpns} VPNs', c) : T('Contents are encrypted.');
let err = '';
for (;;) {
const r = await modal({
title: T('Import backup'),
body: `<p style="margin-top:0;color:var(--muted)">${esc(f.name)}${info.createdAt ? ' · ' + esc(new Date(info.createdAt).toLocaleString()) : ''}<br>${esc(summary)}</p>
${err ? `<p style="color:var(--red)">${esc(err)}</p>` : ''}
${info.encrypted ? field(T('Password'), 'pw', '', { type: 'password' }).outerHTML : ''}
${check(T('Import settings'), 'settings', false).outerHTML}
${check(T('Replace existing data (entries not contained in the backup are deleted)'), 'replace', false).outerHTML}`,
buttons: [{ label: T('Cancel'), value: null, cls: 'ghost' }, { label: T('Import'), value: 'form', cls: 'primary' }],
});
if (!r) return;
if (r.replace && !(await confirmBox(T('Replace existing data?'), T('Hosts, keys, snippets and other entries that are not in the backup will be deleted.'), T('Replace')))) continue;
try {
const n = await api.call('backup:import', f.content, r.pw || '', { replace: r.replace, settings: r.settings });
toast(T('Import complete: {hosts} hosts, {keys} keys, {snippets} snippets', n), 'ok');
reload();
return;
} catch (e) { err = backupError(e); }
}
}
// ============================================================ Updates // ============================================================ Updates
let updateInfo = null; let updateInfo = null;
let updateDialogOpen = false; // Auto-Prüfung beim Start und manuelle Prüfung sollen keine zwei Dialoge stapeln
async function showUpdate(info) { async function showUpdate(info) {
updateInfo = info; updateInfo = info;
const b = $('#updateBadge'); const b = $('#updateBadge');
b.style.display = ''; b.style.display = '';
b.querySelector('span').textContent = `Update ${info.version}`; b.querySelector('span').textContent = `Update ${info.version}`;
if (updateDialogOpen) return;
updateDialogOpen = true;
const r = await modal({ const r = await modal({
title: T('MrTerm {v} is available', { v: info.version }), title: T('MrTerm {v} is available', { v: info.version }),
text: T('Installed: {v}', { v: info.current }) + (info.asset ? ' · ' + T('Package: {name} ({size})', { name: info.asset.name, size: fmtSize(info.asset.size) }) : ''), text: T('Installed: {v}', { v: info.current }) + (info.asset ? ' · ' + T('Package: {name} ({size})', { name: info.asset.name, size: fmtSize(info.asset.size) }) : ''),
@@ -1350,7 +1492,7 @@ async function showUpdate(info) {
${info.kind === 'dev' ? `<p>${T('Development mode: please update via <code>git pull</code>.')}</p>` : !info.asset ? `<p>${T('The release contains no package for this system.')}</p>` : ''} ${info.kind === 'dev' ? `<p>${T('Development mode: please update via <code>git pull</code>.')}</p>` : !info.asset ? `<p>${T('The release contains no package for this system.')}</p>` : ''}
<div class="upd-prog" style="display:none"><div class="transfer" style="padding:6px 0"><span class="nm">${T('Downloading …')}</span><span class="pct"></span><div class="bar"><i></i></div></div></div>`, <div class="upd-prog" style="display:none"><div class="transfer" style="padding:6px 0"><span class="nm">${T('Downloading …')}</span><span class="pct"></span><div class="bar"><i></i></div></div></div>`,
buttons: [{ label: T('Later'), value: false, cls: 'ghost' }, { label: T('Release page'), value: 'web', cls: '' }, ...(info.asset && info.kind !== 'dev' ? [{ label: T('Install now'), value: true, cls: 'primary' }] : [])], buttons: [{ label: T('Later'), value: false, cls: 'ghost' }, { label: T('Release page'), value: 'web', cls: '' }, ...(info.asset && info.kind !== 'dev' ? [{ label: T('Install now'), value: true, cls: 'primary' }] : [])],
}); }).finally(() => { updateDialogOpen = false; });
if (r === 'web') return api.call('shell:open', info.url); if (r === 'web') return api.call('shell:open', info.url);
if (r !== true) return; if (r !== true) return;
toast(T('Downloading update …')); toast(T('Downloading update …'));
@@ -1501,6 +1643,7 @@ class TerminalSession {
it.onclick = () => runSnippet(s, this); it.onclick = () => runSnippet(s, this);
box.append(it); box.append(it);
}); });
box.append(templateList(this));
} }
toggleFind(open) { toggleFind(open) {
+6
View File
@@ -191,6 +191,12 @@ body.in-session #sidebar { display: none; }
.snip-panel .items { overflow: auto; padding: 8px; display: flex; flex-direction: column; gap: 6px; } .snip-panel .items { overflow: auto; padding: 8px; display: flex; flex-direction: column; gap: 6px; }
.snip-panel .it { background: var(--card); border-radius: 8px; padding: 9px 10px; cursor: pointer; } .snip-panel .it { background: var(--card); border-radius: 8px; padding: 9px 10px; cursor: pointer; }
.snip-panel .it:hover { background: var(--card-hover); } .snip-panel .it:hover { background: var(--card-hover); }
.snip-tpl { margin-top: 10px; }
.snip-tpl summary { cursor: pointer; color: var(--muted); font-weight: 600; padding: 6px 2px; user-select: none; }
.snip-tpl .tpl-items { display: flex; flex-direction: column; gap: 6px; margin-top: 4px; }
.snip-tpl .it { background: var(--card); border-radius: 8px; padding: 9px 10px; cursor: pointer; }
.snip-tpl .it:hover { background: var(--card-hover); }
.snip-tpl .hint { color: var(--muted); font-size: 12px; margin-top: 3px; }
.snip-panel .it .mono { color: var(--muted); white-space: nowrap; overflow: hidden; text-overflow: ellipsis; margin-top: 3px; } .snip-panel .it .mono { color: var(--muted); white-space: nowrap; overflow: hidden; text-overflow: ellipsis; margin-top: 3px; }
.findbar { position: absolute; top: 8px; right: 24px; z-index: 4; display: none; gap: 4px; align-items: center; background: var(--panel); border: 1px solid var(--border); border-radius: 8px; padding: 4px; box-shadow: 0 8px 24px #0008; } .findbar { position: absolute; top: 8px; right: 24px; z-index: 4; display: none; gap: 4px; align-items: center; background: var(--panel); border: 1px solid var(--border); border-radius: 8px; padding: 4px; box-shadow: 0 8px 24px #0008; }
.findbar.open { display: flex; } .findbar.open { display: flex; }
+1 -2
View File
@@ -79,8 +79,7 @@
throw new Error('Pasting from the menu needs HTTPS. Use Ctrl+Shift+V or Ctrl+V instead.'); throw new Error('Pasting from the menu needs HTTPS. Use Ctrl+Shift+V or Ctrl+V instead.');
}, },
'shell:open': (url) => { if (/^https?:\/\//i.test(url)) window.open(url, '_blank', 'noopener'); }, 'shell:open': (url) => { if (/^https?:\/\//i.test(url)) window.open(url, '_blank', 'noopener'); },
'vault:export': async () => { downloadText('mrterm-backup.json', JSON.stringify(await remote('vault:exportData', []), null, 2)); return 'mrterm-backup.json'; }, 'backup:saveFile': (content, name) => { downloadText(name, content); return name; },
'vault:import': async () => { const f = await pickFile('.json,application/json'); if (!f) return false; return remote('vault:importData', [JSON.parse(f.content)]); },
'key:pickFile': async () => { const f = await pickFile(); return f ? { name: f.name, content: f.content, publicKey: '' } : null; }, 'key:pickFile': async () => { const f = await pickFile(); return f ? { name: f.name, content: f.content, publicKey: '' } : null; },
'import:pickFile': async () => pickFile(), 'import:pickFile': async () => pickFile(),
'vault:importSshConfig': () => { throw new Error('Not available in the web version.'); }, 'vault:importSshConfig': () => { throw new Error('Not available in the web version.'); },