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:
Gmer4Lfe
2026-08-02 10:11:39 -04:00
parent 6a959fb5e4
commit 987313e7dc
55 changed files with 3972 additions and 95 deletions
+79 -2
View File
@@ -1,7 +1,74 @@
<?php
// ═══════════════════════════════════════════════════════════════════════════════════════════════
// PURPOSE
// Arr data endpoint. Serves the arrs tab: local Sonarr/Radarr/Lidarr statistics gathered
// live, partner statistics served from the background cache, plus an on-demand refresh of
// one partner's cache.
//
// OPERATIONAL MODEL
// Two modes on one URL. Without parameters it answers from a 300s cache and only builds a
// fresh payload on a miss. With ?action=refresh_remote&host=hostN it shells out to
// Tools/remote_arr_cache_writer.sh for that one host, returns the newly written node, and
// busts the main cache so the next ordinary poll picks the change up.
//
// DESIGN PRINCIPLES
// The refresh branch runs before the cache read, and exits.
// It is a distinct operation, not a cache-control flag, so it never falls through into
// the normal load path and cannot return a stale payload labelled as refreshed.
//
// Cache first for the ordinary case.
// vv_arrs_all() contacts every configured arr instance. At the tab's poll rate that is
// far too expensive to repeat, so the 300s cache is the default path and the live build
// is the exception.
//
// include/arrs.php is required only on a cache miss.
// A cache hit answers without loading the library at all, which is the difference
// between a poll that costs a file read and one that costs an autoload.
//
// OPERATIONAL SAFEGUARDS
// The host parameter is matched against a pattern, never used as a path.
// ^host\d+$ is enforced before the value goes anywhere. It reaches the script only as
// an escapeshellarg'd --host= value and the cache filename it composes, so neither a
// shell metacharacter nor a traversal sequence can survive the check.
//
// The refresh is externally time-boxed.
// set_time_limit() does not count time spent inside exec() on Linux, so PHP's own limit
// cannot end a hung SSH call — the child is wrapped in `timeout` instead. Exit 124 is
// reported as a timeout rather than a generic failure, so the UI can distinguish an
// unreachable partner from a broken script.
//
// A missing script is reported, not executed.
// The file_exists() check runs before exec(), so a partial deploy returns a named error
// rather than a shell "command not found" surfacing as an empty refresh.
//
// The cache is busted after the write, not before.
// @unlink() of arrs.json follows the script run, so a failed refresh leaves the previous
// good payload in place instead of forcing every subsequent poll onto the live path.
//
// Read-only with respect to the arrs themselves. Statistics come from the databases the
// scripts write; nothing here triggers a scan, cleanup, or import.
//
// REQUEST
// GET cached (300s) full payload
// GET ?action=refresh_remote&host=host<n> re-run the partner cache writer for one host
//
// RESPONSE
// normal vv_arrs_all() verbatim — local node live, remote nodes cached with cache_age
// refresh {"ok":bool,"node":object|null,"output":string}
// errors {"ok":false,"error":string} — invalid host, missing script, or timeout
//
// DEPENDS ON
// include/config.php vv_cache_read(), VV_CACHE_DIR
// include/arrs.php vv_arrs_all() (loaded only on a cache miss)
// Tools/remote_arr_cache_writer.sh (refresh branch only)
// ═══════════════════════════════════════════════════════════════════════════════════════════════
header('Content-Type: application/json');
require_once dirname(__DIR__) . '/include/config.php';
// Bound on the partner refresh child process. set_time_limit() does not cover exec() time
// on Linux, so this is enforced by `timeout`, not by PHP.
define('VV_ARRS_REFRESH_TIMEOUT', 120);
// ── Manual remote refresh — runs remote_arr_cache_writer for one host ─────────
$_action = trim($_GET['action'] ?? '');
if ($_action === 'refresh_remote') {
@@ -13,9 +80,19 @@ if ($_action === 'refresh_remote') {
if (!file_exists($script)) {
echo json_encode(['ok' => false, 'error' => 'remote_arr_cache_writer.sh not found']); exit;
}
set_time_limit(30);
set_time_limit(VV_ARRS_REFRESH_TIMEOUT + 30);
$out = []; $exit = 0;
exec('bash ' . escapeshellarg($script) . ' --host=' . escapeshellarg(strtoupper($host)) . ' 2>&1', $out, $exit);
exec('timeout ' . VV_ARRS_REFRESH_TIMEOUT . ' bash ' . escapeshellarg($script)
. ' --host=' . escapeshellarg(strtoupper($host)) . ' 2>&1', $out, $exit);
if ($exit === 124) {
echo json_encode([
'ok' => false,
'error' => 'Refresh timed out after ' . VV_ARRS_REFRESH_TIMEOUT . 's',
'output' => implode("\n", $out),
]);
exit;
}
$cacheFile = VV_CACHE_DIR . '/arrs_remote_' . $host . '.json';
$node = null;