Files
Varaverk/Plugin/unraid/api/cert.php
T
Gmer4Lfe 99b58c0c4f Make the Auth tab explain a number instead of only showing it
A low uptime figure, a refused login and a certificate that stopped
renewing all looked the same from the row: a number, with the reason
split across NPM, an Authelia config and the directory.

The why-check goes and looks — TCP to the forward target, HTTP through
the proxy, a second handshake with verification off to tell a broken
certificate from a broken service. Forward hosts are docker names that
only resolve on NPM's network, so an unresolvable one is redirected to
the container address and the substitution is reported; a check that
could not be made must never read as a check that failed.

The access simulator walks the rules the way Authelia does and shows the
ones it stepped over, reading whichever instance the chosen host points
at rather than the one conf names — there are two here.

Cert triage counts runs rather than log lines and orders by rotation
suffix rather than mtime, both of which change the answer.
2026-08-16 01:59:01 -04:00

322 lines
16 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;
}
// ── Why renewals failed ───────────────────────────────────────────────────────
// Reads certbot's own logs and names the categories the failures fall into. A read, so GET: it
// opens files and nothing else. Bounded inside vv_cert_triage() by file count and bytes per file,
// because the log directory here is 639 MB and a page request must not depend on its size.
if ($action === 'triage') {
echo json_encode(vv_cert_triage());
exit;
}
$data = json_decode(file_get_contents($cacheFile), true) ?: [];
echo json_encode(array_merge(['ok' => true], $data));