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= [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; } // Custom scripts live outside the repo (CUSTOM_SCRIPTS_DIR) — everything else resolves under SCRIPTS_DIR. $script = str_starts_with($id, 'Custom/') ? CUSTOM_SCRIPTS_DIR . '/' . substr($id, strlen('Custom/')) : 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 true]);