168 lines
5.3 KiB
TypeScript
168 lines
5.3 KiB
TypeScript
import * as path from 'node:path';
|
|
import * as fs from 'node:fs';
|
|
import process from 'node:process';
|
|
import { exec } from 'node:child_process';
|
|
import { promisify } from 'node:util';
|
|
import { Action, type IActionConfig, type IActionContext } from './base-action.ts';
|
|
import { logger } from '../logger.ts';
|
|
|
|
const execAsync = promisify(exec);
|
|
|
|
/**
|
|
* ScriptAction - Executes a custom shell script from /etc/nupst/
|
|
*
|
|
* Runs user-provided scripts with UPS state passed as environment variables and arguments.
|
|
* Scripts must be .sh files located in /etc/nupst/ for security.
|
|
*/
|
|
export class ScriptAction extends Action {
|
|
readonly type = 'script';
|
|
|
|
private static readonly SCRIPT_DIR = '/etc/nupst';
|
|
|
|
/**
|
|
* Execute the script action
|
|
* @param context Action context with UPS state
|
|
*/
|
|
async execute(context: IActionContext): Promise<void> {
|
|
// Check if we should execute based on trigger mode
|
|
if (!this.shouldExecute(context)) {
|
|
logger.info(`Script action skipped (trigger mode: ${this.config.triggerMode || 'powerChangesAndThresholds'})`);
|
|
return;
|
|
}
|
|
|
|
if (!this.config.scriptPath) {
|
|
logger.error('Script path not configured');
|
|
return;
|
|
}
|
|
|
|
// Validate and build script path
|
|
const scriptPath = this.validateAndBuildScriptPath(this.config.scriptPath);
|
|
if (!scriptPath) {
|
|
logger.error(`Invalid script path: ${this.config.scriptPath}`);
|
|
return;
|
|
}
|
|
|
|
// Check if script exists and is executable
|
|
if (!fs.existsSync(scriptPath)) {
|
|
logger.error(`Script not found: ${scriptPath}`);
|
|
return;
|
|
}
|
|
|
|
const timeout = this.config.scriptTimeout || 60000; // Default 60 seconds
|
|
|
|
logger.info(`Executing script: ${scriptPath}`);
|
|
|
|
try {
|
|
await this.executeScript(scriptPath, context, timeout);
|
|
logger.success('Script executed successfully');
|
|
} catch (error) {
|
|
logger.error(
|
|
`Script execution failed: ${error instanceof Error ? error.message : String(error)}`,
|
|
);
|
|
// Don't throw - script failures shouldn't stop other actions
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Validate script path and build full path
|
|
* Ensures security by preventing path traversal and limiting to /etc/nupst
|
|
* @param scriptPath Relative script path from config
|
|
* @returns Full validated path or null if invalid
|
|
*/
|
|
private validateAndBuildScriptPath(scriptPath: string): string | null {
|
|
// Remove any leading/trailing whitespace
|
|
scriptPath = scriptPath.trim();
|
|
|
|
// Reject paths with path traversal attempts
|
|
if (scriptPath.includes('..') || scriptPath.includes('/') || scriptPath.includes('\\')) {
|
|
logger.error('Script path must not contain directory separators or parent references');
|
|
return null;
|
|
}
|
|
|
|
// Require .sh extension
|
|
if (!scriptPath.endsWith('.sh')) {
|
|
logger.error('Script must have .sh extension');
|
|
return null;
|
|
}
|
|
|
|
// Build full path
|
|
return path.join(ScriptAction.SCRIPT_DIR, scriptPath);
|
|
}
|
|
|
|
/**
|
|
* Execute the script with UPS state as environment variables and arguments
|
|
* @param scriptPath Full path to the script
|
|
* @param context Action context
|
|
* @param timeout Execution timeout in milliseconds
|
|
*/
|
|
private async executeScript(
|
|
scriptPath: string,
|
|
context: IActionContext,
|
|
timeout: number,
|
|
): Promise<void> {
|
|
// Prepare environment variables
|
|
const env = {
|
|
...process.env,
|
|
NUPST_UPS_ID: context.upsId,
|
|
NUPST_UPS_NAME: context.upsName,
|
|
NUPST_POWER_STATUS: context.powerStatus,
|
|
NUPST_BATTERY_CAPACITY: String(context.batteryCapacity),
|
|
NUPST_BATTERY_RUNTIME: String(context.batteryRuntime),
|
|
NUPST_TRIGGER_REASON: context.triggerReason,
|
|
NUPST_TIMESTAMP: String(context.timestamp),
|
|
// Include action's own thresholds if configured
|
|
NUPST_BATTERY_THRESHOLD: this.config.thresholds ? String(this.config.thresholds.battery) : '',
|
|
NUPST_RUNTIME_THRESHOLD: this.config.thresholds ? String(this.config.thresholds.runtime) : '',
|
|
};
|
|
|
|
// Build command with arguments
|
|
// Arguments: powerStatus batteryCapacity batteryRuntime
|
|
const args = [
|
|
context.powerStatus,
|
|
String(context.batteryCapacity),
|
|
String(context.batteryRuntime),
|
|
].join(' ');
|
|
|
|
const command = `bash "${scriptPath}" ${args}`;
|
|
|
|
try {
|
|
const { stdout, stderr } = await execAsync(command, {
|
|
env,
|
|
cwd: ScriptAction.SCRIPT_DIR,
|
|
timeout,
|
|
});
|
|
|
|
// Log output
|
|
if (stdout) {
|
|
logger.log('Script stdout:');
|
|
logger.dim(stdout.trim());
|
|
}
|
|
|
|
if (stderr) {
|
|
logger.warn('Script stderr:');
|
|
logger.dim(stderr.trim());
|
|
}
|
|
} catch (error) {
|
|
// Check if it was a timeout
|
|
if (error instanceof Error && 'killed' in error && error.killed) {
|
|
throw new Error(`Script timed out after ${timeout}ms`);
|
|
}
|
|
|
|
// Include stdout/stderr in error if available
|
|
if (error && typeof error === 'object' && 'stdout' in error && 'stderr' in error) {
|
|
const execError = error as { stdout: string; stderr: string };
|
|
if (execError.stdout) {
|
|
logger.log('Script stdout:');
|
|
logger.dim(execError.stdout.trim());
|
|
}
|
|
if (execError.stderr) {
|
|
logger.warn('Script stderr:');
|
|
logger.dim(execError.stderr.trim());
|
|
}
|
|
}
|
|
|
|
throw error;
|
|
}
|
|
}
|
|
}
|