Document the PHP api layer and fix what documenting it exposed
Writing down what each endpoint actually guarantees made the places it didn't obvious — shell arguments reaching a crontab or a bash -c unescaped, master.conf written without tmp+rename, and conf edits that could be saved without ever being parsed.
This commit is contained in:
@@ -1,4 +1,69 @@
|
||||
<?php
|
||||
// ═══════════════════════════════════════════════════════════════════════════════════════════════
|
||||
// PURPOSE
|
||||
// Dry-run launcher. Starts one script through run_job.sh with --dry-run, so the scheduler
|
||||
// page can show what a job would do without letting it do any of it.
|
||||
//
|
||||
// OPERATIONAL MODEL
|
||||
// Identical to run.php but for two differences: --dry-run is added, and there is no
|
||||
// already-running guard. The missing guard is deliberate — a dry run changes nothing, so
|
||||
// there is no reason to refuse one while a real run is in progress, and the two write to
|
||||
// the same log where their output is distinguishable by the dry-run banner.
|
||||
//
|
||||
// Fire and forget. The child is nohup'd and detached, and the response returns immediately
|
||||
// with ok:true meaning "launched", not "finished". Progress is followed through log.php.
|
||||
//
|
||||
// DESIGN PRINCIPLES
|
||||
// Never executes the target script directly.
|
||||
// Everything goes through run_job.sh, so a dry run gets the same locking, logging, stat
|
||||
// file and exit handling as a scheduled run. A second execution path would be a second
|
||||
// set of bugs.
|
||||
//
|
||||
// --dry-run is added here, honoured there.
|
||||
// This endpoint guarantees the flag is passed; whether a given script actually respects
|
||||
// it is that script's contract. The scripts that accept the flag are the ones the UI
|
||||
// offers the button for.
|
||||
//
|
||||
// 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, so a
|
||||
// well-formed id for a file that is not there fails before anything is spawned.
|
||||
//
|
||||
// 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() even though
|
||||
// none of them can currently carry a metacharacter. 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 dry 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,"error":"Invalid id"|"Script not found: …"|"Invalid location"
|
||||
// |"Invalid extra_args"}
|
||||
//
|
||||
// DEPENDS ON
|
||||
// include/scheduler.php vv_job_log_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';
|
||||
|
||||
@@ -25,8 +90,11 @@ if ($location && (!str_starts_with($location, '/') || str_contains($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, so
|
||||
// the shell never parses any of it as syntax.
|
||||
$extra_args = trim($_POST['extra_args'] ?? '');
|
||||
if ($extra_args && preg_match('/[;&|`$<>\\\\"\']/', $extra_args)) {
|
||||
if ($extra_args !== '' && preg_match('/[\x00-\x1f\x7f]/', $extra_args)) {
|
||||
echo json_encode(['ok' => false, 'error' => 'Invalid extra_args']);
|
||||
exit;
|
||||
}
|
||||
@@ -34,7 +102,10 @@ if ($extra_args && preg_match('/[;&|`$<>\\\\"\']/', $extra_args)) {
|
||||
$runner = dirname(__DIR__) . '/run_job.sh';
|
||||
$flags = vv_job_flags($id);
|
||||
$locArg = $location ? ' ' . escapeshellarg('--location=' . $location) : '';
|
||||
$extraStr = $extra_args ? ' ' . $extra_args : '';
|
||||
$extraStr = '';
|
||||
foreach (preg_split('/\s+/', $extra_args, -1, PREG_SPLIT_NO_EMPTY) as $tok) {
|
||||
$extraStr .= ' ' . escapeshellarg($tok);
|
||||
}
|
||||
exec('nohup bash ' . escapeshellarg($runner) . ' ' . escapeshellarg($id) . ' ' . escapeshellarg($script) . ' --dry-run' . ($flags ? " $flags" : '') . ' --manual' . $locArg . $extraStr . ' >> ' . escapeshellarg($logFile) . ' 2>&1 </dev/null &');
|
||||
|
||||
echo json_encode(['ok' => true]);
|
||||
|
||||
Reference in New Issue
Block a user