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
+97 -1
View File
@@ -1,5 +1,101 @@
<?php
// Live board data: locks, recent errors, partner reachability.
// ═══════════════════════════════════════════════════════════════════════════════════════════════
// PURPOSE
// Attention board. The three things worth knowing without opening a tab: locks nobody is
// holding, scripts whose last run ended badly, and whether the partner is reachable.
//
// OPERATIONAL MODEL
// A dashboard of exceptions, not of state. Everything here is designed to be empty on a
// healthy host — an empty board is the expected reading, which is what makes a non-empty
// one worth looking at.
//
// Each section answers a question no single other endpoint does. Locks come from the
// filesystem, errors from correlating logs against their stat files, reachability from an
// actual ping. They are collected together because they are read together.
//
// DESIGN PRINCIPLES
// Only stale locks are reported.
// A lock whose owning pid is still in /proc is a job legitimately running and is
// skipped. What remains is the set a person might need to clear — which is exactly what
// clearlock.php exists for.
//
// Errors are gated on the stat file, not on the log text.
// A script is only reported when its last recorded run exited warn or error. Scanning
// logs for the word "error" produced two persistent false positives: dry runs, which
// print failures they did not cause, and success summaries containing lines like
// "Failed: 0". The stat file is the run's own verdict, and it is authoritative.
//
// A script with no stat file is skipped entirely.
// No stat file means it never ran through run_job.sh, so there is no verdict to trust
// and no basis for reporting it.
//
// The error line is the last matching one, searched backwards.
// Logs are appended, so the most recent failure is at the end. The search walks up from
// there and stops at the first hit rather than reading forward and reporting the oldest.
//
// There is a fallback when nothing matches.
// A run that failed without printing a recognisable error still reports its last
// non-blank line. A script marked error with no explanation is worse than an imperfect
// one.
//
// The partner is discovered, not configured.
// master.conf is scanned for HOST<n> entries and the first non-self one is used, so this
// works for any number of hosts without a second list to maintain.
//
// Tailscale resolves the address, never local DNS.
// vv_resolve_tailscale_ip() mirrors common.sh, so the board tests the same path the
// rest of the system uses and survives the partner's IP changing.
//
// OPERATIONAL SAFEGUARDS
// Read-only. Nothing here clears a lock, truncates a log, or restarts anything — the board
// reports; clearlock.php and stop.php act.
//
// The lock scan is bounded to a hardcoded directory.
// glob over /tmp/unraid_locks/*.lock — a literal, not a config value, so no conf edit
// can point this scan somewhere else.
//
// The log walk is wrapped in a try/catch.
// RecursiveDirectoryIterator throws when LOG_DIR is absent or a subdirectory is
// unreadable — the normal state on a fresh install. The catch yields an empty error
// list rather than a 500.
//
// Every file read is independently suppressed and defaulted.
// @file_get_contents, @file, and ?: fallbacks throughout. One unreadable log or stat
// file costs its own row, not the response.
//
// The scan window and the output are both bounded.
// Logs older than 7 days are skipped, only the last 200 lines of each are read, each
// line is truncated at 220 characters, and the result is capped at 20 errors. This runs
// against a directory that grows without limit.
//
// ANSI escapes are stripped before matching and before returning.
// Logs are written with colour. Without stripping, the patterns would miss coloured
// error markers and the JSON would carry terminal control codes into the page.
//
// The ping target is escaped, and time-boxed by ping itself.
// escapeshellarg() on a value that came from conf, and -c1 -W2 so an unreachable
// partner costs two seconds. The result is cached 30s so the board's poll does not ping
// on every request.
//
// Reachability falls back to the hostname when Tailscale cannot resolve.
// Better to test something and report the result than to report nothing because the
// preferred resolution path failed.
//
// REQUEST
// GET, no parameters
//
// RESPONSE
// {"ok":true,
// "locks":[{"name","file","age"}], stale locks only — empty is healthy
// "errors":[{"script","line","ts"}], newest first, max 20
// "partner":{"host","reachable","latency"}} null when no partner is configured
//
// DEPENDS ON
// include/config.php LOG_DIR, vv_conf_vars(), vv_get_hostname(),
// vv_resolve_tailscale_ip(), vv_cache_read(), vv_cache_write()
// /tmp/unraid_locks lock files written by common.sh's locking helper
// LOG_DIR/** .log files and their .json stat files, written by run_job.sh
// ═══════════════════════════════════════════════════════════════════════════════════════════════
header('Content-Type: application/json');
require_once dirname(__DIR__) . '/include/config.php';