Files
netfelix-audio-fix/server/services/ssh.ts
Felix Förtsch 5ac44b7551 restructure to react spa + hono api, fix missing server/ and lib/
rewrite from monolithic hono jsx to react 19 spa with tanstack router
+ hono json api backend. add scan, review, execute, nodes, and setup
pages. multi-stage dockerfile (node for vite build, bun for runtime).

previously, server/ and src/shared/lib/ were silently excluded by
global gitignore patterns (/server/ from emacs, lib/ from python).

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-02 22:57:40 +01:00

164 lines
3.7 KiB
TypeScript

import { Client } from 'ssh2';
import type { Node } from '../types';
export interface ExecResult {
exitCode: number;
output: string;
}
/** Test SSH connectivity to a node. Returns ok + optional error message. */
export function testConnection(node: Node): Promise<{ ok: boolean; error?: string }> {
return new Promise((resolve) => {
const conn = new Client();
const timeout = setTimeout(() => {
conn.destroy();
resolve({ ok: false, error: 'Connection timed out' });
}, 10_000);
conn.on('ready', () => {
clearTimeout(timeout);
conn.exec('echo ok', (err, stream) => {
if (err) {
conn.end();
resolve({ ok: false, error: err.message });
return;
}
stream.on('close', () => {
conn.end();
resolve({ ok: true });
});
});
});
conn.on('error', (err) => {
clearTimeout(timeout);
resolve({ ok: false, error: err.message });
});
conn.connect(buildConnectConfig(node));
});
}
/**
* Execute a command on a remote node and stream output lines.
* Yields lines as they arrive. Throws if connection fails.
*/
export async function* execStream(
node: Node,
command: string
): AsyncGenerator<string> {
// Collect lines via a promise-based queue
const queue: string[] = [];
const resolvers: Array<(value: IteratorResult<string>) => void> = [];
let done = false;
let errorVal: Error | null = null;
const push = (line: string) => {
if (resolvers.length > 0) {
resolvers.shift()!({ value: line, done: false });
} else {
queue.push(line);
}
};
const finish = (err?: Error) => {
done = true;
errorVal = err ?? null;
while (resolvers.length > 0) {
resolvers.shift()!({ value: undefined as unknown as string, done: true });
}
};
const conn = new Client();
conn.on('ready', () => {
conn.exec(command, { pty: false }, (err, stream) => {
if (err) {
conn.end();
finish(err);
return;
}
stream.stdout.on('data', (data: Buffer) => {
const lines = data.toString('utf8').split('\n');
for (const line of lines) {
if (line) push(line);
}
});
stream.stderr.on('data', (data: Buffer) => {
const lines = data.toString('utf8').split('\n');
for (const line of lines) {
if (line) push(`[stderr] ${line}`);
}
});
stream.on('close', (code: number) => {
if (code !== 0) {
push(`[exit code ${code}]`);
}
conn.end();
finish();
});
});
});
conn.on('error', (err) => finish(err));
conn.connect(buildConnectConfig(node));
// Yield from the queue
while (true) {
if (queue.length > 0) {
yield queue.shift()!;
} else if (done) {
if (errorVal) throw errorVal;
return;
} else {
await new Promise<IteratorResult<string>>((resolve) => {
resolvers.push(resolve);
});
}
}
}
/**
* Execute a command on a remote node and return full output + exit code.
* For use when streaming isn't needed.
*/
export function execOnce(node: Node, command: string): Promise<ExecResult> {
return new Promise((resolve, reject) => {
const conn = new Client();
let output = '';
conn.on('ready', () => {
conn.exec(command, (err, stream) => {
if (err) {
conn.end();
reject(err);
return;
}
stream.stdout.on('data', (d: Buffer) => { output += d.toString(); });
stream.stderr.on('data', (d: Buffer) => { output += d.toString(); });
stream.on('close', (code: number) => {
conn.end();
resolve({ exitCode: code ?? 0, output });
});
});
});
conn.on('error', reject);
conn.connect(buildConnectConfig(node));
});
}
function buildConnectConfig(node: Node): Parameters<Client['connect']>[0] {
return {
host: node.host,
port: node.port,
username: node.username,
privateKey: node.private_key,
readyTimeout: 10_000,
};
}