feat(actions): implement action system for UPS state management with shutdown, webhook, and script actions
This commit is contained in:
166
ts/actions/script-action.ts
Normal file
166
ts/actions/script-action.ts
Normal file
@@ -0,0 +1,166 @@
|
||||
import * as path from 'node:path';
|
||||
import * as fs from 'node:fs';
|
||||
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;
|
||||
}
|
||||
}
|
||||
}
|
Reference in New Issue
Block a user