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));