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.
136 lines
6.8 KiB
PHP
136 lines
6.8 KiB
PHP
<?php
|
|
// ═══════════════════════════════════════════════════════════════════════════════════════════════
|
|
// PURPOSE
|
|
// Schedule writer. Saves one job's enabled state, cron expression and log flag — or a whole
|
|
// set of them in one call — and rebuilds varaverk.cron from the result.
|
|
//
|
|
// OPERATIONAL MODEL
|
|
// schedule.json is the source of truth; varaverk.cron is generated from it. Nothing edits
|
|
// the crontab directly, which is why the generated file carries a "managed by plugin, do
|
|
// not edit manually" banner. Every save here ends in a vv_cron_rebuild() inside the library,
|
|
// so the two can never drift.
|
|
//
|
|
// Two paths, same effect. The single-entry path is what the UI's toggles use; the batch
|
|
// path exists so a bulk edit is one load/write/rebuild cycle rather than N of them. The
|
|
// batch path is currently unreferenced by the UI.
|
|
//
|
|
// The two paths differ in how they treat a bad cron, deliberately. A single save rejects it
|
|
// and tells the user; a batch save blanks that one field and continues, because failing an
|
|
// entire bulk edit over one malformed row would lose every other change in it.
|
|
//
|
|
// DESIGN PRINCIPLES
|
|
// Event triggers are stored here but never reach cron.
|
|
// array_start and array_stop are accepted as cron values and recorded in schedule.json,
|
|
// but vv_cron_rebuild() skips them — they fire from the array event hook instead. One
|
|
// schedule holds both kinds of trigger so the page has a single list to render.
|
|
//
|
|
// Enablement is decided here, precedence in the library.
|
|
// A child script whose orchestrator is enabled gets no independent cron line, and that
|
|
// suppression lives in vv_cron_rebuild(). This endpoint records intent; the library
|
|
// resolves what that intent means against the rest of the schedule.
|
|
//
|
|
// OPERATIONAL SAFEGUARDS
|
|
// The job id and the cron expression are both crontab injection surfaces, and are validated
|
|
// as such.
|
|
// vv_cron_rebuild() interpolates both into a generated crontab line inside double
|
|
// quotes. The id must match ^[A-Za-z0-9_./\-]+\.sh$ with no '..', and the cron
|
|
// expression must consist only of [0-9A-Za-z*,\-/ ] — neither can carry a quote, a
|
|
// shell metacharacter, or a newline. Previously the id was checked only for emptiness,
|
|
// and the field-count pattern used \s, which matches newline: a value of "* * * *\nX"
|
|
// satisfied it and would have appended a second, attacker-chosen line to root's crontab.
|
|
// The character-class check is what carries that guarantee now; the field count is a
|
|
// usability check on top of it.
|
|
//
|
|
// Both paths share one validator.
|
|
// vv_sched_id_valid() and vv_sched_cron_valid() are called from the single and batch
|
|
// paths alike, so the bulk path cannot become the loose one — which is exactly how the
|
|
// id check came to be missing from it before.
|
|
//
|
|
// POST only, checked before any parameter is read.
|
|
//
|
|
// Anything other than the literal "1" is false.
|
|
// Both flags compare strictly against '1', so a missing or malformed parameter disables
|
|
// rather than enables. The single-entry path previously used a (bool) cast, under which
|
|
// the string "false" evaluates to true.
|
|
//
|
|
// Malformed batch JSON degrades to an empty set.
|
|
// json_decode with a ?: [] fallback, so a truncated payload writes nothing rather than
|
|
// rebuilding the crontab from garbage.
|
|
//
|
|
// REQUEST
|
|
// POST id=<Category/name.sh> enabled=0|1 cron=<5 fields|array_start|array_stop|empty>
|
|
// log_enabled=0|1
|
|
// POST batch=<JSON array of {id, enabled, cron, log_enabled}>
|
|
//
|
|
// RESPONSE
|
|
// {"ok":true,"error":null}
|
|
// {"ok":false,"error":"POST only"|"Invalid id"|"Invalid cron expression"
|
|
// |"Failed to write schedule"}
|
|
//
|
|
// DEPENDS ON
|
|
// include/scheduler.php vv_schedule_update(), vv_schedule_update_batch(),
|
|
// vv_cron_rebuild()
|
|
// varaverk.cron generated output — never edited directly
|
|
// ═══════════════════════════════════════════════════════════════════════════════════════════════
|
|
header('Content-Type: application/json');
|
|
require_once dirname(__DIR__) . '/include/scheduler.php';
|
|
|
|
if ($_SERVER['REQUEST_METHOD'] !== 'POST') {
|
|
echo json_encode(['ok' => false, 'error' => 'POST only']);
|
|
exit;
|
|
}
|
|
|
|
// Both values below are interpolated into the generated crontab by vv_cron_rebuild(), so
|
|
// neither may contain a quote, a shell metacharacter, or a newline.
|
|
function vv_sched_id_valid(string $id): bool {
|
|
return $id !== '' && !str_contains($id, '..') && (bool)preg_match('/^[A-Za-z0-9_.\-\/]+\.sh$/', $id);
|
|
}
|
|
|
|
// Five whitespace-separated fields of cron-safe characters only. \s matches newline, so the
|
|
// field-count pattern alone would accept a value carrying a second crontab line.
|
|
function vv_sched_cron_valid(string $cron): bool {
|
|
if (in_array($cron, ['array_start', 'array_stop'], true)) return true;
|
|
if (!preg_match('/^[0-9A-Za-z*,\-\/ ]+$/', $cron)) return false;
|
|
return (bool)preg_match('/^(\S+ +){4}\S+$/', $cron);
|
|
}
|
|
|
|
// Batch save — all entries in one load/write/rebuild cycle
|
|
if (!empty($_POST['batch'])) {
|
|
$entries = json_decode($_POST['batch'], true) ?: [];
|
|
$clean = [];
|
|
foreach ($entries as $e) {
|
|
$id = trim($e['id'] ?? '');
|
|
$cron = trim($e['cron'] ?? '');
|
|
if (!vv_sched_id_valid($id)) continue;
|
|
if ($cron && !vv_sched_cron_valid($cron)) $cron = '';
|
|
$clean[] = [
|
|
'id' => $id,
|
|
'enabled' => ($e['enabled'] ?? '0') === '1',
|
|
'cron' => $cron,
|
|
'log_enabled' => ($e['log_enabled'] ?? '0') === '1',
|
|
];
|
|
}
|
|
$ok = vv_schedule_update_batch($clean);
|
|
echo json_encode(['ok' => $ok, 'error' => $ok ? null : 'Failed to write schedule']);
|
|
exit;
|
|
}
|
|
|
|
$id = trim($_POST['id'] ?? '');
|
|
$enabled = ($_POST['enabled'] ?? '0') === '1';
|
|
$cron = trim($_POST['cron'] ?? '');
|
|
$log_enabled = ($_POST['log_enabled'] ?? '0') === '1';
|
|
|
|
if (!vv_sched_id_valid($id)) {
|
|
echo json_encode(['ok' => false, 'error' => 'Invalid id']);
|
|
exit;
|
|
}
|
|
|
|
// 5 cron fields, or a known event trigger, or empty
|
|
if ($cron && !vv_sched_cron_valid($cron)) {
|
|
echo json_encode(['ok' => false, 'error' => 'Invalid cron expression']);
|
|
exit;
|
|
}
|
|
|
|
$ok = vv_schedule_update($id, $enabled, $cron, $log_enabled);
|
|
echo json_encode(['ok' => $ok, 'error' => $ok ? null : 'Failed to write schedule']);
|