342 lines
14 KiB
JavaScript
342 lines
14 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') {
|
|
// Nur verwenden, wenn der Windows-OpenSSH-Agent läuft (sonst schlägt die Agent-Anmeldung unnötig fehl)
|
|
const pipe = '\\\\.\\pipe\\openssh-ssh-agent';
|
|
try { return fs.existsSync(pipe) ? pipe : undefined; } catch { return undefined; }
|
|
}
|
|
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 || (() => { try { return os.userInfo().username; } catch { return 'root'; } })(),
|
|
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);
|
|
});
|
|
// ssh2 kann mehrere 'error'-Ereignisse senden (z. B. Anmeldefehler und danach Handshake-Timeout).
|
|
// Ein dauerhafter Listener verhindert, dass ein späterer Fehler den Hauptprozess abstürzen lässt;
|
|
// Fehler nach dem Verbindungsaufbau melden die Sitzungen selbst (close/error).
|
|
let settled = false;
|
|
conn.on('error', () => {});
|
|
conn.once('ready', () => { settled = true; resolve(conn); });
|
|
conn.on('error', async (err) => {
|
|
if (settled) return;
|
|
settled = true;
|
|
try { conn.end(); } catch {}
|
|
// 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.level === 'client-authentication' ? new Error(i18n.t('Authentication failed for {user}. Check the username, password or key.', { user: `${cfg.username}@${host.address}` })) : err);
|
|
});
|
|
try { conn.connect(cfg); } catch (e) { settled = true; 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) => {
|
|
const pty = { term: 'xterm-256color', cols, rows };
|
|
const onStream = (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 && !host.execCommand) stream.write(host.startupCommand + '\n');
|
|
res();
|
|
};
|
|
// execCommand: statt Login-Shell einen Befehl mit PTY starten (z. B. docker exec -it …)
|
|
if (host.execCommand) conn.exec(host.execCommand, { pty, env }, onStream);
|
|
else conn.shell(pty, { env }, onStream);
|
|
});
|
|
}
|
|
|
|
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 };
|