Files
Varaverk/Plugin/unraid/api/scheduler.php
T
Gmer4Lfe 987313e7dc 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.
2026-08-02 10:11:39 -04:00

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']);