- Add resolveServerForSSH() to load full server (including ssh_key_path) from DB when WebSocket server has id but key auth without valid ssh_key_path - Call resolver in handleMessage for all start flows (clone, backup, update, shell, script) so Shell and Update over SSH work with key auth - Extend ServerInfo typedef with auth_type, ssh_key_path for TypeScript
1749 lines
58 KiB
JavaScript
1749 lines
58 KiB
JavaScript
import { createServer } from 'http';
|
||
import { parse } from 'url';
|
||
import next from 'next';
|
||
import { WebSocketServer } from 'ws';
|
||
import { spawn } from 'child_process';
|
||
import { existsSync } from 'fs';
|
||
import { join, resolve } from 'path';
|
||
import stripAnsi from 'strip-ansi';
|
||
import { spawn as ptySpawn } from 'node-pty';
|
||
import { getSSHExecutionService } from './src/server/ssh-execution-service.js';
|
||
import { getDatabase } from './src/server/database-prisma.js';
|
||
import dotenv from 'dotenv';
|
||
|
||
// Dynamic import for auto sync init to avoid tsx caching issues
|
||
/** @type {any} */
|
||
let autoSyncModule = null;
|
||
|
||
// Load environment variables from .env file
|
||
dotenv.config();
|
||
// Fallback minimal global error handlers for Node runtime (avoid TS import)
|
||
function registerGlobalErrorHandlers() {
|
||
if (registerGlobalErrorHandlers._registered) return;
|
||
registerGlobalErrorHandlers._registered = true;
|
||
process.on('uncaughtException', (err) => {
|
||
console.error('uncaught_exception', err);
|
||
});
|
||
process.on('unhandledRejection', (reason) => {
|
||
console.error('unhandled_rejection', reason);
|
||
});
|
||
}
|
||
registerGlobalErrorHandlers._registered = false;
|
||
|
||
const dev = process.env.NODE_ENV !== 'production';
|
||
const hostname = '0.0.0.0';
|
||
const port = parseInt(process.env.PORT || '3000', 10);
|
||
|
||
const app = next({ dev, hostname, port });
|
||
// Register global handlers once at bootstrap
|
||
registerGlobalErrorHandlers();
|
||
const handle = app.getRequestHandler();
|
||
|
||
// WebSocket handler for script execution
|
||
/**
|
||
* @typedef {import('ws').WebSocket & {connectionTime?: number, clientIP?: string}} ExtendedWebSocket
|
||
*/
|
||
|
||
/**
|
||
* @typedef {Object} Execution
|
||
* @property {any} process
|
||
* @property {ExtendedWebSocket} ws
|
||
*/
|
||
|
||
/**
|
||
* @typedef {Object} ServerInfo
|
||
* @property {string} name
|
||
* @property {string} ip
|
||
* @property {string} user
|
||
* @property {string} password
|
||
* @property {number} [id]
|
||
* @property {string} [auth_type]
|
||
* @property {string} [ssh_key_path]
|
||
*/
|
||
|
||
/**
|
||
* @typedef {Object} ExecutionResult
|
||
* @property {any} process
|
||
* @property {Function} kill
|
||
*/
|
||
|
||
/**
|
||
* @typedef {Object} WebSocketMessage
|
||
* @property {string} action
|
||
* @property {string} [scriptPath]
|
||
* @property {string} [executionId]
|
||
* @property {string} [input]
|
||
* @property {string} [mode]
|
||
* @property {ServerInfo} [server]
|
||
* @property {boolean} [isUpdate]
|
||
* @property {boolean} [isShell]
|
||
* @property {boolean} [isBackup]
|
||
* @property {boolean} [isClone]
|
||
* @property {string} [containerId]
|
||
* @property {string} [storage]
|
||
* @property {string} [backupStorage]
|
||
* @property {number} [cloneCount]
|
||
* @property {string[]} [hostnames]
|
||
* @property {'lxc'|'vm'} [containerType]
|
||
* @property {Record<string, string|number|boolean>} [envVars]
|
||
*/
|
||
|
||
class ScriptExecutionHandler {
|
||
/**
|
||
* @param {import('http').Server} server
|
||
*/
|
||
constructor(server) {
|
||
// Create WebSocketServer without attaching to server
|
||
// We'll handle upgrades manually to avoid interfering with Next.js HMR
|
||
this.wss = new WebSocketServer({
|
||
noServer: true
|
||
});
|
||
this.activeExecutions = new Map();
|
||
this.db = getDatabase();
|
||
this.setupWebSocket();
|
||
}
|
||
|
||
/**
|
||
* Handle WebSocket upgrade for our endpoint
|
||
* @param {import('http').IncomingMessage} request
|
||
* @param {import('stream').Duplex} socket
|
||
* @param {Buffer} head
|
||
*/
|
||
handleUpgrade(request, socket, head) {
|
||
this.wss.handleUpgrade(request, socket, head, (ws) => {
|
||
this.wss.emit('connection', ws, request);
|
||
});
|
||
}
|
||
|
||
/**
|
||
* Parse Container ID from terminal output
|
||
* @param {string} output - Terminal output to parse
|
||
* @returns {string|null} - Container ID if found, null otherwise
|
||
*/
|
||
parseContainerId(output) {
|
||
// First, strip ANSI color codes to make pattern matching more reliable
|
||
const cleanOutput = output.replace(/\x1b\[[0-9;]*m/g, '');
|
||
|
||
// Look for various patterns that Proxmox scripts might use
|
||
const patterns = [
|
||
// Primary pattern - the exact format from the output
|
||
/🆔\s+Container\s+ID:\s+(\d+)/i,
|
||
|
||
// Standard patterns with flexible spacing
|
||
/🆔\s*Container\s*ID:\s*(\d+)/i,
|
||
/Container\s*ID:\s*(\d+)/i,
|
||
/CT\s*ID:\s*(\d+)/i,
|
||
/Container\s*(\d+)/i,
|
||
|
||
// Alternative patterns
|
||
/CT\s*(\d+)/i,
|
||
/Container\s*created\s*with\s*ID\s*(\d+)/i,
|
||
/Created\s*container\s*(\d+)/i,
|
||
/Container\s*(\d+)\s*created/i,
|
||
/ID:\s*(\d+)/i,
|
||
|
||
// Patterns with different spacing and punctuation
|
||
/Container\s*ID\s*:\s*(\d+)/i,
|
||
/CT\s*ID\s*:\s*(\d+)/i,
|
||
/Container\s*#\s*(\d+)/i,
|
||
/CT\s*#\s*(\d+)/i,
|
||
|
||
// Patterns that might appear in success messages
|
||
/Successfully\s*created\s*container\s*(\d+)/i,
|
||
/Container\s*(\d+)\s*is\s*ready/i,
|
||
/Container\s*(\d+)\s*started/i,
|
||
|
||
// Generic number patterns that might be container IDs (3-4 digits)
|
||
/(?:^|\s)(\d{3,4})(?:\s|$)/m,
|
||
];
|
||
|
||
// Try patterns on both original and cleaned output
|
||
const outputsToTry = [output, cleanOutput];
|
||
|
||
for (const testOutput of outputsToTry) {
|
||
for (const pattern of patterns) {
|
||
const match = testOutput.match(pattern);
|
||
if (match && match[1]) {
|
||
const containerId = match[1];
|
||
// Additional validation: container IDs are typically 3-4 digits
|
||
if (containerId.length >= 3 && containerId.length <= 4) {
|
||
return containerId;
|
||
}
|
||
}
|
||
}
|
||
}
|
||
|
||
|
||
return null;
|
||
}
|
||
|
||
/**
|
||
* Parse Web UI URL from terminal output
|
||
* @param {string} output - Terminal output to parse
|
||
* @returns {{ip: string, port: number}|null} - Object with ip and port if found, null otherwise
|
||
*/
|
||
parseWebUIUrl(output) {
|
||
// First, strip ANSI color codes to make pattern matching more reliable
|
||
const cleanOutput = output.replace(/\x1b\[[0-9;]*m/g, '');
|
||
|
||
// Look for URL patterns with any valid IP address (private or public)
|
||
const patterns = [
|
||
// HTTP/HTTPS URLs with IP and port
|
||
/https?:\/\/(\d{1,3}\.\d{1,3}\.\d{1,3}\.\d{1,3}):(\d+)/gi,
|
||
// URLs without explicit port (assume default ports)
|
||
/https?:\/\/(\d{1,3}\.\d{1,3}\.\d{1,3}\.\d{1,3})(?:\/|$|\s)/gi,
|
||
// URLs with trailing slash and port
|
||
/https?:\/\/(\d{1,3}\.\d{1,3}\.\d{1,3}\.\d{1,3}):(\d+)\//gi,
|
||
// URLs with just IP and port (no protocol)
|
||
/(?:^|\s)(\d{1,3}\.\d{1,3}\.\d{1,3}\.\d{1,3}):(\d+)(?:\s|$)/gi,
|
||
// URLs with just IP (no protocol, no port)
|
||
/(?:^|\s)(\d{1,3}\.\d{1,3}\.\d{1,3}\.\d{1,3})(?:\s|$)/gi,
|
||
];
|
||
|
||
// Try patterns on both original and cleaned output
|
||
const outputsToTry = [output, cleanOutput];
|
||
|
||
for (const testOutput of outputsToTry) {
|
||
for (const pattern of patterns) {
|
||
const matches = [...testOutput.matchAll(pattern)];
|
||
for (const match of matches) {
|
||
if (match[1]) {
|
||
const ip = match[1];
|
||
const port = match[2] || (match[0].startsWith('https') ? '443' : '80');
|
||
|
||
// Validate IP address format
|
||
if (ip.match(/^\d{1,3}\.\d{1,3}\.\d{1,3}\.\d{1,3}$/)) {
|
||
return {
|
||
ip: ip,
|
||
port: parseInt(port, 10)
|
||
};
|
||
}
|
||
}
|
||
}
|
||
}
|
||
}
|
||
|
||
return null;
|
||
}
|
||
|
||
/**
|
||
* Create installation record
|
||
* @param {string} scriptName - Name of the script
|
||
* @param {string} scriptPath - Path to the script
|
||
* @param {string} executionMode - 'local' or 'ssh'
|
||
* @param {number|null} serverId - Server ID for SSH executions
|
||
* @returns {Promise<number|null>} - Installation record ID
|
||
*/
|
||
async createInstallationRecord(scriptName, scriptPath, executionMode, serverId = null) {
|
||
try {
|
||
const result = await this.db.createInstalledScript({
|
||
script_name: scriptName,
|
||
script_path: scriptPath,
|
||
container_id: undefined,
|
||
server_id: serverId ?? undefined,
|
||
execution_mode: executionMode,
|
||
status: 'in_progress',
|
||
output_log: ''
|
||
});
|
||
return Number(result.id);
|
||
} catch (error) {
|
||
console.error('Error creating installation record:', error);
|
||
return null;
|
||
}
|
||
}
|
||
|
||
/**
|
||
* Update installation record
|
||
* @param {number} installationId - Installation record ID
|
||
* @param {Object} updateData - Data to update
|
||
*/
|
||
async updateInstallationRecord(installationId, updateData) {
|
||
try {
|
||
await this.db.updateInstalledScript(installationId, updateData);
|
||
} catch (error) {
|
||
console.error('Error updating installation record:', error);
|
||
}
|
||
}
|
||
|
||
setupWebSocket() {
|
||
this.wss.on('connection', (ws, request) => {
|
||
|
||
// Set connection metadata
|
||
/** @type {ExtendedWebSocket} */ (ws).connectionTime = Date.now();
|
||
/** @type {ExtendedWebSocket} */ (ws).clientIP = request.socket.remoteAddress || 'unknown';
|
||
|
||
ws.on('message', (data) => {
|
||
try {
|
||
const rawMessage = data.toString();
|
||
const message = JSON.parse(rawMessage);
|
||
this.handleMessage(/** @type {ExtendedWebSocket} */ (ws), message);
|
||
} catch (error) {
|
||
console.error('Error parsing WebSocket message:', error);
|
||
this.sendMessage(ws, {
|
||
type: 'error',
|
||
data: 'Invalid message format',
|
||
timestamp: Date.now()
|
||
});
|
||
}
|
||
});
|
||
|
||
ws.on('close', (code, reason) => {
|
||
this.cleanupActiveExecutions(/** @type {ExtendedWebSocket} */ (ws));
|
||
});
|
||
|
||
ws.on('error', (error) => {
|
||
console.error('WebSocket error:', error);
|
||
this.cleanupActiveExecutions(/** @type {ExtendedWebSocket} */ (ws));
|
||
});
|
||
});
|
||
}
|
||
|
||
/**
|
||
* Resolve full server from DB when client sends server with id but no ssh_key_path (e.g. for Shell/Update over SSH).
|
||
* @param {ServerInfo|null} server - Server from WebSocket message
|
||
* @returns {Promise<ServerInfo|null>} Same server or full server from DB
|
||
*/
|
||
async resolveServerForSSH(server) {
|
||
if (!server?.id) return server;
|
||
if (server.auth_type === 'key' && (!server.ssh_key_path || !existsSync(server.ssh_key_path))) {
|
||
const full = await this.db.getServerById(server.id);
|
||
return /** @type {ServerInfo|null} */ (full ?? server);
|
||
}
|
||
return server;
|
||
}
|
||
|
||
/**
|
||
* @param {ExtendedWebSocket} ws
|
||
* @param {WebSocketMessage} message
|
||
*/
|
||
async handleMessage(ws, message) {
|
||
const { action, scriptPath, executionId, input, mode, server, isUpdate, isShell, isBackup, isClone, containerId, storage, backupStorage, cloneCount, hostnames, containerType, envVars } = message;
|
||
|
||
switch (action) {
|
||
case 'start':
|
||
if (scriptPath && executionId) {
|
||
let serverToUse = server;
|
||
if (serverToUse?.id) {
|
||
serverToUse = await this.resolveServerForSSH(serverToUse) ?? serverToUse;
|
||
}
|
||
const resolved = serverToUse ?? server;
|
||
if (isClone && containerId && storage && server && cloneCount && hostnames && containerType) {
|
||
await this.startSSHCloneExecution(ws, containerId, executionId, storage, /** @type {ServerInfo} */ (resolved), containerType, cloneCount, hostnames);
|
||
} else if (isBackup && containerId && storage) {
|
||
await this.startBackupExecution(ws, containerId, executionId, storage, mode, resolved);
|
||
} else if (isUpdate && containerId) {
|
||
await this.startUpdateExecution(ws, containerId, executionId, mode, resolved, backupStorage);
|
||
} else if (isShell && containerId) {
|
||
await this.startShellExecution(ws, containerId, executionId, mode, resolved, containerType);
|
||
} else {
|
||
await this.startScriptExecution(ws, scriptPath, executionId, mode, resolved, envVars);
|
||
}
|
||
} else {
|
||
this.sendMessage(ws, {
|
||
type: 'error',
|
||
data: 'Missing scriptPath or executionId',
|
||
timestamp: Date.now()
|
||
});
|
||
}
|
||
break;
|
||
|
||
case 'stop':
|
||
if (executionId) {
|
||
this.stopScriptExecution(executionId);
|
||
}
|
||
break;
|
||
|
||
case 'input':
|
||
if (executionId && input !== undefined) {
|
||
this.sendInputToProcess(executionId, input);
|
||
}
|
||
break;
|
||
|
||
default:
|
||
this.sendMessage(ws, {
|
||
type: 'error',
|
||
data: 'Unknown action',
|
||
timestamp: Date.now()
|
||
});
|
||
}
|
||
}
|
||
|
||
/**
|
||
* @param {ExtendedWebSocket} ws
|
||
* @param {string} scriptPath
|
||
* @param {string} executionId
|
||
* @param {string} mode
|
||
* @param {ServerInfo|null} server
|
||
* @param {Object} [envVars] - Optional environment variables to pass to the script
|
||
*/
|
||
async startScriptExecution(ws, scriptPath, executionId, mode = 'local', server = null, envVars = {}) {
|
||
/** @type {number|null} */
|
||
let installationId = null;
|
||
|
||
try {
|
||
|
||
// Check if execution is already running
|
||
if (this.activeExecutions.has(executionId)) {
|
||
this.sendMessage(ws, {
|
||
type: 'error',
|
||
data: 'Script execution already running',
|
||
timestamp: Date.now()
|
||
});
|
||
return;
|
||
}
|
||
|
||
// Extract script name from path
|
||
const scriptName = scriptPath.split('/').pop() ?? scriptPath.split('\\').pop() ?? 'Unknown Script';
|
||
|
||
// Create installation record
|
||
const serverId = server ? (server.id ?? null) : null;
|
||
installationId = await this.createInstallationRecord(scriptName, scriptPath, mode, serverId);
|
||
|
||
if (!installationId) {
|
||
console.error('Failed to create installation record');
|
||
}
|
||
|
||
// Handle SSH execution
|
||
if (mode === 'ssh' && server) {
|
||
await this.startSSHScriptExecution(ws, scriptPath, executionId, server, installationId, envVars);
|
||
return;
|
||
}
|
||
|
||
if (mode === 'ssh' && !server) {
|
||
// SSH mode requested but no server provided, falling back to local execution
|
||
}
|
||
|
||
// Basic validation for local execution
|
||
const scriptsDir = join(process.cwd(), 'scripts');
|
||
const resolvedPath = resolve(scriptPath);
|
||
|
||
if (!resolvedPath.startsWith(resolve(scriptsDir))) {
|
||
this.sendMessage(ws, {
|
||
type: 'error',
|
||
data: 'Script path is not within the allowed scripts directory',
|
||
timestamp: Date.now()
|
||
});
|
||
|
||
// Update installation record with failure
|
||
if (installationId) {
|
||
await this.updateInstallationRecord(installationId, { status: 'failed' });
|
||
}
|
||
return;
|
||
}
|
||
|
||
// Format environment variables for local execution
|
||
// Convert envVars object to environment variables
|
||
const envWithVars = {
|
||
...process.env,
|
||
TERM: 'xterm-256color', // Enable proper terminal support
|
||
FORCE_ANSI: 'true', // Allow ANSI codes for proper display
|
||
COLUMNS: '80', // Set terminal width
|
||
LINES: '24' // Set terminal height
|
||
};
|
||
|
||
// Add envVars to environment
|
||
if (envVars && typeof envVars === 'object') {
|
||
for (const [key, value] of Object.entries(envVars)) {
|
||
/** @type {Record<string, string>} */
|
||
const envRecord = envWithVars;
|
||
envRecord[key] = String(value);
|
||
}
|
||
}
|
||
|
||
// Start script execution with pty for proper TTY support
|
||
const childProcess = ptySpawn('bash', [resolvedPath], {
|
||
cwd: scriptsDir,
|
||
name: 'xterm-256color',
|
||
cols: 80,
|
||
rows: 24,
|
||
env: envWithVars
|
||
});
|
||
|
||
// pty handles encoding automatically
|
||
|
||
// Store the execution with installation ID
|
||
this.activeExecutions.set(executionId, {
|
||
process: childProcess,
|
||
ws,
|
||
installationId,
|
||
outputBuffer: ''
|
||
});
|
||
|
||
// Send start message
|
||
this.sendMessage(ws, {
|
||
type: 'start',
|
||
data: `Starting execution of ${scriptPath}`,
|
||
timestamp: Date.now()
|
||
});
|
||
|
||
// Handle pty data (both stdout and stderr combined)
|
||
childProcess.onData(async (data) => {
|
||
const output = data.toString();
|
||
|
||
// Store output in buffer for logging
|
||
const execution = this.activeExecutions.get(executionId);
|
||
if (execution) {
|
||
execution.outputBuffer += output;
|
||
// Keep only last 1000 characters to avoid memory issues
|
||
if (execution.outputBuffer.length > 1000) {
|
||
execution.outputBuffer = execution.outputBuffer.slice(-1000);
|
||
}
|
||
}
|
||
|
||
// Parse for Container ID
|
||
const containerId = this.parseContainerId(output);
|
||
if (containerId && installationId) {
|
||
await this.updateInstallationRecord(installationId, { container_id: containerId });
|
||
}
|
||
|
||
// Parse for Web UI URL
|
||
const webUIUrl = this.parseWebUIUrl(output);
|
||
if (webUIUrl && installationId) {
|
||
const { ip, port } = webUIUrl;
|
||
if (ip && port) {
|
||
await this.updateInstallationRecord(installationId, {
|
||
web_ui_ip: ip,
|
||
web_ui_port: port
|
||
});
|
||
}
|
||
}
|
||
|
||
this.sendMessage(ws, {
|
||
type: 'output',
|
||
data: output,
|
||
timestamp: Date.now()
|
||
});
|
||
});
|
||
|
||
// Handle process exit
|
||
childProcess.onExit((e) => {
|
||
const execution = this.activeExecutions.get(executionId);
|
||
const isSuccess = e.exitCode === 0;
|
||
|
||
// Update installation record with final status and output
|
||
if (installationId && execution) {
|
||
this.updateInstallationRecord(installationId, {
|
||
status: isSuccess ? 'success' : 'failed',
|
||
output_log: execution.outputBuffer
|
||
});
|
||
}
|
||
|
||
this.sendMessage(ws, {
|
||
type: 'end',
|
||
data: `Script execution finished with code: ${e.exitCode}, signal: ${e.signal}`,
|
||
timestamp: Date.now()
|
||
});
|
||
|
||
// Clean up
|
||
this.activeExecutions.delete(executionId);
|
||
});
|
||
|
||
} catch (error) {
|
||
this.sendMessage(ws, {
|
||
type: 'error',
|
||
data: `Failed to start script: ${error instanceof Error ? error.message : String(error)}`,
|
||
timestamp: Date.now()
|
||
});
|
||
|
||
// Update installation record with failure
|
||
if (installationId) {
|
||
await this.updateInstallationRecord(installationId, { status: 'failed' });
|
||
}
|
||
}
|
||
}
|
||
|
||
/**
|
||
* Start SSH script execution
|
||
* @param {ExtendedWebSocket} ws
|
||
* @param {string} scriptPath
|
||
* @param {string} executionId
|
||
* @param {ServerInfo} server
|
||
* @param {number|null} installationId
|
||
* @param {Object} [envVars] - Optional environment variables to pass to the script
|
||
*/
|
||
async startSSHScriptExecution(ws, scriptPath, executionId, server, installationId = null, envVars = {}) {
|
||
const sshService = getSSHExecutionService();
|
||
|
||
// Send start message
|
||
this.sendMessage(ws, {
|
||
type: 'start',
|
||
data: `Starting SSH execution of ${scriptPath} on ${server.name} (${server.ip})`,
|
||
timestamp: Date.now()
|
||
});
|
||
|
||
try {
|
||
const execution = /** @type {ExecutionResult} */ (await sshService.executeScript(
|
||
server,
|
||
scriptPath,
|
||
/** @param {string} data */ async (data) => {
|
||
// Store output in buffer for logging
|
||
const exec = this.activeExecutions.get(executionId);
|
||
if (exec) {
|
||
exec.outputBuffer += data;
|
||
// Keep only last 1000 characters to avoid memory issues
|
||
if (exec.outputBuffer.length > 1000) {
|
||
exec.outputBuffer = exec.outputBuffer.slice(-1000);
|
||
}
|
||
}
|
||
|
||
// Parse for Container ID
|
||
const containerId = this.parseContainerId(data);
|
||
if (containerId && installationId) {
|
||
await this.updateInstallationRecord(installationId, { container_id: containerId });
|
||
}
|
||
|
||
// Parse for Web UI URL
|
||
const webUIUrl = this.parseWebUIUrl(data);
|
||
if (webUIUrl && installationId) {
|
||
const { ip, port } = webUIUrl;
|
||
if (ip && port) {
|
||
await this.updateInstallationRecord(installationId, {
|
||
web_ui_ip: ip,
|
||
web_ui_port: port
|
||
});
|
||
}
|
||
}
|
||
|
||
// Handle data output
|
||
this.sendMessage(ws, {
|
||
type: 'output',
|
||
data: data,
|
||
timestamp: Date.now()
|
||
});
|
||
},
|
||
/** @param {string} error */ (error) => {
|
||
// Store error in buffer for logging
|
||
const exec = this.activeExecutions.get(executionId);
|
||
if (exec) {
|
||
exec.outputBuffer += error;
|
||
// Keep only last 1000 characters to avoid memory issues
|
||
if (exec.outputBuffer.length > 1000) {
|
||
exec.outputBuffer = exec.outputBuffer.slice(-1000);
|
||
}
|
||
}
|
||
|
||
// Handle errors
|
||
this.sendMessage(ws, {
|
||
type: 'error',
|
||
data: error,
|
||
timestamp: Date.now()
|
||
});
|
||
},
|
||
/** @param {number} code */ async (code) => {
|
||
const exec = this.activeExecutions.get(executionId);
|
||
const isSuccess = code === 0;
|
||
|
||
// Update installation record with final status and output
|
||
if (installationId && exec) {
|
||
await this.updateInstallationRecord(installationId, {
|
||
status: isSuccess ? 'success' : 'failed',
|
||
output_log: exec.outputBuffer
|
||
});
|
||
}
|
||
|
||
// Handle process exit
|
||
this.sendMessage(ws, {
|
||
type: 'end',
|
||
data: `SSH script execution finished with code: ${code}`,
|
||
timestamp: Date.now()
|
||
});
|
||
|
||
// Clean up
|
||
this.activeExecutions.delete(executionId);
|
||
},
|
||
envVars
|
||
));
|
||
|
||
// Store the execution with installation ID
|
||
this.activeExecutions.set(executionId, {
|
||
process: execution.process,
|
||
ws,
|
||
installationId,
|
||
outputBuffer: ''
|
||
});
|
||
|
||
} catch (error) {
|
||
this.sendMessage(ws, {
|
||
type: 'error',
|
||
data: `Failed to start SSH execution: ${error instanceof Error ? error.message : String(error)}`,
|
||
timestamp: Date.now()
|
||
});
|
||
|
||
// Update installation record with failure
|
||
if (installationId) {
|
||
await this.updateInstallationRecord(installationId, { status: 'failed' });
|
||
}
|
||
}
|
||
}
|
||
|
||
/**
|
||
* @param {string} executionId
|
||
*/
|
||
stopScriptExecution(executionId) {
|
||
const execution = this.activeExecutions.get(executionId);
|
||
if (execution) {
|
||
execution.process.kill('SIGTERM');
|
||
this.activeExecutions.delete(executionId);
|
||
|
||
this.sendMessage(execution.ws, {
|
||
type: 'end',
|
||
data: 'Script execution stopped by user',
|
||
timestamp: Date.now()
|
||
});
|
||
}
|
||
}
|
||
|
||
/**
|
||
* @param {string} executionId
|
||
* @param {string} input
|
||
*/
|
||
sendInputToProcess(executionId, input) {
|
||
const execution = this.activeExecutions.get(executionId);
|
||
if (execution && execution.process.write) {
|
||
execution.process.write(input);
|
||
}
|
||
}
|
||
|
||
/**
|
||
* @param {ExtendedWebSocket} ws
|
||
* @param {any} message
|
||
*/
|
||
sendMessage(ws, message) {
|
||
if (ws.readyState === 1) { // WebSocket.OPEN
|
||
ws.send(JSON.stringify(message));
|
||
}
|
||
}
|
||
|
||
/**
|
||
* @param {ExtendedWebSocket} ws
|
||
*/
|
||
cleanupActiveExecutions(ws) {
|
||
for (const [executionId, execution] of this.activeExecutions.entries()) {
|
||
if (execution.ws === ws) {
|
||
execution.process.kill('SIGTERM');
|
||
this.activeExecutions.delete(executionId);
|
||
}
|
||
}
|
||
}
|
||
|
||
/**
|
||
* Start backup execution
|
||
* @param {ExtendedWebSocket} ws
|
||
* @param {string} containerId
|
||
* @param {string} executionId
|
||
* @param {string} storage
|
||
* @param {string} mode
|
||
* @param {ServerInfo|null} server
|
||
*/
|
||
async startBackupExecution(ws, containerId, executionId, storage, mode = 'local', server = null) {
|
||
try {
|
||
// Send start message
|
||
this.sendMessage(ws, {
|
||
type: 'start',
|
||
data: `Starting backup for container ${containerId} to storage ${storage}...`,
|
||
timestamp: Date.now()
|
||
});
|
||
|
||
if (mode === 'ssh' && server) {
|
||
await this.startSSHBackupExecution(ws, containerId, executionId, storage, server);
|
||
} else {
|
||
this.sendMessage(ws, {
|
||
type: 'error',
|
||
data: 'Backup is only supported via SSH',
|
||
timestamp: Date.now()
|
||
});
|
||
}
|
||
} catch (error) {
|
||
this.sendMessage(ws, {
|
||
type: 'error',
|
||
data: `Failed to start backup: ${error instanceof Error ? error.message : String(error)}`,
|
||
timestamp: Date.now()
|
||
});
|
||
}
|
||
}
|
||
|
||
/**
|
||
* Start SSH backup execution
|
||
* @param {ExtendedWebSocket} ws
|
||
* @param {string} containerId
|
||
* @param {string} executionId
|
||
* @param {string} storage
|
||
* @param {ServerInfo} server
|
||
* @param {Function} [onComplete] - Optional callback when backup completes
|
||
*/
|
||
startSSHBackupExecution(ws, containerId, executionId, storage, server, onComplete = undefined) {
|
||
const sshService = getSSHExecutionService();
|
||
|
||
return new Promise((resolve, reject) => {
|
||
try {
|
||
const backupCommand = `vzdump ${containerId} --storage ${storage} --mode snapshot`;
|
||
|
||
// Wrap the onExit callback to resolve our promise
|
||
let promiseResolved = false;
|
||
|
||
sshService.executeCommand(
|
||
server,
|
||
backupCommand,
|
||
/** @param {string} data */
|
||
(data) => {
|
||
this.sendMessage(ws, {
|
||
type: 'output',
|
||
data: data,
|
||
timestamp: Date.now()
|
||
});
|
||
},
|
||
/** @param {string} error */
|
||
(error) => {
|
||
this.sendMessage(ws, {
|
||
type: 'error',
|
||
data: error,
|
||
timestamp: Date.now()
|
||
});
|
||
},
|
||
/** @param {number} code */
|
||
(code) => {
|
||
// Don't send 'end' message here if this is part of a backup+update flow
|
||
// The update flow will handle completion messages
|
||
const success = code === 0;
|
||
|
||
if (!success) {
|
||
this.sendMessage(ws, {
|
||
type: 'error',
|
||
data: `Backup failed with exit code: ${code}`,
|
||
timestamp: Date.now()
|
||
});
|
||
}
|
||
|
||
// Send a completion message (but not 'end' type to avoid stopping terminal)
|
||
this.sendMessage(ws, {
|
||
type: 'output',
|
||
data: `\n[Backup ${success ? 'completed' : 'failed'} with exit code: ${code}]\n`,
|
||
timestamp: Date.now()
|
||
});
|
||
|
||
if (onComplete) onComplete(success);
|
||
|
||
// Resolve the promise when backup completes
|
||
// Use setImmediate to ensure resolution happens in the right execution context
|
||
if (!promiseResolved) {
|
||
promiseResolved = true;
|
||
const result = { success, code };
|
||
|
||
// Use setImmediate to ensure promise resolution happens in the next tick
|
||
// This ensures the await in startUpdateExecution can properly resume
|
||
setImmediate(() => {
|
||
try {
|
||
resolve(result);
|
||
} catch (resolveError) {
|
||
console.error('Error resolving backup promise:', resolveError);
|
||
reject(resolveError);
|
||
}
|
||
});
|
||
}
|
||
|
||
this.activeExecutions.delete(executionId);
|
||
}
|
||
).then((execution) => {
|
||
// Store the execution
|
||
this.activeExecutions.set(executionId, {
|
||
process: /** @type {any} */ (execution).process,
|
||
ws
|
||
});
|
||
// Note: Don't resolve here - wait for onExit callback
|
||
}).catch((error) => {
|
||
console.error('Error starting backup execution:', error);
|
||
this.sendMessage(ws, {
|
||
type: 'error',
|
||
data: `SSH backup execution failed: ${error instanceof Error ? error.message : String(error)}`,
|
||
timestamp: Date.now()
|
||
});
|
||
if (onComplete) onComplete(false);
|
||
if (!promiseResolved) {
|
||
promiseResolved = true;
|
||
reject(error);
|
||
}
|
||
});
|
||
|
||
} catch (error) {
|
||
console.error('Error in startSSHBackupExecution:', error);
|
||
this.sendMessage(ws, {
|
||
type: 'error',
|
||
data: `SSH backup execution failed: ${error instanceof Error ? error.message : String(error)}`,
|
||
timestamp: Date.now()
|
||
});
|
||
if (onComplete) onComplete(false);
|
||
reject(error);
|
||
}
|
||
});
|
||
}
|
||
|
||
/**
|
||
* Start SSH clone execution
|
||
* Gets next IDs sequentially: get next ID → clone → get next ID → clone, etc.
|
||
* @param {ExtendedWebSocket} ws
|
||
* @param {string} containerId
|
||
* @param {string} executionId
|
||
* @param {string} storage
|
||
* @param {ServerInfo} server
|
||
* @param {'lxc'|'vm'} containerType
|
||
* @param {number} cloneCount
|
||
* @param {string[]} hostnames
|
||
*/
|
||
async startSSHCloneExecution(ws, containerId, executionId, storage, server, containerType, cloneCount, hostnames) {
|
||
const sshService = getSSHExecutionService();
|
||
|
||
this.sendMessage(ws, {
|
||
type: 'start',
|
||
data: `Starting clone operation: Creating ${cloneCount} clone(s) of ${containerType.toUpperCase()} ${containerId}...`,
|
||
timestamp: Date.now()
|
||
});
|
||
|
||
try {
|
||
// Step 1: Stop source container/VM
|
||
this.sendMessage(ws, {
|
||
type: 'output',
|
||
data: `\n[Step 1/${4 + cloneCount}] Stopping source ${containerType.toUpperCase()} ${containerId}...\n`,
|
||
timestamp: Date.now()
|
||
});
|
||
|
||
const stopCommand = containerType === 'lxc' ? `pct stop ${containerId}` : `qm stop ${containerId}`;
|
||
await new Promise(/** @type {(resolve: (value?: void) => void, reject: (error?: any) => void) => void} */ ((resolve, reject) => {
|
||
sshService.executeCommand(
|
||
server,
|
||
stopCommand,
|
||
/** @param {string} data */
|
||
(data) => {
|
||
this.sendMessage(ws, {
|
||
type: 'output',
|
||
data: data,
|
||
timestamp: Date.now()
|
||
});
|
||
},
|
||
/** @param {string} error */
|
||
(error) => {
|
||
this.sendMessage(ws, {
|
||
type: 'error',
|
||
data: error,
|
||
timestamp: Date.now()
|
||
});
|
||
},
|
||
/** @param {number} code */
|
||
(code) => {
|
||
if (code === 0) {
|
||
this.sendMessage(ws, {
|
||
type: 'output',
|
||
data: `\n[Step 1/${4 + cloneCount}] Source ${containerType.toUpperCase()} stopped successfully.\n`,
|
||
timestamp: Date.now()
|
||
});
|
||
resolve();
|
||
} else {
|
||
// Continue even if stop fails (might already be stopped)
|
||
this.sendMessage(ws, {
|
||
type: 'output',
|
||
data: `\n[Step 1/${4 + cloneCount}] Stop command completed with exit code ${code} (container may already be stopped).\n`,
|
||
timestamp: Date.now()
|
||
});
|
||
resolve();
|
||
}
|
||
}
|
||
);
|
||
}));
|
||
|
||
// Step 2: Clone for each clone count (get next ID sequentially before each clone)
|
||
const clonedIds = [];
|
||
for (let i = 0; i < cloneCount; i++) {
|
||
const cloneNumber = i + 1;
|
||
const hostname = hostnames[i];
|
||
|
||
// Get next ID for this clone
|
||
this.sendMessage(ws, {
|
||
type: 'output',
|
||
data: `\n[Step ${2 + i}/${4 + cloneCount}] Getting next available ID for clone ${cloneNumber}...\n`,
|
||
timestamp: Date.now()
|
||
});
|
||
|
||
let nextId = '';
|
||
try {
|
||
let output = '';
|
||
await new Promise(/** @type {(resolve: (value?: void) => void, reject: (error?: any) => void) => void} */ ((resolve, reject) => {
|
||
sshService.executeCommand(
|
||
server,
|
||
'pvesh get /cluster/nextid',
|
||
/** @param {string} data */
|
||
(data) => {
|
||
output += data;
|
||
},
|
||
/** @param {string} error */
|
||
(error) => {
|
||
reject(new Error(`Failed to get next ID: ${error}`));
|
||
},
|
||
/** @param {number} exitCode */
|
||
(exitCode) => {
|
||
if (exitCode === 0) {
|
||
resolve();
|
||
} else {
|
||
reject(new Error(`pvesh command failed with exit code ${exitCode}`));
|
||
}
|
||
}
|
||
);
|
||
}));
|
||
|
||
nextId = output.trim();
|
||
if (!nextId || !/^\d+$/.test(nextId)) {
|
||
throw new Error('Invalid next ID received');
|
||
}
|
||
|
||
this.sendMessage(ws, {
|
||
type: 'output',
|
||
data: `\n[Step ${2 + i}/${4 + cloneCount}] Got next ID: ${nextId}\n`,
|
||
timestamp: Date.now()
|
||
});
|
||
} catch (error) {
|
||
this.sendMessage(ws, {
|
||
type: 'error',
|
||
data: `\n[Step ${2 + i}/${4 + cloneCount}] Failed to get next ID: ${error instanceof Error ? error.message : String(error)}\n`,
|
||
timestamp: Date.now()
|
||
});
|
||
throw error;
|
||
}
|
||
|
||
clonedIds.push(nextId);
|
||
|
||
// Clone the container/VM
|
||
this.sendMessage(ws, {
|
||
type: 'output',
|
||
data: `\n[Step ${2 + i}/${4 + cloneCount}] Cloning ${containerType.toUpperCase()} ${containerId} to ${nextId} with hostname ${hostname}...\n`,
|
||
timestamp: Date.now()
|
||
});
|
||
|
||
const cloneCommand = containerType === 'lxc'
|
||
? `pct clone ${containerId} ${nextId} --hostname ${hostname} --storage ${storage}`
|
||
: `qm clone ${containerId} ${nextId} --name ${hostname} --storage ${storage}`;
|
||
|
||
await new Promise(/** @type {(resolve: (value?: void) => void, reject: (error?: any) => void) => void} */ ((resolve, reject) => {
|
||
sshService.executeCommand(
|
||
server,
|
||
cloneCommand,
|
||
/** @param {string} data */
|
||
(data) => {
|
||
this.sendMessage(ws, {
|
||
type: 'output',
|
||
data: data,
|
||
timestamp: Date.now()
|
||
});
|
||
},
|
||
/** @param {string} error */
|
||
(error) => {
|
||
this.sendMessage(ws, {
|
||
type: 'error',
|
||
data: error,
|
||
timestamp: Date.now()
|
||
});
|
||
},
|
||
/** @param {number} code */
|
||
(code) => {
|
||
if (code === 0) {
|
||
this.sendMessage(ws, {
|
||
type: 'output',
|
||
data: `\n[Step ${2 + i}/${4 + cloneCount}] Clone ${cloneNumber} created successfully.\n`,
|
||
timestamp: Date.now()
|
||
});
|
||
resolve();
|
||
} else {
|
||
this.sendMessage(ws, {
|
||
type: 'error',
|
||
data: `\nClone ${cloneNumber} failed with exit code: ${code}\n`,
|
||
timestamp: Date.now()
|
||
});
|
||
reject(new Error(`Clone ${cloneNumber} failed with exit code ${code}`));
|
||
}
|
||
}
|
||
);
|
||
}));
|
||
}
|
||
|
||
// Step 3: Start source container/VM
|
||
this.sendMessage(ws, {
|
||
type: 'output',
|
||
data: `\n[Step ${2 + cloneCount + 1}/${4 + cloneCount}] Starting source ${containerType.toUpperCase()} ${containerId}...\n`,
|
||
timestamp: Date.now()
|
||
});
|
||
|
||
const startSourceCommand = containerType === 'lxc' ? `pct start ${containerId}` : `qm start ${containerId}`;
|
||
await new Promise(/** @type {(resolve: (value?: void) => void, reject: (error?: any) => void) => void} */ ((resolve) => {
|
||
sshService.executeCommand(
|
||
server,
|
||
startSourceCommand,
|
||
/** @param {string} data */
|
||
(data) => {
|
||
this.sendMessage(ws, {
|
||
type: 'output',
|
||
data: data,
|
||
timestamp: Date.now()
|
||
});
|
||
},
|
||
/** @param {string} error */
|
||
(error) => {
|
||
this.sendMessage(ws, {
|
||
type: 'error',
|
||
data: error,
|
||
timestamp: Date.now()
|
||
});
|
||
},
|
||
/** @param {number} code */
|
||
(code) => {
|
||
if (code === 0) {
|
||
this.sendMessage(ws, {
|
||
type: 'output',
|
||
data: `\n[Step ${2 + cloneCount + 1}/${4 + cloneCount}] Source ${containerType.toUpperCase()} started successfully.\n`,
|
||
timestamp: Date.now()
|
||
});
|
||
} else {
|
||
this.sendMessage(ws, {
|
||
type: 'output',
|
||
data: `\n[Step ${2 + cloneCount + 1}/${4 + cloneCount}] Start command completed with exit code ${code}.\n`,
|
||
timestamp: Date.now()
|
||
});
|
||
}
|
||
resolve();
|
||
}
|
||
);
|
||
}));
|
||
|
||
// Step 4: Start target containers/VMs
|
||
this.sendMessage(ws, {
|
||
type: 'output',
|
||
data: `\n[Step ${2 + cloneCount + 2}/${4 + cloneCount}] Starting cloned ${containerType.toUpperCase()}(s)...\n`,
|
||
timestamp: Date.now()
|
||
});
|
||
|
||
for (let i = 0; i < cloneCount; i++) {
|
||
const cloneNumber = i + 1;
|
||
const nextId = clonedIds[i];
|
||
|
||
const startTargetCommand = containerType === 'lxc' ? `pct start ${nextId}` : `qm start ${nextId}`;
|
||
await new Promise(/** @type {(resolve: (value?: void) => void, reject: (error?: any) => void) => void} */ ((resolve) => {
|
||
sshService.executeCommand(
|
||
server,
|
||
startTargetCommand,
|
||
/** @param {string} data */
|
||
(data) => {
|
||
this.sendMessage(ws, {
|
||
type: 'output',
|
||
data: data,
|
||
timestamp: Date.now()
|
||
});
|
||
},
|
||
/** @param {string} error */
|
||
(error) => {
|
||
this.sendMessage(ws, {
|
||
type: 'error',
|
||
data: error,
|
||
timestamp: Date.now()
|
||
});
|
||
},
|
||
/** @param {number} code */
|
||
(code) => {
|
||
if (code === 0) {
|
||
this.sendMessage(ws, {
|
||
type: 'output',
|
||
data: `\nClone ${cloneNumber} (ID: ${nextId}) started successfully.\n`,
|
||
timestamp: Date.now()
|
||
});
|
||
} else {
|
||
this.sendMessage(ws, {
|
||
type: 'output',
|
||
data: `\nClone ${cloneNumber} (ID: ${nextId}) start completed with exit code ${code}.\n`,
|
||
timestamp: Date.now()
|
||
});
|
||
}
|
||
resolve();
|
||
}
|
||
);
|
||
}));
|
||
}
|
||
|
||
// Step 5: Add to database
|
||
this.sendMessage(ws, {
|
||
type: 'output',
|
||
data: `\n[Step ${2 + cloneCount + 3}/${4 + cloneCount}] Adding cloned ${containerType.toUpperCase()}(s) to database...\n`,
|
||
timestamp: Date.now()
|
||
});
|
||
|
||
for (let i = 0; i < cloneCount; i++) {
|
||
const nextId = clonedIds[i];
|
||
const hostname = hostnames[i];
|
||
|
||
try {
|
||
// Read config file to get hostname/name (node-specific path)
|
||
const nodeName = server.name;
|
||
const configPath = containerType === 'lxc'
|
||
? `/etc/pve/nodes/${nodeName}/lxc/${nextId}.conf`
|
||
: `/etc/pve/nodes/${nodeName}/qemu-server/${nextId}.conf`;
|
||
|
||
let configContent = '';
|
||
await new Promise(/** @type {(resolve: (value?: void) => void) => void} */ ((resolve) => {
|
||
sshService.executeCommand(
|
||
server,
|
||
`cat "${configPath}" 2>/dev/null || echo ""`,
|
||
/** @param {string} data */
|
||
(data) => {
|
||
configContent += data;
|
||
},
|
||
() => resolve(),
|
||
() => resolve()
|
||
);
|
||
}));
|
||
|
||
// Parse config for hostname/name
|
||
let finalHostname = hostname;
|
||
if (configContent.trim()) {
|
||
const lines = configContent.split('\n');
|
||
for (const line of lines) {
|
||
const trimmed = line.trim();
|
||
if (containerType === 'lxc' && trimmed.startsWith('hostname:')) {
|
||
finalHostname = trimmed.substring(9).trim();
|
||
break;
|
||
} else if (containerType === 'vm' && trimmed.startsWith('name:')) {
|
||
finalHostname = trimmed.substring(5).trim();
|
||
break;
|
||
}
|
||
}
|
||
}
|
||
|
||
if (!finalHostname) {
|
||
finalHostname = `${containerType}-${nextId}`;
|
||
}
|
||
|
||
// Create installed script record
|
||
const script = await this.db.createInstalledScript({
|
||
script_name: finalHostname,
|
||
script_path: `cloned/${finalHostname}`,
|
||
container_id: nextId,
|
||
server_id: server.id,
|
||
execution_mode: 'ssh',
|
||
status: 'success',
|
||
output_log: `Cloned ${containerType.toUpperCase()}`
|
||
});
|
||
|
||
// For LXC, store config in database
|
||
if (containerType === 'lxc' && configContent.trim()) {
|
||
// Simple config parser
|
||
/** @type {any} */
|
||
const configData = {};
|
||
const lines = configContent.split('\n');
|
||
for (const line of lines) {
|
||
const trimmed = line.trim();
|
||
if (!trimmed || trimmed.startsWith('#')) continue;
|
||
|
||
const [key, ...valueParts] = trimmed.split(':');
|
||
const value = valueParts.join(':').trim();
|
||
|
||
if (key === 'hostname') configData.hostname = value;
|
||
else if (key === 'arch') configData.arch = value;
|
||
else if (key === 'cores') configData.cores = parseInt(value) || null;
|
||
else if (key === 'memory') configData.memory = parseInt(value) || null;
|
||
else if (key === 'swap') configData.swap = parseInt(value) || null;
|
||
else if (key === 'onboot') configData.onboot = parseInt(value) || null;
|
||
else if (key === 'ostype') configData.ostype = value;
|
||
else if (key === 'unprivileged') configData.unprivileged = parseInt(value) || null;
|
||
else if (key === 'tags') configData.tags = value;
|
||
else if (key === 'rootfs') {
|
||
const match = value.match(/^([^:]+):([^,]+)/);
|
||
if (match) {
|
||
configData.rootfs_storage = match[1];
|
||
const sizeMatch = value.match(/size=([^,]+)/);
|
||
if (sizeMatch) {
|
||
configData.rootfs_size = sizeMatch[1];
|
||
}
|
||
}
|
||
}
|
||
}
|
||
|
||
await this.db.createLXCConfig(script.id, configData);
|
||
}
|
||
|
||
this.sendMessage(ws, {
|
||
type: 'output',
|
||
data: `\nClone ${i + 1} (ID: ${nextId}, Hostname: ${finalHostname}) added to database successfully.\n`,
|
||
timestamp: Date.now()
|
||
});
|
||
} catch (error) {
|
||
this.sendMessage(ws, {
|
||
type: 'error',
|
||
data: `\nError adding clone ${i + 1} (ID: ${nextId}) to database: ${error instanceof Error ? error.message : String(error)}\n`,
|
||
timestamp: Date.now()
|
||
});
|
||
}
|
||
}
|
||
|
||
this.sendMessage(ws, {
|
||
type: 'output',
|
||
data: `\n\n[Clone operation completed successfully!]\nCreated ${cloneCount} clone(s) of ${containerType.toUpperCase()} ${containerId}.\n`,
|
||
timestamp: Date.now()
|
||
});
|
||
|
||
this.activeExecutions.delete(executionId);
|
||
} catch (error) {
|
||
this.sendMessage(ws, {
|
||
type: 'error',
|
||
data: `\n\n[Clone operation failed!]\nError: ${error instanceof Error ? error.message : String(error)}\n`,
|
||
timestamp: Date.now()
|
||
});
|
||
this.activeExecutions.delete(executionId);
|
||
}
|
||
}
|
||
|
||
/**
|
||
* Start update execution (pct enter + update command)
|
||
* @param {ExtendedWebSocket} ws
|
||
* @param {string} containerId
|
||
* @param {string} executionId
|
||
* @param {string} mode
|
||
* @param {ServerInfo|undefined} server
|
||
* @param {string} [backupStorage] - Optional storage to backup to before update
|
||
*/
|
||
async startUpdateExecution(ws, containerId, executionId, mode = 'local', server = undefined, backupStorage = undefined) {
|
||
try {
|
||
// If backup storage is provided, run backup first
|
||
if (backupStorage && mode === 'ssh' && server) {
|
||
this.sendMessage(ws, {
|
||
type: 'start',
|
||
data: `Starting backup before update for container ${containerId}...`,
|
||
timestamp: Date.now()
|
||
});
|
||
|
||
// Create a separate execution ID for backup
|
||
const backupExecutionId = `backup_${executionId}`;
|
||
|
||
// Run backup and wait for it to complete
|
||
try {
|
||
const backupResult = await this.startSSHBackupExecution(
|
||
ws,
|
||
containerId,
|
||
backupExecutionId,
|
||
backupStorage,
|
||
server
|
||
);
|
||
|
||
// Backup completed (successfully or not)
|
||
if (!backupResult || !backupResult.success) {
|
||
// Backup failed, but we'll still allow update (per requirement 1b)
|
||
this.sendMessage(ws, {
|
||
type: 'output',
|
||
data: '\n⚠️ Backup failed, but proceeding with update as requested...\n',
|
||
timestamp: Date.now()
|
||
});
|
||
} else {
|
||
// Backup succeeded
|
||
this.sendMessage(ws, {
|
||
type: 'output',
|
||
data: '\n✅ Backup completed successfully. Starting update...\n',
|
||
timestamp: Date.now()
|
||
});
|
||
}
|
||
} catch (error) {
|
||
console.error('Backup error before update:', error);
|
||
// Backup failed to start, but allow update to proceed
|
||
this.sendMessage(ws, {
|
||
type: 'output',
|
||
data: `\n⚠️ Backup error: ${error instanceof Error ? error.message : String(error)}. Proceeding with update...\n`,
|
||
timestamp: Date.now()
|
||
});
|
||
}
|
||
|
||
// Small delay before starting update
|
||
await new Promise(resolve => setTimeout(resolve, 1000));
|
||
}
|
||
|
||
// Send start message for update (only if we're actually starting an update)
|
||
this.sendMessage(ws, {
|
||
type: 'start',
|
||
data: `Starting update for container ${containerId}...`,
|
||
timestamp: Date.now()
|
||
});
|
||
|
||
if (mode === 'ssh' && server) {
|
||
await this.startSSHUpdateExecution(ws, containerId, executionId, server);
|
||
} else {
|
||
await this.startLocalUpdateExecution(ws, containerId, executionId);
|
||
}
|
||
|
||
} catch (error) {
|
||
this.sendMessage(ws, {
|
||
type: 'error',
|
||
data: `Failed to start update: ${error instanceof Error ? error.message : String(error)}`,
|
||
timestamp: Date.now()
|
||
});
|
||
}
|
||
}
|
||
|
||
/**
|
||
* Start local update execution
|
||
* @param {ExtendedWebSocket} ws
|
||
* @param {string} containerId
|
||
* @param {string} executionId
|
||
*/
|
||
async startLocalUpdateExecution(ws, containerId, executionId) {
|
||
const { spawn } = await import('node-pty');
|
||
|
||
// Create a shell process that will run pct enter and then update
|
||
const childProcess = spawn('bash', ['-c', `pct enter ${containerId}`], {
|
||
name: 'xterm-color',
|
||
cols: 80,
|
||
rows: 24,
|
||
cwd: process.cwd(),
|
||
env: process.env
|
||
});
|
||
|
||
// Store the execution
|
||
this.activeExecutions.set(executionId, {
|
||
process: childProcess,
|
||
ws
|
||
});
|
||
|
||
// Handle pty data
|
||
childProcess.onData((data) => {
|
||
this.sendMessage(ws, {
|
||
type: 'output',
|
||
data: data.toString(),
|
||
timestamp: Date.now()
|
||
});
|
||
});
|
||
|
||
// Send the update command after a delay to ensure we're in the container
|
||
setTimeout(() => {
|
||
childProcess.write('update\n');
|
||
}, 4000);
|
||
|
||
// Handle process exit
|
||
childProcess.onExit((e) => {
|
||
this.sendMessage(ws, {
|
||
type: 'end',
|
||
data: `Update completed with exit code: ${e.exitCode}`,
|
||
timestamp: Date.now()
|
||
});
|
||
|
||
this.activeExecutions.delete(executionId);
|
||
});
|
||
}
|
||
|
||
/**
|
||
* Start SSH update execution
|
||
* @param {ExtendedWebSocket} ws
|
||
* @param {string} containerId
|
||
* @param {string} executionId
|
||
* @param {ServerInfo} server
|
||
*/
|
||
async startSSHUpdateExecution(ws, containerId, executionId, server) {
|
||
const sshService = getSSHExecutionService();
|
||
|
||
try {
|
||
const execution = await sshService.executeCommand(
|
||
server,
|
||
`pct enter ${containerId}`,
|
||
/** @param {string} data */
|
||
(data) => {
|
||
this.sendMessage(ws, {
|
||
type: 'output',
|
||
data: data,
|
||
timestamp: Date.now()
|
||
});
|
||
},
|
||
/** @param {string} error */
|
||
(error) => {
|
||
this.sendMessage(ws, {
|
||
type: 'error',
|
||
data: error,
|
||
timestamp: Date.now()
|
||
});
|
||
},
|
||
/** @param {number} code */
|
||
(code) => {
|
||
this.sendMessage(ws, {
|
||
type: 'end',
|
||
data: `Update completed with exit code: ${code}`,
|
||
timestamp: Date.now()
|
||
});
|
||
|
||
this.activeExecutions.delete(executionId);
|
||
}
|
||
);
|
||
|
||
// Store the execution
|
||
this.activeExecutions.set(executionId, {
|
||
process: /** @type {any} */ (execution).process,
|
||
ws
|
||
});
|
||
|
||
// Send the update command after a delay to ensure we're in the container
|
||
setTimeout(() => {
|
||
/** @type {any} */ (execution).process.write('update\n');
|
||
}, 4000);
|
||
|
||
} catch (error) {
|
||
this.sendMessage(ws, {
|
||
type: 'error',
|
||
data: `SSH execution failed: ${error instanceof Error ? error.message : String(error)}`,
|
||
timestamp: Date.now()
|
||
});
|
||
}
|
||
}
|
||
|
||
/**
|
||
* Start shell execution
|
||
* @param {ExtendedWebSocket} ws
|
||
* @param {string} containerId
|
||
* @param {string} executionId
|
||
* @param {string} mode
|
||
* @param {ServerInfo|null} server
|
||
* @param {'lxc'|'vm'} [containerType='lxc']
|
||
*/
|
||
async startShellExecution(ws, containerId, executionId, mode = 'local', server = null, containerType = 'lxc') {
|
||
try {
|
||
const typeLabel = containerType === 'vm' ? 'VM' : 'container';
|
||
this.sendMessage(ws, {
|
||
type: 'start',
|
||
data: `Starting shell session for ${typeLabel} ${containerId}...`,
|
||
timestamp: Date.now()
|
||
});
|
||
|
||
if (mode === 'ssh' && server) {
|
||
await this.startSSHShellExecution(ws, containerId, executionId, server, containerType);
|
||
} else {
|
||
await this.startLocalShellExecution(ws, containerId, executionId, containerType);
|
||
}
|
||
|
||
} catch (error) {
|
||
this.sendMessage(ws, {
|
||
type: 'error',
|
||
data: `Failed to start shell: ${error instanceof Error ? error.message : String(error)}`,
|
||
timestamp: Date.now()
|
||
});
|
||
}
|
||
}
|
||
|
||
/**
|
||
* Start local shell execution
|
||
* @param {ExtendedWebSocket} ws
|
||
* @param {string} containerId
|
||
* @param {string} executionId
|
||
* @param {'lxc'|'vm'} [containerType='lxc']
|
||
*/
|
||
async startLocalShellExecution(ws, containerId, executionId, containerType = 'lxc') {
|
||
const { spawn } = await import('node-pty');
|
||
const shellCommand = containerType === 'vm' ? `qm terminal ${containerId}` : `pct enter ${containerId}`;
|
||
const childProcess = spawn('bash', ['-c', shellCommand], {
|
||
name: 'xterm-color',
|
||
cols: 80,
|
||
rows: 24,
|
||
cwd: process.cwd(),
|
||
env: process.env
|
||
});
|
||
|
||
// Store the execution
|
||
this.activeExecutions.set(executionId, {
|
||
process: childProcess,
|
||
ws
|
||
});
|
||
|
||
// Handle pty data
|
||
childProcess.onData((data) => {
|
||
this.sendMessage(ws, {
|
||
type: 'output',
|
||
data: data.toString(),
|
||
timestamp: Date.now()
|
||
});
|
||
});
|
||
|
||
// Note: No automatic command is sent - user can type commands interactively
|
||
|
||
// Handle process exit
|
||
childProcess.onExit((e) => {
|
||
this.sendMessage(ws, {
|
||
type: 'end',
|
||
data: `Shell session ended with exit code: ${e.exitCode}`,
|
||
timestamp: Date.now()
|
||
});
|
||
|
||
this.activeExecutions.delete(executionId);
|
||
});
|
||
}
|
||
|
||
/**
|
||
* Start SSH shell execution
|
||
* @param {ExtendedWebSocket} ws
|
||
* @param {string} containerId
|
||
* @param {string} executionId
|
||
* @param {ServerInfo} server
|
||
* @param {'lxc'|'vm'} [containerType='lxc']
|
||
*/
|
||
async startSSHShellExecution(ws, containerId, executionId, server, containerType = 'lxc') {
|
||
const sshService = getSSHExecutionService();
|
||
const shellCommand = containerType === 'vm' ? `qm terminal ${containerId}` : `pct enter ${containerId}`;
|
||
try {
|
||
const execution = await sshService.executeCommand(
|
||
server,
|
||
shellCommand,
|
||
/** @param {string} data */
|
||
(data) => {
|
||
this.sendMessage(ws, {
|
||
type: 'output',
|
||
data: data,
|
||
timestamp: Date.now()
|
||
});
|
||
},
|
||
/** @param {string} error */
|
||
(error) => {
|
||
this.sendMessage(ws, {
|
||
type: 'error',
|
||
data: error,
|
||
timestamp: Date.now()
|
||
});
|
||
},
|
||
/** @param {number} code */
|
||
(code) => {
|
||
this.sendMessage(ws, {
|
||
type: 'end',
|
||
data: `Shell session ended with exit code: ${code}`,
|
||
timestamp: Date.now()
|
||
});
|
||
|
||
this.activeExecutions.delete(executionId);
|
||
}
|
||
);
|
||
|
||
// Store the execution
|
||
this.activeExecutions.set(executionId, {
|
||
process: /** @type {any} */ (execution).process,
|
||
ws
|
||
});
|
||
|
||
// Note: No automatic command is sent - user can type commands interactively
|
||
|
||
} catch (error) {
|
||
this.sendMessage(ws, {
|
||
type: 'error',
|
||
data: `SSH shell execution failed: ${error instanceof Error ? error.message : String(error)}`,
|
||
timestamp: Date.now()
|
||
});
|
||
}
|
||
}
|
||
}
|
||
|
||
// TerminalHandler removed - not used by current application
|
||
|
||
app.prepare().then(() => {
|
||
console.log('> Next.js app prepared successfully');
|
||
const httpServer = createServer(async (req, res) => {
|
||
try {
|
||
// Be sure to pass `true` as the second argument to `url.parse`.
|
||
// This tells it to parse the query portion of the URL.
|
||
const parsedUrl = parse(req.url || '', true);
|
||
const { pathname, query } = parsedUrl;
|
||
|
||
// Check if this is a WebSocket upgrade request
|
||
const isWebSocketUpgrade = req.headers.upgrade === 'websocket';
|
||
|
||
// Only intercept WebSocket upgrades for /ws/script-execution
|
||
// Let Next.js handle all other WebSocket upgrades (like HMR) and all HTTP requests
|
||
if (isWebSocketUpgrade && pathname === '/ws/script-execution') {
|
||
// WebSocket upgrade will be handled by the WebSocket server
|
||
// Don't call handle() for this path - let WebSocketServer handle it
|
||
return;
|
||
}
|
||
|
||
// Let Next.js handle all other requests including:
|
||
// - HTTP requests to /ws/script-execution (non-WebSocket)
|
||
// - WebSocket upgrades to other paths (like /_next/webpack-hmr)
|
||
// - All static assets (_next routes)
|
||
// - All other routes
|
||
await handle(req, res, parsedUrl);
|
||
} catch (err) {
|
||
console.error('Error occurred handling', req.url, err);
|
||
res.statusCode = 500;
|
||
res.end('internal server error');
|
||
}
|
||
});
|
||
|
||
// Create WebSocket handlers
|
||
const scriptHandler = new ScriptExecutionHandler(httpServer);
|
||
|
||
// Handle WebSocket upgrades manually to avoid interfering with Next.js HMR
|
||
// We need to preserve Next.js's upgrade handlers and call them for non-matching paths
|
||
// Save any existing upgrade listeners (Next.js might have set them up)
|
||
const existingUpgradeListeners = httpServer.listeners('upgrade').slice();
|
||
httpServer.removeAllListeners('upgrade');
|
||
|
||
// Add our upgrade handler that routes based on path
|
||
httpServer.on('upgrade', (request, socket, head) => {
|
||
const parsedUrl = parse(request.url || '', true);
|
||
const { pathname } = parsedUrl;
|
||
|
||
if (pathname === '/ws/script-execution') {
|
||
// Handle our custom WebSocket endpoint
|
||
scriptHandler.handleUpgrade(request, socket, head);
|
||
} else {
|
||
// For all other paths (including Next.js HMR), call existing listeners
|
||
// This allows Next.js to handle its own WebSocket upgrades
|
||
for (const listener of existingUpgradeListeners) {
|
||
try {
|
||
listener.call(httpServer, request, socket, head);
|
||
} catch (err) {
|
||
console.error('Error in upgrade listener:', err);
|
||
}
|
||
}
|
||
}
|
||
});
|
||
// Note: TerminalHandler removed as it's not being used by the current application
|
||
|
||
httpServer
|
||
.once('error', (err) => {
|
||
console.error(err);
|
||
process.exit(1);
|
||
})
|
||
.listen(port, hostname, async () => {
|
||
console.log(`> Ready on http://${hostname}:${port}`);
|
||
console.log(`> WebSocket server running on ws://${hostname}:${port}/ws/script-execution`);
|
||
|
||
// Initialize auto sync module and run initialization
|
||
if (!autoSyncModule) {
|
||
try {
|
||
console.log('Dynamically importing autoSyncInit...');
|
||
autoSyncModule = await import('./src/server/lib/autoSyncInit.js');
|
||
console.log('autoSyncModule loaded, exports:', Object.keys(autoSyncModule));
|
||
} catch (error) {
|
||
const err = error instanceof Error ? error : new Error(String(error));
|
||
console.error('Failed to import autoSyncInit:', err.message);
|
||
console.error('Stack:', err.stack);
|
||
throw error;
|
||
}
|
||
}
|
||
|
||
// Initialize default repositories
|
||
if (typeof autoSyncModule.initializeRepositories === 'function') {
|
||
console.log('Calling initializeRepositories...');
|
||
await autoSyncModule.initializeRepositories();
|
||
} else {
|
||
console.warn('initializeRepositories is not a function, type:', typeof autoSyncModule.initializeRepositories);
|
||
}
|
||
|
||
// Initialize auto-sync service
|
||
if (typeof autoSyncModule.initializeAutoSync === 'function') {
|
||
console.log('Calling initializeAutoSync...');
|
||
autoSyncModule.initializeAutoSync();
|
||
}
|
||
|
||
// Setup graceful shutdown handlers
|
||
if (typeof autoSyncModule.setupGracefulShutdown === 'function') {
|
||
console.log('Setting up graceful shutdown...');
|
||
autoSyncModule.setupGracefulShutdown();
|
||
}
|
||
});
|
||
}).catch((err) => {
|
||
console.error('> Failed to start server:', err.message);
|
||
console.error('> If you see "Could not find a production build", run: npm run build');
|
||
console.error('> Full error:', err);
|
||
process.exit(1);
|
||
});
|