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.
This commit is contained in:
Gmer4Lfe
2026-08-15 18:51:38 -04:00
parent 13de1dab82
commit 71ba0a239f
6 changed files with 554 additions and 0 deletions
+76
View File
@@ -139,6 +139,82 @@ if ($action === 'npm') {
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();