MrTerm: SSH/SFTP/RDP-Client im Termius-Stil (Electron)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
+324
@@ -0,0 +1,324 @@
|
||||
// SSH-Verbindungen: Terminal-Shells, SFTP, Port-Forwarding (L/R/D), Jump-Hosts.
|
||||
const { Client } = require('ssh2');
|
||||
const net = require('net');
|
||||
const fs = require('fs');
|
||||
const os = require('os');
|
||||
const path = require('path');
|
||||
const crypto = require('crypto');
|
||||
|
||||
function defaultAgent() {
|
||||
if (process.env.SSH_AUTH_SOCK) return process.env.SSH_AUTH_SOCK;
|
||||
if (process.platform === 'win32') return '\\\\.\\pipe\\openssh-ssh-agent';
|
||||
return undefined;
|
||||
}
|
||||
|
||||
class SshManager {
|
||||
/**
|
||||
* @param {import('./store').Store} store
|
||||
* @param {(host, fingerprint, known) => Promise<boolean>} confirmHostKey
|
||||
* @param {(sessionId, prompt) => Promise<string|null>} askSecret
|
||||
*/
|
||||
constructor(store, confirmHostKey, askSecret) {
|
||||
this.store = store;
|
||||
this.confirmHostKey = confirmHostKey;
|
||||
this.askSecret = askSecret;
|
||||
this.sessions = new Map(); // id -> { conn, stream, sftp, jumps[] }
|
||||
this.forwards = new Map(); // forwardId -> { conn, server, jumps }
|
||||
}
|
||||
|
||||
buildAuth(host, sessionId) {
|
||||
const cfg = {
|
||||
host: host.address,
|
||||
port: Number(host.port) || 22,
|
||||
username: host.username || os.userInfo().username,
|
||||
readyTimeout: 20000,
|
||||
keepaliveInterval: (this.store.get().settings.keepAlive || 0) * 1000,
|
||||
tryKeyboard: true,
|
||||
};
|
||||
if (host.keyId) {
|
||||
const key = this.store.resolveKey(host.keyId);
|
||||
if (key) {
|
||||
cfg.privateKey = key.privateKey;
|
||||
if (key.passphrase) cfg.passphrase = key.passphrase;
|
||||
}
|
||||
}
|
||||
if (host.password) cfg.password = host.password;
|
||||
if (host.useAgent !== false) cfg.agent = defaultAgent();
|
||||
|
||||
cfg.hostVerifier = (keyBuf, verify) => {
|
||||
const fp = 'SHA256:' + crypto.createHash('sha256').update(keyBuf).digest('base64').replace(/=+$/, '');
|
||||
const id = `[${cfg.host}]:${cfg.port}`;
|
||||
const known = this.store.get().knownHosts[id];
|
||||
if (known && known.fingerprint === fp) return verify(true);
|
||||
this.confirmHostKey({ id, host: cfg.host, port: cfg.port }, fp, known).then((ok) => {
|
||||
if (ok) {
|
||||
this.store.get().knownHosts[id] = { fingerprint: fp, addedAt: Date.now() };
|
||||
this.store.save();
|
||||
}
|
||||
verify(ok);
|
||||
});
|
||||
};
|
||||
return cfg;
|
||||
}
|
||||
|
||||
// Baut eine Verbindung auf, optional über eine Kette von Jump-Hosts.
|
||||
async connect(host, sessionId, onStatus = () => {}) {
|
||||
const chain = [];
|
||||
let jumpId = host.jumpHostId;
|
||||
const seen = new Set([host.id]);
|
||||
while (jumpId && !seen.has(jumpId)) {
|
||||
seen.add(jumpId);
|
||||
const j = this.store.resolveHost(jumpId);
|
||||
if (!j) break;
|
||||
chain.unshift(j);
|
||||
jumpId = j.jumpHostId;
|
||||
}
|
||||
const opened = [];
|
||||
let sock;
|
||||
try {
|
||||
for (const hop of [...chain, host]) {
|
||||
onStatus(`Verbinde mit ${hop.label || hop.address} (${hop.address}:${hop.port || 22}) …`);
|
||||
const conn = await this.openClient(hop, sessionId, sock);
|
||||
opened.push(conn);
|
||||
if (hop !== host) {
|
||||
const next = hop === chain[chain.length - 1] ? host : chain[chain.indexOf(hop) + 1];
|
||||
sock = await new Promise((res, rej) =>
|
||||
conn.forwardOut('127.0.0.1', 0, next.address, Number(next.port) || 22, (err, s) => (err ? rej(err) : res(s))));
|
||||
}
|
||||
}
|
||||
} catch (e) {
|
||||
opened.forEach((c) => c.end());
|
||||
throw e;
|
||||
}
|
||||
const conn = opened.pop();
|
||||
return { conn, jumps: opened };
|
||||
}
|
||||
|
||||
openClient(host, sessionId, sock) {
|
||||
return new Promise((resolve, reject) => {
|
||||
const conn = new Client();
|
||||
const cfg = this.buildAuth(host, sessionId);
|
||||
if (sock) { cfg.sock = sock; delete cfg.host; }
|
||||
let askedPassword = false;
|
||||
conn.on('keyboard-interactive', async (name, instr, lang, prompts, finish) => {
|
||||
const answers = [];
|
||||
for (const p of prompts) {
|
||||
if (!p.echo && host.password && !askedPassword) { answers.push(host.password); askedPassword = true; continue; }
|
||||
const a = await this.askSecret(sessionId, { title: name || 'Anmeldung', prompt: p.prompt, echo: p.echo, host: host.label || host.address });
|
||||
if (a == null) return finish([]);
|
||||
answers.push(a);
|
||||
}
|
||||
finish(answers);
|
||||
});
|
||||
conn.once('ready', () => resolve(conn));
|
||||
conn.once('error', async (err) => {
|
||||
// Kein Passwort hinterlegt und alle Methoden schlugen fehl -> nach Passwort fragen und neu versuchen
|
||||
if (err.level === 'client-authentication' && !host.password && !host._retried) {
|
||||
const pw = await this.askSecret(sessionId, { title: 'Passwort', prompt: `Passwort für ${cfg.username}@${host.address}`, echo: false, host: host.label || host.address });
|
||||
if (pw != null) {
|
||||
this.openClient({ ...host, password: pw, _retried: true }, sessionId, sock).then(resolve, reject);
|
||||
return;
|
||||
}
|
||||
}
|
||||
reject(err);
|
||||
});
|
||||
try { conn.connect(cfg); } catch (e) { reject(e); }
|
||||
});
|
||||
}
|
||||
|
||||
// ---------- Terminal ----------
|
||||
async openShell(sessionId, host, { cols, rows }, send) {
|
||||
const { conn, jumps } = await this.connect(host, sessionId, (m) => send('status', m));
|
||||
const sess = { conn, jumps, host };
|
||||
this.sessions.set(sessionId, sess);
|
||||
conn.on('close', () => { send('closed'); this.cleanup(sessionId); });
|
||||
conn.on('error', (e) => send('error', e.message));
|
||||
|
||||
const env = host.env ? Object.fromEntries(host.env.split('\n').filter(Boolean).map((l) => l.split('=').map((s) => s.trim()))) : undefined;
|
||||
await new Promise((res, rej) => {
|
||||
conn.shell({ term: 'xterm-256color', cols, rows }, { env }, (err, stream) => {
|
||||
if (err) return rej(err);
|
||||
sess.stream = stream;
|
||||
stream.on('data', (d) => send('data', d.toString('utf8')));
|
||||
stream.stderr.on('data', (d) => send('data', d.toString('utf8')));
|
||||
stream.on('close', () => conn.end());
|
||||
if (host.startupCommand) stream.write(host.startupCommand + '\n');
|
||||
res();
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
write(id, data) { this.sessions.get(id)?.stream?.write(data); }
|
||||
resize(id, cols, rows) { this.sessions.get(id)?.stream?.setWindow(rows, cols, 0, 0); }
|
||||
|
||||
close(id) {
|
||||
const s = this.sessions.get(id);
|
||||
if (!s) return;
|
||||
try { s.stream?.end(); } catch {}
|
||||
try { s.conn.end(); } catch {}
|
||||
this.cleanup(id);
|
||||
}
|
||||
|
||||
cleanup(id) {
|
||||
const s = this.sessions.get(id);
|
||||
if (!s) return;
|
||||
s.jumps?.forEach((c) => { try { c.end(); } catch {} });
|
||||
this.sessions.delete(id);
|
||||
}
|
||||
|
||||
// ---------- SFTP ----------
|
||||
async openSftp(sessionId, host) {
|
||||
const { conn, jumps } = await this.connect(host, sessionId);
|
||||
const sftp = await new Promise((res, rej) => conn.sftp((e, s) => (e ? rej(e) : res(s))));
|
||||
this.sessions.set(sessionId, { conn, jumps, sftp, host });
|
||||
conn.on('close', () => this.cleanup(sessionId));
|
||||
const home = await new Promise((res) => sftp.realpath('.', (e, p) => res(e ? '/' : p)));
|
||||
return { home };
|
||||
}
|
||||
|
||||
sftpOf(id) {
|
||||
const s = this.sessions.get(id);
|
||||
if (!s?.sftp) throw new Error('SFTP-Sitzung nicht gefunden');
|
||||
return s.sftp;
|
||||
}
|
||||
|
||||
sftpList(id, dir) {
|
||||
const sftp = this.sftpOf(id);
|
||||
return new Promise((res, rej) => sftp.readdir(dir, (err, list) => {
|
||||
if (err) return rej(err);
|
||||
res(list.map((f) => ({
|
||||
name: f.filename,
|
||||
size: f.attrs.size,
|
||||
mtime: f.attrs.mtime * 1000,
|
||||
isDir: (f.attrs.mode & 0o170000) === 0o040000,
|
||||
isLink: (f.attrs.mode & 0o170000) === 0o120000,
|
||||
mode: f.attrs.mode,
|
||||
})));
|
||||
}));
|
||||
}
|
||||
|
||||
sftpOp(id, op, a, b) {
|
||||
const sftp = this.sftpOf(id);
|
||||
const cb = (res, rej) => (err, v) => (err ? rej(err) : res(v));
|
||||
return new Promise((res, rej) => {
|
||||
switch (op) {
|
||||
case 'mkdir': return sftp.mkdir(a, cb(res, rej));
|
||||
case 'rename': return sftp.rename(a, b, cb(res, rej));
|
||||
case 'unlink': return sftp.unlink(a, cb(res, rej));
|
||||
case 'rmdir': return this.sftpRmRf(sftp, a).then(res, rej);
|
||||
case 'realpath': return sftp.realpath(a, cb(res, rej));
|
||||
case 'chmod': return sftp.chmod(a, b, cb(res, rej));
|
||||
default: rej(new Error('Unbekannte Operation ' + op));
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
async sftpRmRf(sftp, dir) {
|
||||
const list = await new Promise((res, rej) => sftp.readdir(dir, (e, l) => (e ? rej(e) : res(l))));
|
||||
for (const f of list) {
|
||||
const p = path.posix.join(dir, f.filename);
|
||||
if ((f.attrs.mode & 0o170000) === 0o040000) await this.sftpRmRf(sftp, p);
|
||||
else await new Promise((res, rej) => sftp.unlink(p, (e) => (e ? rej(e) : res())));
|
||||
}
|
||||
await new Promise((res, rej) => sftp.rmdir(dir, (e) => (e ? rej(e) : res())));
|
||||
}
|
||||
|
||||
async transfer(id, direction, localPath, remotePath, progress) {
|
||||
const sftp = this.sftpOf(id);
|
||||
const step = (done, _chunk, total) => progress(done, total);
|
||||
if (direction === 'download') {
|
||||
const st = await new Promise((res, rej) => sftp.stat(remotePath, (e, s) => (e ? rej(e) : res(s))));
|
||||
if ((st.mode & 0o170000) === 0o040000) {
|
||||
fs.mkdirSync(localPath, { recursive: true });
|
||||
for (const f of await this.sftpList(id, remotePath))
|
||||
await this.transfer(id, direction, path.join(localPath, f.name), path.posix.join(remotePath, f.name), progress);
|
||||
return;
|
||||
}
|
||||
await new Promise((res, rej) => sftp.fastGet(remotePath, localPath, { step }, (e) => (e ? rej(e) : res())));
|
||||
} else {
|
||||
const st = fs.statSync(localPath);
|
||||
if (st.isDirectory()) {
|
||||
await new Promise((res) => sftp.mkdir(remotePath, () => res()));
|
||||
for (const name of fs.readdirSync(localPath))
|
||||
await this.transfer(id, direction, path.join(localPath, name), path.posix.join(remotePath, name), progress);
|
||||
return;
|
||||
}
|
||||
await new Promise((res, rej) => sftp.fastPut(localPath, remotePath, { step }, (e) => (e ? rej(e) : res())));
|
||||
}
|
||||
}
|
||||
|
||||
// ---------- Port-Forwarding ----------
|
||||
async startForward(fw, onEvent) {
|
||||
const host = this.store.resolveHost(fw.hostId);
|
||||
if (!host) throw new Error('Host nicht gefunden');
|
||||
const { conn, jumps } = await this.connect(host, 'fw-' + fw.id);
|
||||
const entry = { conn, jumps };
|
||||
this.forwards.set(fw.id, entry);
|
||||
conn.on('close', () => { this.stopForward(fw.id); onEvent('stopped'); });
|
||||
const bindHost = fw.bindAddress || '127.0.0.1';
|
||||
|
||||
if (fw.type === 'remote') {
|
||||
await new Promise((res, rej) => conn.forwardIn(bindHost, Number(fw.bindPort), (e) => (e ? rej(e) : res())));
|
||||
conn.on('tcp connection', (info, accept) => {
|
||||
const ch = accept();
|
||||
const sock = net.connect(Number(fw.destPort), fw.destHost || '127.0.0.1');
|
||||
ch.pipe(sock).pipe(ch);
|
||||
sock.on('error', () => ch.close());
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
const server = net.createServer((sock) => {
|
||||
if (fw.type === 'dynamic') return this.socks5(conn, sock);
|
||||
conn.forwardOut(sock.remoteAddress || '127.0.0.1', sock.remotePort || 0, fw.destHost, Number(fw.destPort), (err, ch) => {
|
||||
if (err) return sock.destroy();
|
||||
sock.pipe(ch).pipe(sock);
|
||||
sock.on('error', () => ch.close());
|
||||
});
|
||||
});
|
||||
entry.server = server;
|
||||
await new Promise((res, rej) => { server.once('error', rej); server.listen(Number(fw.bindPort), bindHost, res); });
|
||||
}
|
||||
|
||||
// Minimaler SOCKS5-Server (CONNECT, ohne Auth) für dynamisches Forwarding
|
||||
socks5(conn, sock) {
|
||||
sock.once('data', (hello) => {
|
||||
if (hello[0] !== 5) return sock.destroy();
|
||||
sock.write(Buffer.from([5, 0]));
|
||||
sock.once('data', (req) => {
|
||||
if (req[1] !== 1) { sock.end(Buffer.from([5, 7, 0, 1, 0, 0, 0, 0, 0, 0])); return; }
|
||||
let host, off;
|
||||
if (req[3] === 1) { host = [...req.slice(4, 8)].join('.'); off = 8; }
|
||||
else if (req[3] === 3) { const l = req[4]; host = req.slice(5, 5 + l).toString(); off = 5 + l; }
|
||||
else if (req[3] === 4) { host = req.slice(4, 20).toString('hex').match(/.{4}/g).join(':'); off = 20; }
|
||||
else return sock.destroy();
|
||||
const port = req.readUInt16BE(off);
|
||||
conn.forwardOut('127.0.0.1', 0, host, port, (err, ch) => {
|
||||
if (err) { sock.end(Buffer.from([5, 5, 0, 1, 0, 0, 0, 0, 0, 0])); return; }
|
||||
sock.write(Buffer.from([5, 0, 0, 1, 0, 0, 0, 0, 0, 0]));
|
||||
sock.pipe(ch).pipe(sock);
|
||||
sock.on('error', () => ch.close());
|
||||
});
|
||||
});
|
||||
});
|
||||
sock.on('error', () => {});
|
||||
}
|
||||
|
||||
stopForward(id) {
|
||||
const f = this.forwards.get(id);
|
||||
if (!f) return;
|
||||
this.forwards.delete(id);
|
||||
try { f.server?.close(); } catch {}
|
||||
try { f.conn.end(); } catch {}
|
||||
f.jumps?.forEach((c) => { try { c.end(); } catch {} });
|
||||
}
|
||||
|
||||
activeForwards() { return [...this.forwards.keys()]; }
|
||||
|
||||
closeAll() {
|
||||
for (const id of [...this.sessions.keys()]) this.close(id);
|
||||
for (const id of [...this.forwards.keys()]) this.stopForward(id);
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = { SshManager };
|
||||
Reference in New Issue
Block a user