MrTerm: SSH/SFTP/RDP-Client im Termius-Stil (Electron)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,266 @@
|
||||
const { app, BrowserWindow, ipcMain, dialog, shell, clipboard, Menu } = require('electron');
|
||||
const path = require('path');
|
||||
const fs = require('fs');
|
||||
const os = require('os');
|
||||
const crypto = require('crypto');
|
||||
const { utils: sshUtils } = require('ssh2');
|
||||
const { Store } = require('./store');
|
||||
const { SshManager } = require('./ssh');
|
||||
const rdp = require('./rdp');
|
||||
|
||||
let win;
|
||||
const store = new Store();
|
||||
const pendingSecrets = new Map();
|
||||
|
||||
function send(channel, ...args) {
|
||||
if (win && !win.isDestroyed()) win.webContents.send(channel, ...args);
|
||||
}
|
||||
|
||||
async function confirmHostKey(target, fingerprint, known) {
|
||||
if (process.env.MRTERM_SMOKE) return true;
|
||||
const changed = !!known;
|
||||
const r = await dialog.showMessageBox(win, {
|
||||
type: changed ? 'warning' : 'question',
|
||||
title: changed ? 'Host-Schlüssel hat sich geändert!' : 'Unbekannter Host',
|
||||
message: changed
|
||||
? `WARNUNG: Der Host-Schlüssel von ${target.host}:${target.port} hat sich geändert. Das kann auf einen Man-in-the-Middle-Angriff hindeuten.`
|
||||
: `Die Echtheit von ${target.host}:${target.port} kann nicht bestätigt werden.`,
|
||||
detail: `Fingerprint:\n${fingerprint}${changed ? `\n\nBisher gespeichert:\n${known.fingerprint}` : ''}`,
|
||||
buttons: [changed ? 'Trotzdem verbinden & ersetzen' : 'Vertrauen & verbinden', 'Abbrechen'],
|
||||
defaultId: changed ? 1 : 0,
|
||||
cancelId: 1,
|
||||
});
|
||||
return r.response === 0;
|
||||
}
|
||||
|
||||
function askSecret(sessionId, req) {
|
||||
return new Promise((resolve) => {
|
||||
const reqId = crypto.randomUUID();
|
||||
pendingSecrets.set(reqId, resolve);
|
||||
send('secret:request', { reqId, sessionId, ...req });
|
||||
});
|
||||
}
|
||||
|
||||
const ssh = new SshManager(store, confirmHostKey, askSecret);
|
||||
|
||||
function createWindow() {
|
||||
win = new BrowserWindow({
|
||||
width: 1360,
|
||||
height: 860,
|
||||
minWidth: 900,
|
||||
minHeight: 560,
|
||||
backgroundColor: '#14161d',
|
||||
frame: false,
|
||||
titleBarStyle: process.platform === 'darwin' ? 'hiddenInset' : 'hidden',
|
||||
webPreferences: {
|
||||
preload: path.join(__dirname, '..', 'preload.js'),
|
||||
contextIsolation: true,
|
||||
nodeIntegration: false,
|
||||
sandbox: true,
|
||||
},
|
||||
});
|
||||
Menu.setApplicationMenu(null);
|
||||
win.loadFile(path.join(__dirname, '..', 'renderer', 'index.html'));
|
||||
win.on('maximize', () => send('win:state', true));
|
||||
win.on('unmaximize', () => send('win:state', false));
|
||||
win.webContents.setWindowOpenHandler(({ url }) => { shell.openExternal(url); return { action: 'deny' }; });
|
||||
}
|
||||
|
||||
function handle(channel, fn) {
|
||||
ipcMain.handle(channel, async (_e, ...args) => {
|
||||
try { return { ok: true, value: await fn(...args) }; }
|
||||
catch (e) { return { ok: false, error: e.message || String(e) }; }
|
||||
});
|
||||
}
|
||||
|
||||
// ---------- Fenster ----------
|
||||
ipcMain.on('win:min', () => win.minimize());
|
||||
ipcMain.on('win:max', () => (win.isMaximized() ? win.unmaximize() : win.maximize()));
|
||||
ipcMain.on('win:close', () => win.close());
|
||||
|
||||
// ---------- 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:settings', (s) => store.setSettings(s));
|
||||
handle('vault:forgetHost', (id) => { delete store.get().knownHosts[id]; store.save(); });
|
||||
handle('vault:export', async () => {
|
||||
const r = await dialog.showSaveDialog(win, { defaultPath: 'mrterm-backup.json', filters: [{ name: 'JSON', extensions: ['json'] }] });
|
||||
if (r.canceled) return false;
|
||||
fs.writeFileSync(r.filePath, JSON.stringify(store.get(), null, 2), { mode: 0o600 });
|
||||
return r.filePath;
|
||||
});
|
||||
handle('vault:import', async () => {
|
||||
const r = await dialog.showOpenDialog(win, { filters: [{ name: 'JSON', extensions: ['json'] }], properties: ['openFile'] });
|
||||
if (r.canceled) return false;
|
||||
const data = JSON.parse(fs.readFileSync(r.filePaths[0], 'utf8'));
|
||||
for (const col of ['groups', 'hosts', 'keys', 'snippets', 'forwards'])
|
||||
for (const item of data[col] || []) store.upsert(col, item);
|
||||
return true;
|
||||
});
|
||||
|
||||
// Import aus ~/.ssh/config
|
||||
handle('vault:importSshConfig', () => {
|
||||
const file = path.join(os.homedir(), '.ssh', 'config');
|
||||
if (!fs.existsSync(file)) throw new Error('~/.ssh/config nicht gefunden');
|
||||
const entries = [];
|
||||
let cur = null;
|
||||
for (const raw of fs.readFileSync(file, 'utf8').split(/\r?\n/)) {
|
||||
const line = raw.trim();
|
||||
if (!line || line.startsWith('#')) continue;
|
||||
const [k, ...rest] = line.split(/\s+/);
|
||||
const v = rest.join(' ');
|
||||
if (k.toLowerCase() === 'host') {
|
||||
cur = v.includes('*') ? null : { label: v, address: v, port: 22, protocol: 'ssh' };
|
||||
if (cur) entries.push(cur);
|
||||
} else if (cur) {
|
||||
const key = k.toLowerCase();
|
||||
if (key === 'hostname') cur.address = v;
|
||||
else if (key === 'user') cur.username = v;
|
||||
else if (key === 'port') cur.port = Number(v);
|
||||
else if (key === 'identityfile') cur._identity = v.replace(/^~/, os.homedir());
|
||||
}
|
||||
}
|
||||
let n = 0;
|
||||
for (const e of entries) {
|
||||
if (store.get().hosts.some((h) => h.address === e.address && h.username === e.username && Number(h.port) === e.port)) continue;
|
||||
if (e._identity && fs.existsSync(e._identity)) {
|
||||
const pk = fs.readFileSync(e._identity, 'utf8');
|
||||
let key = store.get().keys.find((k) => k.privateKey === pk);
|
||||
if (!key) key = store.upsert('keys', { label: path.basename(e._identity), privateKey: pk, publicKey: readPub(e._identity) });
|
||||
e.keyId = key.id;
|
||||
}
|
||||
delete e._identity;
|
||||
store.upsert('hosts', e);
|
||||
n++;
|
||||
}
|
||||
return n;
|
||||
});
|
||||
|
||||
function readPub(p) {
|
||||
try { return fs.readFileSync(p + '.pub', 'utf8').trim(); } catch { return ''; }
|
||||
}
|
||||
|
||||
// ---------- Schlüssel ----------
|
||||
handle('key:generate', ({ type, bits, comment, passphrase }) => {
|
||||
const opts = { comment: comment || `${os.userInfo().username}@mrterm` };
|
||||
if (type === 'rsa') opts.bits = Number(bits) || 4096;
|
||||
if (passphrase) { opts.passphrase = passphrase; opts.cipher = 'aes256-cbc'; }
|
||||
const k = sshUtils.generateKeyPairSync(type === 'rsa' ? 'rsa' : type === 'ecdsa' ? 'ecdsa' : 'ed25519', opts);
|
||||
return { privateKey: k.private, publicKey: k.public };
|
||||
});
|
||||
handle('key:parse', ({ privateKey, passphrase }) => {
|
||||
const k = sshUtils.parseKey(privateKey, passphrase || undefined);
|
||||
if (k instanceof Error) throw k;
|
||||
const key = Array.isArray(k) ? k[0] : k;
|
||||
return { type: key.type, publicKey: `${key.type} ${key.getPublicSSH().toString('base64')} ${key.comment || ''}`.trim() };
|
||||
});
|
||||
handle('key:pickFile', async () => {
|
||||
const r = await dialog.showOpenDialog(win, { defaultPath: path.join(os.homedir(), '.ssh'), properties: ['openFile', 'showHiddenFiles'] });
|
||||
if (r.canceled) return null;
|
||||
return { name: path.basename(r.filePaths[0]), content: fs.readFileSync(r.filePaths[0], 'utf8'), publicKey: readPub(r.filePaths[0]) };
|
||||
});
|
||||
|
||||
// ---------- SSH-Terminal ----------
|
||||
function hostWithOverrides(hostOrId) {
|
||||
if (typeof hostOrId === 'string') {
|
||||
const h = store.resolveHost(hostOrId);
|
||||
if (!h) throw new Error('Host nicht gefunden');
|
||||
return h;
|
||||
}
|
||||
return hostOrId; // Quick-Connect: temporärer Host
|
||||
}
|
||||
|
||||
handle('ssh:open', async (sessionId, hostRef, size) => {
|
||||
const host = hostWithOverrides(hostRef);
|
||||
if (host.id) store.addHistory({ hostId: host.id, at: Date.now() });
|
||||
await ssh.openShell(sessionId, host, size, (type, payload) => send('ssh:event', sessionId, type, payload));
|
||||
return true;
|
||||
});
|
||||
ipcMain.on('ssh:write', (_e, id, data) => ssh.write(id, data));
|
||||
ipcMain.on('ssh:resize', (_e, id, cols, rows) => ssh.resize(id, cols, rows));
|
||||
ipcMain.on('ssh:close', (_e, id) => ssh.close(id));
|
||||
ipcMain.on('secret:reply', (_e, reqId, value) => {
|
||||
pendingSecrets.get(reqId)?.(value);
|
||||
pendingSecrets.delete(reqId);
|
||||
});
|
||||
|
||||
// ---------- SFTP ----------
|
||||
handle('sftp:open', (sessionId, hostRef) => ssh.openSftp(sessionId, hostWithOverrides(hostRef)));
|
||||
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) => {
|
||||
let last = 0;
|
||||
await ssh.transfer(id, dir, localPath, remotePath, (done, total) => {
|
||||
const now = Date.now();
|
||||
if (now - last > 100 || done === total) { last = now; send('sftp:progress', transferId, done, total, dir === 'download' ? remotePath : localPath); }
|
||||
});
|
||||
return true;
|
||||
});
|
||||
handle('sftp:close', (id) => ssh.close(id));
|
||||
|
||||
// Lokales Dateisystem (für den Dual-Pane-SFTP-Browser)
|
||||
handle('local:home', () => os.homedir());
|
||||
handle('local:list', (dir) => fs.readdirSync(dir, { withFileTypes: true }).map((d) => {
|
||||
let st = {};
|
||||
try { st = fs.statSync(path.join(dir, d.name)); } catch {}
|
||||
return { name: d.name, isDir: d.isDirectory() || (d.isSymbolicLink() && st.isDirectory?.()), size: st.size || 0, mtime: st.mtimeMs || 0 };
|
||||
}));
|
||||
handle('local:join', (...p) => path.join(...p));
|
||||
handle('local:parent', (p) => path.dirname(p));
|
||||
handle('local:roots', () => {
|
||||
if (process.platform !== 'win32') return ['/'];
|
||||
return 'CDEFGHIJKLMNOPQRSTUVWXYZ'.split('').map((l) => `${l}:\\`).filter((d) => fs.existsSync(d));
|
||||
});
|
||||
handle('local:op', (op, a, b) => {
|
||||
if (op === 'mkdir') return fs.mkdirSync(a, { recursive: true });
|
||||
if (op === 'rename') return fs.renameSync(a, b);
|
||||
if (op === 'delete') return shell.trashItem(a);
|
||||
if (op === 'open') return shell.openPath(a);
|
||||
});
|
||||
|
||||
// ---------- Port-Forwarding ----------
|
||||
handle('fw:start', (id) => {
|
||||
const fw = store.get().forwards.find((f) => f.id === id);
|
||||
if (!fw) throw new Error('Regel nicht gefunden');
|
||||
return ssh.startForward(fw, (ev) => send('fw:event', id, ev));
|
||||
});
|
||||
handle('fw:stop', (id) => ssh.stopForward(id));
|
||||
handle('fw:active', () => ssh.activeForwards());
|
||||
|
||||
// ---------- RDP ----------
|
||||
handle('rdp:detect', () => rdp.detect(store.get().settings));
|
||||
handle('rdp:launch', (hostId) => {
|
||||
const host = hostWithOverrides(hostId);
|
||||
if (host.id) store.addHistory({ hostId: host.id, at: Date.now() });
|
||||
const r = rdp.launch(host, store.get().settings);
|
||||
if (r.process) {
|
||||
r.process.on('exit', (code) => { if (code && code !== 0 && code !== 12) send('toast', `RDP beendet (Code ${code}): ${r.getErr().split('\n').filter(Boolean).slice(-1)[0] || ''}`, 'error'); });
|
||||
r.process.on('error', (e) => send('toast', 'RDP-Start fehlgeschlagen: ' + e.message, 'error'));
|
||||
}
|
||||
return r.client;
|
||||
});
|
||||
|
||||
handle('clipboard:write', (t) => clipboard.writeText(t));
|
||||
handle('clipboard:read', () => clipboard.readText());
|
||||
handle('shell:open', (url) => shell.openExternal(url));
|
||||
|
||||
app.whenReady().then(() => {
|
||||
store.load();
|
||||
createWindow();
|
||||
});
|
||||
app.on('window-all-closed', () => { ssh.closeAll(); app.quit(); });
|
||||
|
||||
// Smoke-Test: MRTERM_SMOKE=<pfad.png> startet, loggt Renderer-Meldungen, speichert einen Screenshot und beendet.
|
||||
if (process.env.MRTERM_SMOKE) {
|
||||
app.whenReady().then(() => {
|
||||
win.webContents.on('console-message', (e) => console.log('[renderer]', e.level, e.message));
|
||||
setTimeout(async () => {
|
||||
if (process.env.MRTERM_SMOKE_JS) await win.webContents.executeJavaScript(process.env.MRTERM_SMOKE_JS).catch((e) => console.log('js error', e));
|
||||
setTimeout(async () => {
|
||||
fs.writeFileSync(process.env.MRTERM_SMOKE, (await win.webContents.capturePage()).toPNG());
|
||||
app.quit();
|
||||
}, 1500);
|
||||
}, 2500);
|
||||
});
|
||||
}
|
||||
Reference in New Issue
Block a user