Update
This commit is contained in:
@@ -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 };
|
||||
Reference in New Issue
Block a user