This commit is contained in:
NuklearRabbit
2026-07-24 20:29:23 +02:00
commit 66060348da
107 changed files with 14771 additions and 0 deletions
+146
View File
@@ -0,0 +1,146 @@
'use strict';
const fs = require('node:fs/promises');
const crypto = require('node:crypto');
function loadSshClient() {
try { return require('ssh2').Client; }
catch {
const error = new Error('The ssh2 dependency is not installed. Run npm install before configuring SSH deployments.');
error.code = 'SSH2_NOT_INSTALLED';
throw error;
}
}
function fingerprintKey(key) {
const buffer = Buffer.isBuffer(key) ? key : Buffer.from(key);
return `SHA256:${crypto.createHash('sha256').update(buffer).digest('base64').replace(/=+$/, '')}`;
}
function shellQuote(value) {
return `'${String(value ?? '').replace(/'/g, `'\\''`)}'`;
}
class SshService {
constructor({ store, diagnostics }) {
this.store = store;
this.diagnostics = diagnostics;
}
async connectionOptions(server, { trustOnFirstUse = false } = {}) {
const credentials = this.store.getServerCredentials(server.id);
let observedFingerprint = null;
const options = {
host: server.host,
port: server.port || 22,
username: server.username,
readyTimeout: 20_000,
keepaliveInterval: 10_000,
keepaliveCountMax: 3,
hostVerifier: (key) => {
observedFingerprint = fingerprintKey(key);
return trustOnFirstUse || Boolean(server.hostFingerprint && observedFingerprint === server.hostFingerprint);
}
};
if (server.authType === 'password') {
options.password = credentials.password;
} else {
options.privateKey = await fs.readFile(server.privateKeyPath);
if (credentials.passphrase) options.passphrase = credentials.passphrase;
}
return { options, getObservedFingerprint: () => observedFingerprint };
}
async withClient(serverId, action, options = {}) {
const server = this.store.getServer(serverId);
if (!server) throw new Error('The configured SSH server no longer exists.');
const Client = loadSshClient();
const connection = await this.connectionOptions(server, options);
const client = new Client();
const started = Date.now();
return new Promise((resolve, reject) => {
let settled = false;
const finish = (callback, value) => {
if (settled) return;
settled = true;
try { client.end(); } catch {}
callback(value);
};
client.once('ready', async () => {
try {
const data = await action(client, server, connection.getObservedFingerprint());
await this.diagnostics?.debug('ssh.connection.completed', {
serverId,
host: server.host,
durationMs: Date.now() - started
});
finish(resolve, data);
} catch (error) { finish(reject, error); }
});
client.once('error', async (error) => {
const wrapped = new Error(`SSH connection failed: ${error.message}`);
wrapped.code = error.code || 'SSH_CONNECTION_FAILED';
await this.diagnostics?.warning('ssh.connection.failed', {
serverId,
host: server.host,
durationMs: Date.now() - started,
code: wrapped.code,
message: wrapped.message
});
finish(reject, wrapped);
});
client.connect(connection.options);
});
}
execClient(client, command, { timeout = 15 * 60_000, maxOutput = 2 * 1024 * 1024 } = {}) {
return new Promise((resolve, reject) => {
const timer = setTimeout(() => reject(new Error('The SSH command timed out.')), timeout);
client.exec(command, (error, stream) => {
if (error) {
clearTimeout(timer);
reject(error);
return;
}
let stdout = '';
let stderr = '';
stream.on('data', (chunk) => { if (stdout.length < maxOutput) stdout += chunk.toString(); });
stream.stderr.on('data', (chunk) => { if (stderr.length < maxOutput) stderr += chunk.toString(); });
stream.on('close', (code, signal) => {
clearTimeout(timer);
if (code !== 0) {
const failure = new Error(`Remote command failed with exit code ${code}: ${(stderr || stdout).trim().slice(-4000)}`);
failure.code = 'SSH_COMMAND_FAILED';
failure.exitCode = code;
failure.signal = signal;
reject(failure);
} else resolve({ stdout, stderr, exitCode: code });
});
});
});
}
async test(serverId, { trustOnFirstUse = true } = {}) {
return this.withClient(serverId, async (client, server, fingerprint) => {
const result = await this.execClient(client, 'uname -srm && command -v git && (docker compose version || docker-compose version)', { timeout: 30_000 });
return {
connected: true,
fingerprint,
server: { id: server.id, name: server.name, host: server.host, basePath: server.basePath },
output: result.stdout.trim()
};
}, { trustOnFirstUse });
}
async exec(serverId, command, options = {}) {
const server = this.store.getServer(serverId);
if (!server?.hostFingerprint) {
const error = new Error('Test and trust the SSH server fingerprint before running deployment commands.');
error.code = 'SSH_HOST_NOT_TRUSTED';
throw error;
}
return this.withClient(serverId, (client) => this.execClient(client, command, options), { trustOnFirstUse: false });
}
}
module.exports = { SshService, shellQuote, fingerprintKey };