Files
Varaverk/Plugin/unraid/api/cert.php
T
Gmer4Lfe 71ba0a239f Track what happens to every certificate, and show it on the Certs tab
NPM knows what a certificate is today and nothing about what it was, so ten of them could fail
renewal for months — 1001 certbot runs, zero successes — without anything on any page saying so.
Counts start at zero and are only ever observed; only first_seen is seeded, from NPM's own date.
2026-08-15 18:51:38 -04:00

313 lines
15 KiB
PHP

<?php
// ═══════════════════════════════════════════════════════════════════════════════════════════════
// PURPOSE
// Certificate status. Serves the cert panel four ways — the cached results of the last
// cert_monitor.sh run, the configured domain list, a live read of NPM's own certificate
// store, and an on-demand re-run of the monitor.
//
// OPERATIONAL MODEL
// Two independent sources of truth, deliberately kept separate. cert_monitor.sh checks the
// domains as they resolve from outside — the certificate a visitor actually receives. NPM's
// API reports what it holds internally. They disagree exactly when something is wrong: a
// renewed cert that was never reloaded, or a proxy host pointing at the wrong certificate.
// Collapsing them into one number would hide the only case worth catching.
//
// The default path serves cert_monitor.sh's cache and never checks anything itself. That
// script runs on the Sunday report schedule; the run action exists for when someone does
// not want to wait for it.
//
// DESIGN PRINCIPLES
// Thresholds come from master.conf, with shipped defaults.
// CERT_WARN_DAYS and CERT_CRIT_DAYS are read per request rather than baked in, so the
// page and the script that notifies agree on what "critical" means.
//
// No cache is answered with the domain list, not with an error.
// Before the first run there is nothing to report, so the configured domains are
// returned marked UNKN. The panel shows what is being watched rather than an empty
// state that looks like nothing is configured.
//
// Already expired sorts with critical, not past it.
// A negative day count is CRIT rather than a separate state, and the sort puts the
// smallest number first — so the most urgent certificate is always at the top.
//
// The run action returns the script's output alongside the fresh data.
// Capped at 30 lines. When a check fails, the reason is in that output and nowhere in
// the structured result.
//
// OPERATIONAL SAFEGUARDS
// The action is chosen from a fixed set, and everything else falls to the cached read.
// No part of the request names a script, a domain, or a file. The one script this
// endpoint can run is a hardcoded path.
//
// The script run is externally time-boxed.
// `timeout 180` wraps it — set_time_limit() does not cover exec() time on Linux, so
// PHP's own limit cannot end a check hung on an unresponsive domain. Exit 124 is
// reported as a timeout rather than a generic failure.
//
// A missing script is reported, not executed.
// file_exists() precedes exec(), so a partial deploy returns a named error rather than
// a shell failure surfacing as an empty result.
//
// Every threshold read has a default.
// ?: 30 and ?: 7 on the regex captures, so a master.conf mid-edit or missing the keys
// still yields coherent statuses instead of comparing every certificate against zero
// and reporting the whole estate critical.
//
// Missing or malformed expiry data is UNKN, never OK.
// A certificate whose expires_on is absent or unparseable yields a null day count and
// an explicit unknown status. Defaulting it to OK would silently drop a certificate out
// of monitoring — the one failure this panel exists to prevent.
//
// Every cache read degrades.
// json_decode with ?: fallbacks throughout, so a truncated cache file yields an empty
// result rather than a fatal.
//
// The NPM path reports its own transport failures.
// vv_npm_req() returns an _err key rather than throwing, and that is checked before the
// response is treated as a certificate list — so an unreachable NPM is reported as such
// instead of rendering as zero certificates.
//
// The run action is POST only, which is what places it behind Unraid's CSRF guard.
// The platform prepend validates the token on every POST and inspects no GET at all, so
// an action that executes a script must not be reachable by GET. The three read actions
// stay GET-reachable because they change nothing.
//
// REQUEST
// GET cached status, or the configured domains when no cache exists
// GET|POST ?action=npm live certificate list from NPM's API
// GET|POST ?action=domains configured domains and thresholds, no checks run
// POST action=run re-run cert_monitor.sh, then return its fresh cache
// (POST, so Unraid's CSRF guard applies)
//
// RESPONSE
// default {"ok":true,"checked_at","host","warn_days","crit_days","domains":[…]}
// npm {"ok":true,"certs":[{id,nice_name,domain_names,provider,expires,days,status}],
// "warn_days","crit_days"}
// domains {"ok":true,"host_id","domains":[…],"warn_days","crit_days"}
// run {"ok":true,"data":…,"output":[…],"rc":int}
// {"ok":false,"error":"NPM request failed"|"cert_monitor.sh not found"|"… timed out …"}
//
// DEPENDS ON
// include/config.php vv_detect_host(), vv_read_conf_raw(), STATE_DIR, SCRIPTS_DIR
// include/auth.php vv_npm_req()
// Monitors/cert_monitor.sh writes STATE_DIR/cert_status.json
// ═══════════════════════════════════════════════════════════════════════════════════════════════
header('Content-Type: application/json');
require_once dirname(__DIR__) . '/include/config.php';
require_once dirname(__DIR__) . '/include/auth.php';
$action = ($_SERVER['REQUEST_METHOD'] === 'POST')
? trim($_POST['action'] ?? '')
: trim($_GET['action'] ?? '');
$cacheFile = STATE_DIR . '/cert_status.json';
// ── Live NPM cert list ────────────────────────────────────────────────────────
if ($action === 'npm') {
$raw = vv_npm_req('GET', '/api/nginx/certificates');
if (!is_array($raw) || isset($raw['_err']))
die(json_encode(['ok' => false, 'error' => $raw['_err'] ?? 'NPM request failed']));
$master = vv_read_conf_raw('master.conf');
preg_match('/^\s*CERT_WARN_DAYS\s*=\s*(\d+)/m', $master, $w);
preg_match('/^\s*CERT_CRIT_DAYS\s*=\s*(\d+)/m', $master, $c);
$warn = (int)($w[1] ?? 30);
$crit = (int)($c[1] ?? 7);
$now = time();
$certs = [];
foreach ($raw as $cert) {
$exp = !empty($cert['expires_on']) ? strtotime($cert['expires_on']) : false;
$days = $exp !== false ? (int)(($exp - $now) / 86400) : null;
$status = $days === null ? 'UNKN'
: ($days < 0 ? 'CRIT'
: ($days <= $crit ? 'CRIT'
: ($days <= $warn ? 'WARN' : 'OK')));
$certs[] = [
'id' => $cert['id'],
'nice_name' => $cert['nice_name'] ?? implode(', ', $cert['domain_names'] ?? []),
'domain_names'=> $cert['domain_names'] ?? [],
'provider' => $cert['provider'] ?? 'unknown',
'expires' => !empty($cert['expires_on']) ? substr($cert['expires_on'], 0, 10) : '',
'days' => $days,
'status' => $status,
];
}
usort($certs, fn($a, $b) => ($a['days'] ?? PHP_INT_MAX) <=> ($b['days'] ?? PHP_INT_MAX));
echo json_encode(['ok' => true, 'certs' => $certs, 'warn_days' => $warn, 'crit_days' => $crit]);
exit;
}
// ── Per-domain history, totals, and the DDNS containers ──────────────────────
// Read-only. Tools/cert_history.sh is what writes the store; serving it from here would mean the
// counters only advance when somebody happens to have the tab open.
if ($action === 'history') {
$file = rtrim(defined('DB_DIR') ? DB_DIR : (DATA_DIR . '/db'), '/') . '/cert_history.json';
$hist = is_file($file) ? (json_decode((string) @file_get_contents($file), true) ?: []) : [];
$doms = is_array($hist['domains'] ?? null) ? $hist['domains'] : [];
$now = time();
$rows = [];
$tot = ['tracked' => 0, 'active' => 0, 'retired' => 0, 'removed' => 0,
'renewals' => 0, 'failures' => 0, 'checks' => 0, 'oldest' => null];
foreach ($doms as $domain => $r) {
$first = (int) ($r['first_seen'] ?? $now);
$retired = !empty($r['retired_at']);
$removed = !empty($r['removed_at']);
$rows[] = [
'domain' => (string) $domain,
'first_seen'=> $first,
'tracked' => vv_cert_span_php($first, $now),
'checks' => (int) ($r['checks'] ?? 0),
'renewals' => (int) ($r['renewals'] ?? 0),
'failures' => (int) ($r['failures'] ?? 0),
'strikes' => (int) ($r['strikes'] ?? 0),
'expires' => $r['last_expiry'] ?? null,
'last_renewal' => $r['last_renewal'] ?? null,
'provider' => $r['provider'] ?? null,
'state' => $removed ? 'removed' : ($retired ? 'retired' : 'active'),
];
$tot['tracked']++;
$tot[$removed ? 'removed' : ($retired ? 'retired' : 'active')]++;
$tot['renewals'] += (int) ($r['renewals'] ?? 0);
$tot['failures'] += (int) ($r['failures'] ?? 0);
$tot['checks'] += (int) ($r['checks'] ?? 0);
if ($tot['oldest'] === null || $first < $tot['oldest']) $tot['oldest'] = $first;
}
// Longest-tracked first: the domains with the most history are the ones the card is for.
usort($rows, fn($a, $b) => $a['first_seen'] <=> $b['first_seen']);
$tot['oldest_span'] = $tot['oldest'] ? vv_cert_span_php($tot['oldest'], $now) : '—';
// DDNS is on this tab because it is the other half of the same story: a certificate is issued
// against a name, and the name only points here while DDNS keeps it pointed. The ten dead
// certificates removed on 2026-08-15 all failed with NXDOMAIN.
$hostUp = strtoupper(vv_detect_host());
$names = vv_parse_bash_array(vv_read_conf_raw(strtolower($hostUp) . '.conf'),
$hostUp . '_DDNS_CONTAINERS');
$running = [];
foreach (vv_docker_containers() as $c) $running[$c['name']] = $c['status'];
$ddns = [];
foreach ($names as $n) {
$n = trim((string) $n);
if ($n === '') continue;
$ddns[] = ['name' => $n,
'running' => isset($running[$n]),
'status' => $running[$n] ?? 'not running'];
}
echo json_encode(['ok' => true, 'rows' => $rows, 'totals' => $tot, 'ddns' => $ddns,
'last_pass' => $hist['last_pass'] ?? null,
'strike_limit' => (int) (vv_conf_vars()['CERT_HISTORY_STRIKES'] ?? 5)]);
exit;
}
// Same shape as the tool's own formatter — years, months and days, because "3 years 6 months and
// 22 days" is how the question gets asked and 1298 days is the same fact nobody thinks in.
function vv_cert_span_php(int $from, int $to): string {
if ($to < $from) return '0d';
$d = (new DateTime())->setTimestamp($from)->diff((new DateTime())->setTimestamp($to));
$out = [];
if ($d->y) $out[] = $d->y . 'y';
if ($d->m) $out[] = $d->m . 'mo';
if ($d->d || !$out) $out[] = $d->d . 'd';
return implode(' ', $out);
}
// ── Read configured domains (without running checks) ─────────────────────────
if ($action === 'domains') {
$hostId = vv_detect_host();
$hostIdUp = strtoupper($hostId);
$confRaw = ($hostId !== 'unknown') ? vv_read_conf_raw($hostId . '.conf') : '';
$master = vv_read_conf_raw('master.conf');
// Extract CERT_WARN_DAYS / CERT_CRIT_DAYS from master
preg_match('/^\s*CERT_WARN_DAYS\s*=\s*(\d+)/m', $master, $w);
preg_match('/^\s*CERT_CRIT_DAYS\s*=\s*(\d+)/m', $master, $c);
// Extract domains array from host conf
$domains = [];
if (preg_match('/' . $hostIdUp . '_CERT_MONITOR_DOMAINS\s*=\s*\(([^)]*)\)/s', $confRaw, $dm)) {
preg_match_all('/"([^"]+)"/', $dm[1], $dd);
$domains = $dd[1] ?? [];
}
echo json_encode([
'ok' => true,
'host_id' => $hostId,
'domains' => $domains,
'warn_days' => (int)($w[1] ?? 30),
'crit_days' => (int)($c[1] ?? 7),
]);
exit;
}
// ── Run cert_monitor.sh now ───────────────────────────────────────────────────
if ($action === 'run') {
// POST only. This executes a script, and Unraid's CSRF prepend validates POSTs while
// ignoring GETs entirely — over GET it would run with no token check at all.
if ($_SERVER['REQUEST_METHOD'] !== 'POST') {
http_response_code(405);
echo json_encode(['ok' => false, 'error' => 'POST only']);
exit;
}
$script = SCRIPTS_DIR . '/Monitors/cert_monitor.sh';
if (!file_exists($script)) {
echo json_encode(['ok' => false, 'error' => 'cert_monitor.sh not found']);
exit;
}
// set_time_limit() does not cover exec() time on Linux, so the bound has to be external —
// cert_monitor.sh reaches out to every configured domain and one unreachable host would
// otherwise hold a php-fpm worker open indefinitely.
set_time_limit(210);
exec('timeout 180 bash ' . escapeshellarg($script) . ' 2>&1', $out, $rc);
if ($rc === 124) {
echo json_encode(['ok' => false, 'error' => 'cert_monitor.sh timed out after 180s']);
exit;
}
// Read freshly written cache
$data = file_exists($cacheFile)
? (json_decode(file_get_contents($cacheFile), true) ?: null)
: null;
echo json_encode([
'ok' => true,
'data' => $data,
'output' => array_slice(array_filter(array_map('trim', $out)), 0, 30),
'rc' => $rc,
]);
exit;
}
// ── Default: return cached status ─────────────────────────────────────────────
if (!file_exists($cacheFile)) {
// No cache yet — return configured domains so UI can show them unchecked
$hostId = vv_detect_host();
$hostIdUp = strtoupper($hostId);
$confRaw = ($hostId !== 'unknown') ? vv_read_conf_raw($hostId . '.conf') : '';
$master = vv_read_conf_raw('master.conf');
preg_match('/^\s*CERT_WARN_DAYS\s*=\s*(\d+)/m', $master, $w);
preg_match('/^\s*CERT_CRIT_DAYS\s*=\s*(\d+)/m', $master, $c);
$domains = [];
if (preg_match('/' . $hostIdUp . '_CERT_MONITOR_DOMAINS\s*=\s*\(([^)]*)\)/s', $confRaw, $dm)) {
preg_match_all('/"([^"]+)"/', $dm[1], $dd);
foreach ($dd[1] ?? [] as $d) {
$domains[] = ['domain' => $d, 'status' => 'UNKN', 'days' => null, 'expires' => ''];
}
}
echo json_encode([
'ok' => true,
'checked_at' => null,
'host' => $hostId !== 'unknown' ? strtoupper($hostId) : null,
'warn_days' => (int)($w[1] ?? 30),
'crit_days' => (int)($c[1] ?? 7),
'domains' => $domains,
]);
exit;
}
$data = json_decode(file_get_contents($cacheFile), true) ?: [];
echo json_encode(array_merge(['ok' => true], $data));