333 lines
14 KiB
JavaScript
333 lines
14 KiB
JavaScript
'use strict';
|
|
|
|
const fs = require('node:fs');
|
|
const fsp = require('node:fs/promises');
|
|
const crypto = require('node:crypto');
|
|
const path = require('node:path').posix;
|
|
|
|
function loadSshModule() {
|
|
try { return require('ssh2'); }
|
|
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 loadSshClient() {
|
|
return loadSshModule().Client;
|
|
}
|
|
|
|
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, `'\\''`)}'`;
|
|
}
|
|
|
|
function parseCapabilityOutput(output) {
|
|
const marker = '__FORGEFLOW_SERVER_TEST__';
|
|
const index = String(output || '').lastIndexOf(marker);
|
|
if (index < 0) return { platform: String(output || '').trim(), docker: false, dockerReady: false, compose: false, git: false, tar: false, checksum: false };
|
|
const fields = {};
|
|
for (const line of String(output).slice(index + marker.length).trim().split(/\r?\n/)) {
|
|
const separator = line.indexOf('=');
|
|
if (separator > 0) fields[line.slice(0, separator)] = line.slice(separator + 1);
|
|
}
|
|
const decode = (value) => {
|
|
try { return value ? Buffer.from(value, 'base64').toString('utf8') : ''; }
|
|
catch { return ''; }
|
|
};
|
|
return {
|
|
platform: decode(fields.platform),
|
|
docker: fields.docker === 'true',
|
|
dockerReady: fields.dockerReady === 'true',
|
|
compose: fields.compose === 'true',
|
|
composeVersion: decode(fields.composeVersion),
|
|
git: fields.git === 'true',
|
|
tar: fields.tar === 'true',
|
|
checksum: fields.checksum === 'true',
|
|
baseWritable: fields.baseWritable === 'true',
|
|
};
|
|
}
|
|
|
|
class SshService {
|
|
constructor({ store, diagnostics }) {
|
|
this.store = store;
|
|
this.diagnostics = diagnostics;
|
|
}
|
|
|
|
async validateServerConfiguration(server, secrets = {}) {
|
|
if (server?.authType !== 'privateKey') return { valid: true, method: 'password' };
|
|
const privateKeyPath = String(server.privateKeyPath || '').trim();
|
|
if (!privateKeyPath) throw new Error('Select a private key file.');
|
|
const stat = await fsp.stat(privateKeyPath).catch(() => null);
|
|
if (!stat?.isFile()) {
|
|
const error = new Error(`The SSH private key file was not found: ${privateKeyPath}`);
|
|
error.code = 'SSH_PRIVATE_KEY_NOT_FOUND';
|
|
throw error;
|
|
}
|
|
const existing = server.id ? this.store.getServer(server.id) : null;
|
|
const sameKey = existing && String(existing.privateKeyPath || '') === privateKeyPath;
|
|
const storedPassphrase = sameKey ? this.store.getServerCredentials(existing.id).passphrase : '';
|
|
const passphrase = Object.prototype.hasOwnProperty.call(secrets, 'passphrase') && String(secrets.passphrase || '')
|
|
? String(secrets.passphrase)
|
|
: storedPassphrase;
|
|
const key = await fsp.readFile(privateKeyPath);
|
|
const parsed = loadSshModule().utils.parseKey(key, passphrase || undefined);
|
|
const errorResult = Array.isArray(parsed) ? parsed.find((item) => item instanceof Error) : parsed instanceof Error ? parsed : null;
|
|
if (errorResult) {
|
|
const error = new Error(`The selected file is not a usable SSH private key${passphrase ? ' with the supplied passphrase' : ''}: ${errorResult.message}`);
|
|
error.code = /encrypted|passphrase|decrypt/i.test(errorResult.message) ? 'SSH_PRIVATE_KEY_PASSPHRASE_INVALID' : 'SSH_PRIVATE_KEY_INVALID';
|
|
throw error;
|
|
}
|
|
return { valid: true, method: 'privateKey', encrypted: Boolean(passphrase), privateKeyPath };
|
|
}
|
|
|
|
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 {
|
|
try { options.privateKey = await fsp.readFile(server.privateKeyPath); }
|
|
catch (error) {
|
|
const wrapped = new Error(`Could not read SSH private key ${server.privateKeyPath}: ${error.message}`);
|
|
wrapped.code = 'SSH_PRIVATE_KEY_READ_FAILED';
|
|
throw wrapped;
|
|
}
|
|
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 observed = connection.getObservedFingerprint();
|
|
const mismatch = Boolean(server.hostFingerprint && observed && server.hostFingerprint !== observed);
|
|
const wrapped = new Error(mismatch
|
|
? `SSH host identity changed. Expected ${server.hostFingerprint}, but the server presented ${observed}.`
|
|
: `SSH connection failed: ${error.message}`);
|
|
wrapped.code = mismatch ? 'SSH_HOST_KEY_MISMATCH' : (error.code || 'SSH_CONNECTION_FAILED');
|
|
wrapped.expectedFingerprint = mismatch ? server.hostFingerprint : undefined;
|
|
wrapped.observedFingerprint = mismatch ? observed : undefined;
|
|
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) => {
|
|
let completed = false;
|
|
const timer = setTimeout(() => {
|
|
if (completed) return;
|
|
completed = true;
|
|
reject(new Error('The SSH command timed out.'));
|
|
}, timeout);
|
|
client.exec(command, (error, stream) => {
|
|
if (error) {
|
|
clearTimeout(timer);
|
|
completed = true;
|
|
reject(error);
|
|
return;
|
|
}
|
|
let stdout = '';
|
|
let stderr = '';
|
|
let stdoutBytes = 0;
|
|
let stderrBytes = 0;
|
|
let truncated = false;
|
|
const append = (target, chunk) => {
|
|
const text = chunk.toString();
|
|
const bytes = Buffer.byteLength(text);
|
|
if (target === 'stdout') {
|
|
if (stdoutBytes + bytes <= maxOutput) stdout += text;
|
|
else truncated = true;
|
|
stdoutBytes += bytes;
|
|
} else {
|
|
if (stderrBytes + bytes <= maxOutput) stderr += text;
|
|
else truncated = true;
|
|
stderrBytes += bytes;
|
|
}
|
|
};
|
|
stream.on('data', (chunk) => append('stdout', chunk));
|
|
stream.stderr.on('data', (chunk) => append('stderr', chunk));
|
|
stream.on('close', (code, signal) => {
|
|
if (completed) return;
|
|
completed = true;
|
|
clearTimeout(timer);
|
|
if (truncated) {
|
|
const failure = new Error(`Remote command output exceeded the ${maxOutput}-byte safety limit. ForgeFlow refused to use an incomplete result.`);
|
|
failure.code = 'SSH_OUTPUT_TRUNCATED';
|
|
failure.stdoutBytes = stdoutBytes;
|
|
failure.stderrBytes = stderrBytes;
|
|
reject(failure);
|
|
} else 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, truncated: false });
|
|
});
|
|
});
|
|
});
|
|
}
|
|
|
|
ensureUploadTarget(target) {
|
|
const normalized = String(target || '').replace(/\\/g, '/');
|
|
if (!normalized.startsWith('/') || normalized.includes('\0') || normalized.split('/').includes('..')) throw new Error('Remote upload path must be an absolute safe Unix path.');
|
|
return normalized;
|
|
}
|
|
|
|
async withSftp(serverId, remotePath, action) {
|
|
const server = this.store.getServer(serverId);
|
|
if (!server?.hostFingerprint) {
|
|
const error = new Error('Test and trust the SSH server fingerprint before uploading deployment assets.');
|
|
error.code = 'SSH_HOST_NOT_TRUSTED';
|
|
throw error;
|
|
}
|
|
const target = this.ensureUploadTarget(remotePath);
|
|
return this.withClient(serverId, (client) => new Promise((resolve, reject) => {
|
|
client.sftp((sftpError, sftp) => {
|
|
if (sftpError) { reject(sftpError); return; }
|
|
const parts = path.dirname(target).split('/').filter(Boolean);
|
|
let current = '';
|
|
const ensureNext = (index) => {
|
|
if (index >= parts.length) {
|
|
Promise.resolve(action(sftp, target)).then(resolve, reject);
|
|
return;
|
|
}
|
|
current += `/${parts[index]}`;
|
|
sftp.stat(current, (statError, attributes) => {
|
|
if (!statError) {
|
|
if (typeof attributes?.isDirectory === 'function' && !attributes.isDirectory()) { reject(new Error(`Remote upload parent exists but is not a directory: ${current}`)); return; }
|
|
ensureNext(index + 1);
|
|
return;
|
|
}
|
|
if (![2, 'ENOENT'].includes(statError.code)) { reject(statError); return; }
|
|
sftp.mkdir(current, { mode: 0o755 }, (mkdirError) => {
|
|
if (!mkdirError) { ensureNext(index + 1); return; }
|
|
sftp.stat(current, (retryError, retryAttributes) => {
|
|
if (!retryError && (typeof retryAttributes?.isDirectory !== 'function' || retryAttributes.isDirectory())) ensureNext(index + 1);
|
|
else reject(mkdirError);
|
|
});
|
|
});
|
|
});
|
|
};
|
|
ensureNext(0);
|
|
});
|
|
}), { trustOnFirstUse: false });
|
|
}
|
|
|
|
async uploadBuffer(serverId, remotePath, content, { mode = 0o600 } = {}) {
|
|
const data = Buffer.isBuffer(content) ? content : Buffer.from(content);
|
|
return this.withSftp(serverId, remotePath, (sftp, target) => new Promise((resolve, reject) => {
|
|
const stream = sftp.createWriteStream(target, { mode });
|
|
stream.once('error', reject);
|
|
stream.once('close', () => resolve({ remotePath: target, size: data.length }));
|
|
stream.end(data);
|
|
}));
|
|
}
|
|
|
|
async uploadFile(serverId, localPath, remotePath, { mode = 0o600, onProgress = null } = {}) {
|
|
const stat = await fsp.stat(localPath);
|
|
if (!stat.isFile()) throw new Error(`Local upload source is not a file: ${localPath}`);
|
|
return this.withSftp(serverId, remotePath, (sftp, target) => new Promise((resolve, reject) => {
|
|
const options = {
|
|
mode,
|
|
step: (totalTransferred, _chunk, total) => onProgress?.({ transferred: totalTransferred, total: total || stat.size }),
|
|
};
|
|
sftp.fastPut(localPath, target, options, (error) => {
|
|
if (error) { reject(error); return; }
|
|
resolve({ remotePath: target, size: stat.size });
|
|
});
|
|
}));
|
|
}
|
|
|
|
async test(serverId, { trustOnFirstUse = true } = {}) {
|
|
return this.withClient(serverId, async (client, server, fingerprint) => {
|
|
const script = `
|
|
platform=$(uname -srm 2>/dev/null || true)
|
|
docker=false; docker_ready=false; compose=false; compose_version=''; git=false; tar_ok=false; checksum=false; base_writable=false
|
|
command -v docker >/dev/null 2>&1 && docker=true
|
|
[ "$docker" = true ] && docker info >/dev/null 2>&1 && docker_ready=true
|
|
if [ "$docker" = true ]; then
|
|
if docker compose version >/dev/null 2>&1; then compose=true; compose_version=$(docker compose version 2>/dev/null | head -n1); elif command -v docker-compose >/dev/null 2>&1; then compose=true; compose_version=$(docker-compose version 2>/dev/null | head -n1); fi
|
|
fi
|
|
command -v git >/dev/null 2>&1 && git=true
|
|
command -v tar >/dev/null 2>&1 && tar_ok=true
|
|
(command -v sha256sum >/dev/null 2>&1 || command -v shasum >/dev/null 2>&1) && checksum=true
|
|
base=${shellQuote(server.basePath)}
|
|
if [ -d "$base" ]; then [ -w "$base" ] && base_writable=true; else parent=$(dirname "$base"); [ -d "$parent" ] && [ -w "$parent" ] && base_writable=true; fi
|
|
printf '__FORGEFLOW_SERVER_TEST__\\n'
|
|
printf 'platform=%s\\n' "$(printf '%s' "$platform" | base64 | tr -d '\\r\\n')"
|
|
printf 'docker=%s\\n' "$docker"
|
|
printf 'dockerReady=%s\\n' "$docker_ready"
|
|
printf 'compose=%s\\n' "$compose"
|
|
printf 'composeVersion=%s\\n' "$(printf '%s' "$compose_version" | base64 | tr -d '\\r\\n')"
|
|
printf 'git=%s\\n' "$git"
|
|
printf 'tar=%s\\n' "$tar_ok"
|
|
printf 'checksum=%s\\n' "$checksum"
|
|
printf 'baseWritable=%s\\n' "$base_writable"
|
|
`;
|
|
const result = await this.execClient(client, script, { timeout: 30_000, maxOutput: 256 * 1024 });
|
|
const capabilities = parseCapabilityOutput(result.stdout);
|
|
return {
|
|
connected: true,
|
|
fingerprint,
|
|
server: { id: server.id, name: server.name, host: server.host, basePath: server.basePath },
|
|
capabilities,
|
|
output: [capabilities.platform, capabilities.composeVersion].filter(Boolean).join('\n'),
|
|
};
|
|
}, { 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, parseCapabilityOutput };
|