Files
ProxmoxVE-Local/src/server/lib/scripts.ts
2025-10-06 16:21:49 +02:00

292 lines
8.4 KiB
TypeScript

import { readdir, stat, readFile } from 'fs/promises';
import { join, resolve, extname } from 'path';
import { env } from '~/env.js';
import { spawn, type ChildProcess } from 'child_process';
import { localScriptsService } from '~/server/services/localScripts';
export interface ScriptInfo {
name: string;
path: string;
extension: string;
size: number;
lastModified: Date;
executable: boolean;
logo?: string;
slug?: string;
}
export class ScriptManager {
private scriptsDir: string | null = null;
private allowedExtensions: string[] | null = null;
private allowedPaths: string[] | null = null;
private maxExecutionTime: number | null = null;
constructor() {
// Initialize lazily to avoid accessing env vars during module load
}
private initializeConfig() {
if (this.scriptsDir === null) {
// Handle both absolute and relative paths for testing
this.scriptsDir = env.SCRIPTS_DIRECTORY.startsWith('/')
? env.SCRIPTS_DIRECTORY
: join(process.cwd(), env.SCRIPTS_DIRECTORY);
this.allowedExtensions = env.ALLOWED_SCRIPT_EXTENSIONS.split(',').map(ext => ext.trim());
this.allowedPaths = env.ALLOWED_SCRIPT_PATHS.split(',').map(path => path.trim());
this.maxExecutionTime = parseInt(env.MAX_SCRIPT_EXECUTION_TIME, 10);
}
}
/**
* Get all available scripts in the scripts directory
*/
async getScripts(): Promise<ScriptInfo[]> {
this.initializeConfig();
try {
const files = await readdir(this.scriptsDir!);
const scripts: ScriptInfo[] = [];
for (const file of files) {
const filePath = join(this.scriptsDir!, file);
const stats = await stat(filePath);
if (stats.isFile()) {
const extension = extname(file);
// Check if file extension is allowed
if (this.allowedExtensions!.includes(extension)) {
// Check if file is executable
const executable = await this.isExecutable(filePath);
scripts.push({
name: file,
path: filePath,
extension,
size: stats.size,
lastModified: stats.mtime,
executable
});
}
}
}
return scripts.sort((a, b) => a.name.localeCompare(b.name));
} catch (error) {
console.error('Error reading scripts directory:', error);
return [];
}
}
/**
* Get all available scripts in the ct subdirectory
*/
async getCtScripts(): Promise<ScriptInfo[]> {
this.initializeConfig();
try {
const ctDir = join(this.scriptsDir!, 'ct');
const files = await readdir(ctDir);
const scripts: ScriptInfo[] = [];
for (const file of files) {
const filePath = join(ctDir, file);
const stats = await stat(filePath);
if (stats.isFile()) {
const extension = extname(file);
// Check if file extension is allowed
if (this.allowedExtensions!.includes(extension)) {
// Check if file is executable
const executable = await this.isExecutable(filePath);
// Extract slug from filename (remove .sh extension)
const slug = file.replace(/\.sh$/, '');
// Try to get logo from JSON data
let logo: string | undefined;
try {
const scriptData = await localScriptsService.getScriptBySlug(slug);
logo = scriptData?.logo ?? undefined;
} catch {
// JSON file might not exist, that's okay
}
scripts.push({
name: file,
path: filePath,
extension,
size: stats.size,
lastModified: stats.mtime,
executable,
logo,
slug
});
}
}
}
return scripts.sort((a, b) => a.name.localeCompare(b.name));
} catch (error) {
console.error('Error reading ct scripts directory:', error);
return [];
}
}
/**
* Check if a file is executable
*/
private async isExecutable(filePath: string): Promise<boolean> {
try {
const stats = await stat(filePath);
// Check if file has execute permission for owner, group, or others
const mode = stats.mode;
const isExecutable = !!(mode & parseInt('111', 8));
return isExecutable;
} catch {
return false;
}
}
/**
* Validate if a script path is allowed to be executed
*/
validateScriptPath(scriptPath: string): { valid: boolean; message?: string } {
this.initializeConfig();
const resolvedPath = resolve(scriptPath);
const scriptsDirResolved = resolve(this.scriptsDir!);
// Check if the script is within the allowed directory
if (!resolvedPath.startsWith(scriptsDirResolved)) {
return {
valid: false,
message: 'Script path is not within the allowed scripts directory'
};
}
// Check if the script path matches any allowed path pattern
const relativePath = resolvedPath.replace(scriptsDirResolved, '').replace(/\\/g, '/');
const normalizedRelativePath = relativePath.startsWith('/') ? relativePath : '/' + relativePath;
const isAllowed = this.allowedPaths!.some(allowedPath => {
const normalizedAllowedPath = allowedPath.startsWith('/') ? allowedPath : '/' + allowedPath;
// For root path '/', allow files directly in the scripts directory (no subdirectories)
if (normalizedAllowedPath === '/') {
return normalizedRelativePath === '/' || (normalizedRelativePath.startsWith('/') && !normalizedRelativePath.substring(1).includes('/'));
}
// For other paths like '/ct/', check if the path starts with it
return normalizedRelativePath.startsWith(normalizedAllowedPath);
});
if (!isAllowed) {
return {
valid: false,
message: 'Script path is not in the allowed paths list'
};
}
// Check file extension
const extension = extname(scriptPath);
if (!this.allowedExtensions!.includes(extension)) {
return {
valid: false,
message: `File extension '${extension}' is not allowed. Allowed extensions: ${this.allowedExtensions!.join(', ')}`
};
}
return { valid: true };
}
/**
* Execute a script and return a child process
*/
async executeScript(scriptPath: string): Promise<ChildProcess> {
const validation = this.validateScriptPath(scriptPath);
if (!validation.valid) {
throw new Error(validation.message);
}
// Determine the command to run based on file extension
const extension = extname(scriptPath);
let command: string;
let args: string[] = [];
switch (extension) {
case '.sh':
case '.bash':
command = 'bash';
args = [scriptPath];
break;
case '.py':
command = 'python';
args = [scriptPath];
break;
case '.js':
command = 'node';
args = [scriptPath];
break;
case '.ts':
command = 'npx';
args = ['ts-node', scriptPath];
break;
default:
// Try to execute directly (for files with shebang)
command = scriptPath;
args = [];
}
// Spawn the process
const childProcess = spawn(command, args, {
cwd: this.scriptsDir!,
stdio: ['pipe', 'pipe', 'pipe'],
shell: true
});
// Set up timeout
const timeout = setTimeout(() => {
if (!childProcess.killed) {
childProcess.kill('SIGTERM');
}
}, this.maxExecutionTime!);
// Clean up timeout when process exits
childProcess.on('exit', () => {
clearTimeout(timeout);
});
return childProcess;
}
/**
* Get script content for display
*/
async getScriptContent(scriptPath: string): Promise<string> {
const validation = this.validateScriptPath(scriptPath);
if (!validation.valid) {
throw new Error(validation.message);
}
return await readFile(scriptPath, 'utf-8');
}
/**
* Get scripts directory information
*/
getScriptsDirectoryInfo(): {
path: string;
allowedExtensions: string[];
allowedPaths: string[];
maxExecutionTime: number;
} {
this.initializeConfig();
return {
path: this.scriptsDir!,
allowedExtensions: this.allowedExtensions!,
allowedPaths: this.allowedPaths!,
maxExecutionTime: this.maxExecutionTime!
};
}
}
// Export singleton instance
export const scriptManager = new ScriptManager();