Files
MrTerm/src/main/ssh.js
T

326 lines
13 KiB
JavaScript

// 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');
const i18n = require('../i18n');
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(i18n.t('Connecting to {name} ({addr}) …', { name: hop.label || hop.address, addr: `${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 || i18n.t('Authentication'), 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: i18n.t('Password'), prompt: i18n.t('Password for {user}', { user: `${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(i18n.t('SFTP session not found'));
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(i18n.t('Host not found'));
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 };