Files
Varaverk/Plugin/unraid/api/rsync_standalone.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

123 lines
6.1 KiB
PHP

<?php
// ═══════════════════════════════════════════════════════════════════════════════════════════════
// PURPOSE
// Standalone rsync scheduling. Records a location and cron expression for one rsync tier so
// that tier can run on its own schedule when its orchestrator is disabled.
//
// OPERATIONAL MODEL
// Rsync normally runs as a step inside an orchestrator — critical, intermediate, daily,
// weekly. This exists for the case where someone wants that tier's sync without the rest of
// the orchestrator's work. vv_cron_rebuild() emits the standalone entry only when the
// orchestrator is disabled and both a location and a cron are configured, so the two can
// never both fire: enabling the orchestrator silently takes precedence.
//
// Stored in schedule.json under __rsync_<FLAG_NAME>, a namespace deliberately outside the
// job id space. Everything that iterates the schedule as jobs skips keys with that prefix.
//
// The cron is rebuilt immediately on save, so the change takes effect without waiting for
// another event to regenerate varaverk.cron.
//
// DESIGN PRINCIPLES
// Keyed by flag, not by orchestrator.
// The tier flag is the stable identity — CRITICAL_RSYNC_ENABLED means the same thing
// regardless of which orchestrator currently carries that tier. orch_id is stored
// alongside it purely so the rebuild can check whether that orchestrator is enabled.
//
// Configuration only. Nothing here starts a sync; it records when one should start.
//
// Empty fields are permitted and mean "not configured".
// location and cron may both be blank, which is how a standalone entry is cleared —
// vv_cron_rebuild() requires both to be present before it emits anything.
//
// OPERATIONAL SAFEGUARDS
// POST only, checked before any parameter is read.
//
// The flag name is constrained to the rsync namespace.
// ^[A-Z_]+_RSYNC_ENABLED$ — this endpoint cannot create a schedule key for anything
// else, so the __rsync_ namespace stays exactly as wide as the tiers it was built for.
//
// The cron expression is validated as a crontab injection surface.
// vv_cron_rebuild() interpolates it directly into a generated crontab line. Only
// [0-9A-Za-z*,\-/ ] is permitted, and then five whitespace-separated fields are
// required. The character class is what carries the guarantee: the field-count pattern
// uses \s, which matches newline, so on its own it would accept a value carrying a
// second, caller-chosen crontab entry. It was previously not validated at all.
//
// The orchestrator id is validated even though it is only ever used as a lookup key.
// ^[A-Za-z0-9_.\-/]+\.sh$ with an explicit '..' check, so a value that could not name a
// real script cannot be stored as though it does.
//
// The location must be absolute and clean.
// Leading slash required, '..' rejected, control characters rejected. It reaches the
// crontab as an escapeshellarg'd --location= token, so validation and escaping are both
// in place.
//
// The rest of the schedule is preserved — the file is loaded, one key replaced, and the
// whole structure written back.
//
// The cron rebuild only runs after a confirmed write, so a failed save cannot regenerate
// the crontab from a schedule that was not persisted.
//
// REQUEST
// POST flag_name=<TIER>_RSYNC_ENABLED orch_id=<Category/name.sh>
// location=/absolute/path cron=<5 fields>
//
// RESPONSE
// {"ok":true}
// {"ok":false,"error":"POST only"|"Invalid flag_name"|"Invalid orch_id"|"Invalid location"
// |"Invalid cron expression"|"Write failed"}
//
// DEPENDS ON
// include/scheduler.php vv_schedule_load(), vv_schedule_save(), vv_cron_rebuild()
// Rsync/rsync.sh the script the generated cron entry invokes
// ═══════════════════════════════════════════════════════════════════════════════════════════════
header('Content-Type: application/json');
require_once dirname(__DIR__) . '/include/config.php';
require_once dirname(__DIR__) . '/include/scheduler.php';
if ($_SERVER['REQUEST_METHOD'] !== 'POST') {
echo json_encode(['ok' => false, 'error' => 'POST only']);
exit;
}
$flagName = trim($_POST['flag_name'] ?? '');
$orchId = trim($_POST['orch_id'] ?? '');
$location = trim($_POST['location'] ?? '');
$cron = trim($_POST['cron'] ?? '');
if (!$flagName || !preg_match('/^[A-Z_]+_RSYNC_ENABLED$/', $flagName)) {
echo json_encode(['ok' => false, 'error' => 'Invalid flag_name']);
exit;
}
if ($orchId && (str_contains($orchId, '..') || !preg_match('/^[A-Za-z0-9_.\-\/]+\.sh$/', $orchId))) {
echo json_encode(['ok' => false, 'error' => 'Invalid orch_id']);
exit;
}
if ($location && (!str_starts_with($location, '/') || str_contains($location, '..') || preg_match('/[\x00\n\r]/', $location))) {
echo json_encode(['ok' => false, 'error' => 'Invalid location']);
exit;
}
// vv_cron_rebuild() interpolates this straight into a crontab line. Restrict to cron-safe
// characters first — a field-count check alone would accept a value carrying a newline and
// therefore a second, caller-chosen crontab entry.
if ($cron && (!preg_match('/^[0-9A-Za-z*,\-\/ ]+$/', $cron) || !preg_match('/^(\S+ +){4}\S+$/', $cron))) {
echo json_encode(['ok' => false, 'error' => 'Invalid cron expression']);
exit;
}
$key = '__rsync_' . $flagName;
$schedule = vv_schedule_load();
$schedule[$key] = [
'flag_name' => $flagName,
'orch_id' => $orchId,
'location' => $location,
'cron' => $cron,
];
if (!vv_schedule_save($schedule)) {
echo json_encode(['ok' => false, 'error' => 'Write failed']);
exit;
}
vv_cron_rebuild($schedule);
echo json_encode(['ok' => true]);