UI-launched jobs inherited php-fpm's process group, so stopping one that was actually running group-killed the WebGUI; setsid makes the job its own group leader and stop.php now refuses to signal any group it does not lead.
131 lines
6.8 KiB
PHP
131 lines
6.8 KiB
PHP
<?php
|
|
// ═══════════════════════════════════════════════════════════════════════════════════════════════
|
|
// PURPOSE
|
|
// Manual job launcher. Starts one script through run_job.sh, marked --manual, from the
|
|
// scheduler page's run button.
|
|
//
|
|
// OPERATIONAL MODEL
|
|
// Fire and forget. The child is nohup'd, detached from the request, and its output appended
|
|
// to the job's own log. ok:true means "launched", not "succeeded" — the outcome arrives in
|
|
// the stat file, and the page follows it through status.php and log.php.
|
|
//
|
|
// A manual run is the same run the cron would do. Same runner, same flags, same log, same
|
|
// lock — only --manual differs, so that the stat file records who started it.
|
|
//
|
|
// DESIGN PRINCIPLES
|
|
// Never executes the target script directly.
|
|
// run_job.sh is the single execution path, so locking, logging, the stat file and exit
|
|
// handling are identical whether a job was started by cron or by a person. A second
|
|
// path would be a second set of bugs, and they would only show up under manual runs.
|
|
//
|
|
// Refuses a concurrent run, rather than relying on the script's own lock.
|
|
// The scripts do lock and would exit on their own, but they would do it silently in a
|
|
// log nobody has open yet. Checking here turns that into an immediate, visible
|
|
// "Already running" with an already_running flag the UI can act on.
|
|
//
|
|
// OPERATIONAL SAFEGUARDS
|
|
// The job id is validated and then confirmed to exist.
|
|
// ^[a-zA-Z0-9_./\-]+\.sh$ plus an explicit '..' check — the slash must be permitted for
|
|
// Category/name.sh ids, so traversal is caught by its own test rather than by the
|
|
// character class. file_exists() then confirms the resolved path is a real script.
|
|
//
|
|
// The running check is cross-checked against the process table.
|
|
// A stat file saying 'running' is only believed while /proc/<pid> exists. Without that,
|
|
// a job whose runner was OOM-killed — the exact case where someone is trying to start
|
|
// it again — would be permanently unstartable from the UI.
|
|
//
|
|
// The location argument must be absolute and clean.
|
|
// Leading slash required, '..' rejected, control characters rejected — then passed as a
|
|
// single escapeshellarg'd --location= token.
|
|
//
|
|
// Extra arguments cannot become shell syntax.
|
|
// Control characters are rejected, then the string is split on whitespace and each
|
|
// token escaped individually. The previous blocklist of metacharacters missed newlines,
|
|
// which would have terminated the command line and started a second one — a blocklist
|
|
// has to be right about every character, whereas escaping each token is right about all
|
|
// of them.
|
|
//
|
|
// Every interpolated value is escaped, including the ones that are already validated.
|
|
// Runner path, id, script path and log path all go through escapeshellarg(). Validation
|
|
// and escaping guard different things, and the escaping is what stays correct if the
|
|
// validation is ever loosened.
|
|
//
|
|
// Output is appended, never truncated.
|
|
// >> to the job's own log with stdin from /dev/null, so a manual run cannot consume the
|
|
// request's stdin or discard the history of previous runs.
|
|
//
|
|
// REQUEST
|
|
// POST id=<Category/name.sh> [location=/absolute/path] [extra_args=…]
|
|
//
|
|
// RESPONSE
|
|
// {"ok":true} launched — not completed
|
|
// {"ok":false,"already_running":true,"error":"Already running"}
|
|
// {"ok":false,"error":"Invalid id"|"Script not found: …"|"Invalid location"
|
|
// |"Invalid extra_args"}
|
|
//
|
|
// DEPENDS ON
|
|
// include/scheduler.php vv_job_log_path(), vv_job_stat_path(), vv_job_flags()
|
|
// run_job.sh the single execution path for every job
|
|
// ═══════════════════════════════════════════════════════════════════════════════════════════════
|
|
header('Content-Type: application/json');
|
|
require_once dirname(__DIR__) . '/include/scheduler.php';
|
|
|
|
$id = trim($_POST['id'] ?? '');
|
|
|
|
if (!$id || !preg_match('/^[a-zA-Z0-9_.\/\-]+\.sh$/', $id) || str_contains($id, '..')) {
|
|
echo json_encode(['ok' => false, 'error' => 'Invalid id']);
|
|
exit;
|
|
}
|
|
|
|
$script = SCRIPTS_DIR . '/' . $id;
|
|
if (!file_exists($script)) {
|
|
echo json_encode(['ok' => false, 'error' => 'Script not found: ' . $id]);
|
|
exit;
|
|
}
|
|
|
|
$logFile = vv_job_log_path($id);
|
|
$logDir = dirname($logFile);
|
|
if (!is_dir($logDir)) mkdir($logDir, 0755, true);
|
|
|
|
$location = trim($_POST['location'] ?? '');
|
|
if ($location && (!str_starts_with($location, '/') || str_contains($location, '..') || preg_match('/[\x00\n\r]/', $location))) {
|
|
echo json_encode(['ok' => false, 'error' => 'Invalid location']);
|
|
exit;
|
|
}
|
|
|
|
// Control characters are rejected outright — a newline would end the command line and start
|
|
// a second one. Everything that survives is split on whitespace and escaped per token below,
|
|
// so the shell never parses any of it as syntax.
|
|
$extra_args = trim($_POST['extra_args'] ?? '');
|
|
if ($extra_args !== '' && preg_match('/[\x00-\x1f\x7f]/', $extra_args)) {
|
|
echo json_encode(['ok' => false, 'error' => 'Invalid extra_args']);
|
|
exit;
|
|
}
|
|
|
|
// Refuse if already running — scripts will lock-exit anyway, but surface it clearly.
|
|
$statFile = vv_job_stat_path($id);
|
|
if (file_exists($statFile)) {
|
|
$stat = json_decode(file_get_contents($statFile), true) ?: [];
|
|
$pid = $stat['pid'] ?? null;
|
|
$status = $stat['status'] ?? '';
|
|
if ($status === 'running' && $pid && file_exists("/proc/{$pid}")) {
|
|
echo json_encode(['ok' => false, 'already_running' => true, 'error' => 'Already running']);
|
|
exit;
|
|
}
|
|
}
|
|
|
|
$runner = dirname(__DIR__) . '/run_job.sh';
|
|
$flags = vv_job_flags($id);
|
|
$locArg = $location ? ' ' . escapeshellarg('--location=' . $location) : '';
|
|
$extraStr = '';
|
|
foreach (preg_split('/\s+/', $extra_args, -1, PREG_SPLIT_NO_EMPTY) as $tok) {
|
|
$extraStr .= ' ' . escapeshellarg($tok);
|
|
}
|
|
// setsid, not just nohup: the job must lead its own process group so api/stop.php can signal the
|
|
// whole tree without touching anything else. nohup only ignores SIGHUP, and `&` under the
|
|
// non-interactive `sh -c` that exec() uses has job control off, so without this the job inherits
|
|
// the php-fpm worker's process group — and stopping it group-killed the web server.
|
|
exec('setsid nohup bash ' . escapeshellarg($runner) . ' ' . escapeshellarg($id) . ' ' . escapeshellarg($script) . ($flags ? " $flags" : '') . ' --manual' . $locArg . $extraStr . ' >> ' . escapeshellarg($logFile) . ' 2>&1 </dev/null &');
|
|
|
|
echo json_encode(['ok' => true]);
|