// 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} confirmHostKey * @param {(sessionId, prompt) => Promise} 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 };