Files
ProxmoxVE-Local/server.js
Michel Roegl-Brunner 433d8121e8 Fix TypeScript and ESLint errors (#31)
- Add proper type annotations for WebSocketMessage and ServerInfo types
- Fix type imports to use type-only imports where appropriate
- Replace logical OR operators with nullish coalescing operators
- Fix floating promises by adding void operator
- Add proper type assertions for database results
- Fix useEffect dependencies in Terminal component
- Remove unused variables and fix unescaped entities
- Add JSDoc type annotations for database methods
- Fix singleton instance type annotations
2025-09-30 11:30:43 +02:00

404 lines
11 KiB
JavaScript

import { createServer } from 'http';
import { parse } from 'url';
import next from 'next';
import { WebSocketServer } from 'ws';
import { spawn } from 'child_process';
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';
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 });
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
*/
/**
* @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]
*/
class ScriptExecutionHandler {
/**
* @param {import('http').Server} server
*/
constructor(server) {
this.wss = new WebSocketServer({
server,
path: '/ws/script-execution'
});
this.activeExecutions = new Map();
this.setupWebSocket();
}
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();
console.log('Raw WebSocket message received:', rawMessage);
const message = JSON.parse(rawMessage);
console.log('Parsed WebSocket message:', message);
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));
});
});
}
/**
* @param {ExtendedWebSocket} ws
* @param {WebSocketMessage} message
*/
async handleMessage(ws, message) {
const { action, scriptPath, executionId, input, mode, server } = message;
// Debug logging
console.log('WebSocket message received:', { action, scriptPath, executionId, mode, server: server ? { name: server.name, ip: server.ip } : null });
console.log('Full message object:', JSON.stringify(message, null, 2));
switch (action) {
case 'start':
if (scriptPath && executionId) {
await this.startScriptExecution(ws, scriptPath, executionId, mode, server);
} 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
*/
async startScriptExecution(ws, scriptPath, executionId, mode = 'local', server = null) {
try {
// Debug logging
console.log('startScriptExecution called with:', { mode, server: server ? { name: server.name, ip: server.ip } : null });
console.log('Full server object:', JSON.stringify(server, null, 2));
// 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;
}
// Handle SSH execution
if (mode === 'ssh' && server) {
console.log('Starting SSH execution...');
await this.startSSHScriptExecution(ws, scriptPath, executionId, server);
return;
}
if (mode === 'ssh' && !server) {
console.log('SSH mode requested but no server provided, falling back to local execution');
}
console.log('Starting 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()
});
return;
}
// Start script execution with pty for proper TTY support
const childProcess = ptySpawn('bash', [resolvedPath], {
cwd: scriptsDir,
name: 'xterm-256color',
cols: 80,
rows: 24,
env: {
...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
}
});
// pty handles encoding automatically
// Store the execution
this.activeExecutions.set(executionId, { process: childProcess, ws });
// 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((data) => {
this.sendMessage(ws, {
type: 'output',
data: data.toString(),
timestamp: Date.now()
});
});
// Handle process exit
childProcess.onExit((e) => {
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()
});
}
}
/**
* Start SSH script execution
* @param {ExtendedWebSocket} ws
* @param {string} scriptPath
* @param {string} executionId
* @param {ServerInfo} server
*/
async startSSHScriptExecution(ws, scriptPath, executionId, server) {
console.log('startSSHScriptExecution called with server:', server);
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 */ (data) => {
// Handle data output
this.sendMessage(ws, {
type: 'output',
data: data,
timestamp: Date.now()
});
},
/** @param {string} error */ (error) => {
// Handle errors
this.sendMessage(ws, {
type: 'error',
data: error,
timestamp: Date.now()
});
},
/** @param {number} code */ (code) => {
// 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);
}
));
// Store the execution
this.activeExecutions.set(executionId, { process: execution.process, ws });
} catch (error) {
this.sendMessage(ws, {
type: 'error',
data: `Failed to start SSH execution: ${error instanceof Error ? error.message : String(error)}`,
timestamp: Date.now()
});
}
}
/**
* @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);
}
}
}
}
// TerminalHandler removed - not used by current application
app.prepare().then(() => {
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;
if (pathname === '/ws/script-execution') {
// WebSocket upgrade will be handled by the WebSocket server
return;
}
// Let Next.js handle all other requests including HMR
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);
// 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, () => {
console.log(`> Ready on http://${hostname}:${port}`);
console.log(`> WebSocket server running on ws://${hostname}:${port}/ws/script-execution`);
});
});