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
+292
View File
@@ -0,0 +1,292 @@
<?php
// ═══════════════════════════════════════════════════════════════════════════════════════════════
// PURPOSE
// Keeps a running record of every certificate NPM holds: when it was first seen, how many times
// it has renewed, how many times it has been found expired, and how long it has been tracked.
// The Certs tab reads what this writes; nothing here draws anything.
//
// WHY IT EXISTS
// NPM knows what a certificate is today and nothing about what it was. cert_monitor.sh checks
// the two domains in CERT_MONITOR_DOMAINS over TLS and writes a snapshot that the next run
// overwrites. So "has this domain been renewing cleanly for the last year" had no answer
// anywhere, and the ten certificates that had been failing renewal for months — 1001 failed
// certbot runs, zero successes — were visible only by reading certbot's logs by hand.
//
// OPERATIONAL MODEL
// One pass per invocation, over the certificate list NPM returns:
// 1. A domain not in the store is added, with first_seen taken from NPM's created_on rather
// than from now — that date is real and this file should not pretend tracking began the
// day it was installed.
// 2. An expiry that has moved later than the stored one is a renewal.
// 3. An expiry in the past is a failure, and a strike.
// 4. CERT_HISTORY_STRIKES strikes retires the domain from the active list. It stays in the
// store — the history is the point — but stops being counted as something that works.
// 5. A tracked domain that is no longer in NPM is marked removed, not struck. Deleting a
// certificate is a decision; failing to renew one is not.
//
// DESIGN PRINCIPLES
// Counts start at zero and are only ever real.
// There is no renewal history anywhere on this host to seed from — every Let's Encrypt
// archive directory holds exactly one generation, so no renewal has ever completed through
// this NPM. Back-filling a plausible number would make the card a guess wearing a
// statistic. first_seen is seeded because it is a fact NPM already holds.
//
// Keyed by domain, not by NPM id.
// A certificate deleted and re-issued gets a new id and is the same domain. Keying on the
// id would restart the history of anything ever recreated, which is exactly the moment the
// history is worth having.
//
// A renewal is an expiry that moved forward.
// Derived from the list NPM already returns rather than from a TLS handshake per domain:
// forty openssl connections to answer a question the API has already answered is a lot of
// runtime for the same fact. cert_monitor.sh still does the handshake for the domains that
// need the outside world's view.
//
// OPERATIONAL SAFEGUARDS
// Non-fatal, always. A missing NPM, bad credentials or an unreadable store exits 0 with a
// message. This runs inside the Sunday report and must never be the reason it fails.
//
// One pass at a time — flock, non-blocking.
//
// The store is written atomically, tmp + rename, and a write that cannot be verified leaves
// the previous file in place. This is append-mostly history; a truncated write loses all of it.
//
// RUNTIME MODES
// cert_history.php one pass, updates the store
// cert_history.php --dry-run reports what it would change, writes nothing
// cert_history.php --status prints the store as a table
//
// CONFIGURATION
// CERT_HISTORY_STRIKES consecutive failed passes before a domain is retired (default 5)
// DB_DIR cert_history.json lives here
// ═══════════════════════════════════════════════════════════════════════════════════════════════
require_once dirname(__DIR__) . '/include/auth.php';
$dryRun = in_array('--dry-run', $argv, true);
$status = in_array('--status', $argv, true);
const VV_CERT_HISTORY_FILE = 'cert_history.json';
function vv_cert_history_path(): string {
return rtrim(defined('DB_DIR') ? DB_DIR : (DATA_DIR . '/db'), '/') . '/' . VV_CERT_HISTORY_FILE;
}
function vv_cert_history_read(): array {
$p = vv_cert_history_path();
if (!is_file($p)) return ['domains' => [], 'created' => time()];
$j = json_decode((string) @file_get_contents($p), true);
// A corrupt store is not overwritten from here — it is reported and left alone, because the
// alternative is a pass that silently restarts every counter from zero.
if (!is_array($j) || !isset($j['domains']) || !is_array($j['domains'])) return [];
return $j;
}
function vv_cert_history_write(array $data): bool {
$p = vv_cert_history_path();
$dir = dirname($p);
if (!is_dir($dir) && !@mkdir($dir, 0755, true)) return false;
$data['updated'] = time();
$json = json_encode($data, JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES);
if ($json === false) return false;
$tmp = $p . '.vv.tmp';
if (@file_put_contents($tmp, $json) === false) return false;
// Verified before it replaces the real file. This is the only copy of the history.
if (json_decode((string) @file_get_contents($tmp), true) === null) { @unlink($tmp); return false; }
if (!@rename($tmp, $p)) { @unlink($tmp); return false; }
return true;
}
function vv_cert_strike_limit(): int {
$n = (int) (vv_conf_vars()['CERT_HISTORY_STRIKES'] ?? 5);
return $n > 0 ? $n : 5;
}
// ── Status ────────────────────────────────────────────────────────────────────
if ($status) {
$h = vv_cert_history_read();
if (!$h) { echo "cert_history.json is unreadable or malformed\n"; exit(0); }
$d = $h['domains'] ?? [];
printf("%-32s %7s %7s %7s %7s %-10s %s\n",
'domain', 'checks', 'renews', 'fails', 'strikes', 'expires', 'tracked');
foreach ($d as $dom => $r) {
printf("%-32s %7d %7d %7d %7d %-10s %s%s\n", substr($dom, 0, 32),
$r['checks'] ?? 0, $r['renewals'] ?? 0, $r['failures'] ?? 0, $r['strikes'] ?? 0,
$r['last_expiry'] ?? '-', vv_cert_span($r['first_seen'] ?? time()),
!empty($r['retired_at']) ? ' RETIRED' : (!empty($r['removed_at']) ? ' removed' : ''));
}
printf("\n%d tracked, strike limit %d\n", count($d), vv_cert_strike_limit());
exit(0);
}
// Years, months and days rather than a day count. "3 years 6 months and 22 days" is the shape the
// question is asked in; 1298 days is the same fact in a unit nobody thinks in.
function vv_cert_span(int $from, ?int $to = null): string {
$a = (new DateTime())->setTimestamp($from);
$b = (new DateTime())->setTimestamp($to ?? time());
if ($b < $a) return '0d';
$d = $a->diff($b);
$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);
}
// What one observation of one domain does to its record. Pure — takes the record and the facts,
// returns the new record and what happened — so the strike ladder can be tested without waiting
// for a certificate to expire. That mattered: nothing on this host is expired right now, so the
// failure branch would otherwise ship having never run.
//
// $exp the certificate's expiry, as a timestamp
// $now the moment of this pass
// $limit strikes before retirement
function vv_cert_apply(array $r, int $exp, int $now, int $limit): array {
$out = ['renewed' => false, 'failed' => false, 'retired' => false, 'from' => '', 'to' => ''];
$r['checks'] = ($r['checks'] ?? 0) + 1;
$r['last_seen'] = $now;
// Cleared on sight: a domain that is back in NPM is not removed any more, whatever it was
// last pass.
$r['removed_at'] = null;
// Both sides reduced to a date. last_expiry is stored as Y-m-d and NPM's expires_on carries a
// time, so comparing raw timestamps made every re-read of the same certificate look like a
// renewal to a few hours later.
$expDay = strtotime(date('Y-m-d', $exp));
$prev = !empty($r['last_expiry']) ? strtotime($r['last_expiry']) : null;
if ($prev !== null && $expDay > $prev) {
$r['renewals'] = ($r['renewals'] ?? 0) + 1;
$r['last_renewal'] = $now;
// A renewal clears the strikes and un-retires. The point of a strike count is "how long
// has this been broken", and it is no longer broken.
$r['strikes'] = 0;
$r['retired_at'] = null;
$out['renewed'] = true;
$out['from'] = date('Y-m-d', $prev);
$out['to'] = date('Y-m-d', $expDay);
}
if ($exp < $now) {
$r['failures'] = ($r['failures'] ?? 0) + 1;
$r['strikes'] = ($r['strikes'] ?? 0) + 1;
$out['failed'] = true;
if ($r['strikes'] >= $limit && empty($r['retired_at'])) {
$r['retired_at'] = $now;
$r['retired_reason'] = "expired for {$r['strikes']} consecutive passes";
$out['retired'] = true;
}
}
$r['last_expiry'] = date('Y-m-d', $expDay);
$out['record'] = $r;
return $out;
}
// ── One pass ──────────────────────────────────────────────────────────────────
$lockPath = sys_get_temp_dir() . '/vv_cert_history.lock';
$lock = @fopen($lockPath, 'c');
if ($lock === false || !flock($lock, LOCK_EX | LOCK_NB)) {
echo "another pass is running\n";
exit(0);
}
try {
if ($miss = vv_auth_creds_missing('npm')) { echo "$miss\n"; exit(0); }
$certs = vv_npm_list_certs();
if (!$certs) { echo "NPM returned no certificates — nothing to record\n"; exit(0); }
$hist = vv_cert_history_read();
if (!$hist) { echo "cert_history.json is malformed — refusing to overwrite it\n"; exit(1); }
$store = $hist['domains'] ?? [];
$limit = vv_cert_strike_limit();
$now = time();
$seen = [];
$added = $renewed = $failed = $retired = $removed = 0;
$notes = [];
// Resolved to one certificate per domain before anything is counted. A domain can appear on
// more than one certificate — three do here, left behind by re-issuing rather than replacing —
// and walking the list directly counted each of them as a separate check of the same domain,
// then read the second one's expiry as a renewal of the first.
//
// The winner is the latest expiry, because that is the one actually worth serving; the earliest
// created is kept as first_seen for the same reason NPM's created_on is used at all.
$byDomain = [];
foreach ($certs as $c) {
$exp = !empty($c['expires_on']) ? strtotime((string) $c['expires_on']) : false;
if ($exp === false) continue;
$created = !empty($c['created_on']) ? strtotime((string) $c['created_on']) : $now;
foreach (($c['domain_names'] ?? []) as $d) {
$d = strtolower(trim((string) $d));
if ($d === '') continue;
if (!isset($byDomain[$d]) || $exp > $byDomain[$d]['exp'])
$byDomain[$d] = ['exp' => $exp, 'cert' => $c, 'created' => $created];
else
$byDomain[$d]['created'] = min($byDomain[$d]['created'], $created ?: $now);
}
}
{
foreach ($byDomain as $domain => $info) {
$exp = $info['exp'];
$c = $info['cert'];
$seen[$domain] = true;
if (!isset($store[$domain])) {
$created = $info['created'];
$store[$domain] = [
'first_seen' => $created !== false ? $created : $now,
'seeded_from' => 'npm_created_on',
'checks' => 0, 'renewals' => 0, 'failures' => 0, 'strikes' => 0,
'last_expiry' => null, 'last_renewal' => null,
'retired_at' => null, 'removed_at' => null,
];
$added++;
$notes[] = "added $domain (first seen " . date('Y-m-d', $store[$domain]['first_seen']) . ')';
}
$r = &$store[$domain];
$r['npm_id'] = $c['id'] ?? null;
$r['provider'] = $c['provider'] ?? null;
$res = vv_cert_apply($r, $exp, $now, $limit);
$r = $res['record'];
if ($res['renewed']) { $renewed++; $notes[] = "renewed $domain ($res[from]$res[to])"; }
if ($res['failed']) { $failed++; }
if ($res['retired']) { $retired++; $notes[] = "RETIRED $domain after {$r['strikes']} strikes"; }
unset($r);
}
}
// Tracked but no longer in NPM. Marked, never struck and never deleted from the store — the
// history of a domain that used to exist is the reason this file is kept.
foreach ($store as $domain => &$r) {
if (isset($seen[$domain])) continue;
if (empty($r['removed_at'])) {
$r['removed_at'] = $now;
$removed++;
$notes[] = "no longer in NPM: $domain";
}
}
unset($r);
ksort($store);
$hist['domains'] = $store;
$hist['last_pass'] = $now;
printf("%d certificates, %d domains tracked — added %d, renewed %d, failed %d, retired %d, removed %d\n",
count($certs), count($store), $added, $renewed, $failed, $retired, $removed);
foreach ($notes as $n) echo " $n\n";
if ($dryRun) { echo "dry run — nothing written\n"; exit(0); }
if (!vv_cert_history_write($hist)) { echo "could not write " . vv_cert_history_path() . "\n"; exit(1); }
echo 'wrote ' . vv_cert_history_path() . "\n";
exit(0);
} finally {
flock($lock, LOCK_UN);
fclose($lock);
}
+45
View File
@@ -0,0 +1,45 @@
#!/bin/bash
# ==============================================================================================
# ================================ Certificate History =========================================
# ==============================================================================================
#
# PURPOSE
# ─────────────────────────────────────────────────────────────────────────────
# Records what happens to every certificate NPM holds — first seen, renewals, failures, and how
# long each domain has been tracked — into DB_DIR/cert_history.json. The Certs tab reads it.
#
# NPM knows what a certificate is today and nothing about what it was, and cert_monitor.sh writes
# a snapshot the next run overwrites. Ten certificates on this host had been failing renewal for
# months without anything on any page being able to say so.
#
# ==============================================================================================
# OPERATIONAL MODEL
# ==============================================================================================
#
# A wrapper. The work is in cert_history.php, next to the NPM client it needs — the API token
# handling lives in include/auth.php and reimplementing it in bash to avoid a php call would be a
# second copy of the thing most worth having only one of. Same split as api_cache_writer and
# ai_repair_sweep.
#
# Counts start from zero on first run and are only ever observed. first_seen is seeded from NPM's
# own created_on, which is a real date; nothing else is back-filled.
#
# ==============================================================================================
# RUNTIME MODES
# ==============================================================================================
#
# cert_history.sh one pass, updates the store
# cert_history.sh --dry-run reports what it would change, writes nothing
# cert_history.sh --status prints the store as a table
#
# ==============================================================================================
# CONFIGURATION
# ==============================================================================================
#
# CERT_HISTORY_STRIKES consecutive failed passes before a domain is retired (default 5)
# DB_DIR cert_history.json is written here
#
# ==============================================================================================
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
php "$SCRIPT_DIR/cert_history.php" "$@"
+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();
+119
View File
@@ -243,6 +243,32 @@ require_once dirname(__DIR__) . '/include/ai_chat.php';
.vv-au-cert-days { font-size:28px;font-weight:700;line-height:1;margin:6px 0 2px; }
.vv-au-cert-bar { height:3px;border-radius:2px;background:#1a1a1a;overflow:hidden;margin-top:8px; }
.vv-au-cert-fill { height:100%;border-radius:2px;transition:width .3s; }
/* ── Cert history ────────────────────────────────────────────────────────── */
.vv-au-tot { display:grid;grid-template-columns:repeat(auto-fit,minmax(96px,1fr));gap:8px; }
.vv-au-tot-b { background:#161616;border:1px solid #222;border-radius:5px;padding:8px 10px;text-align:center; }
.vv-au-tot-n { font-size:19px;font-weight:700;color:#bbb;line-height:1.1; }
.vv-au-tot-n.bad { color:#ef5350; }
.vv-au-tot-l { font-size:9px;color:#444;text-transform:uppercase;letter-spacing:.05em;margin-top:2px; }
/* Fixed columns rather than flex: forty rows of counters only read as a table if the numbers line
up down the page, and a domain name is the one part whose width varies. */
.vv-au-hist-row { display:grid;grid-template-columns:1fr 34px 34px 74px 84px auto;gap:6px;
align-items:center;padding:4px 12px;border-bottom:1px solid #1a1a1a;font-size:11px; }
.vv-au-hist-row:last-child { border-bottom:none; }
.vv-au-hist-row:hover { background:#141414; }
.vv-au-hist-row.retired { background:#150c0c; }
.vv-au-hist-row.removed { opacity:.45; }
.vv-au-hist-dom { color:#bbb;font-family:monospace;overflow:hidden;text-overflow:ellipsis;white-space:nowrap; }
.vv-au-hist-n { text-align:right;font-weight:600;font-size:11px; }
.vv-au-hist-n.ok { color:#4caf50; }
.vv-au-hist-n.bad{ color:#ef5350; }
.vv-au-hist-n.dim{ color:#333; }
.vv-au-hist-t { color:#666;font-size:10px;text-align:right; }
.vv-au-hist-e { color:#444;font-size:10px;font-family:monospace;text-align:right; }
.vv-au-hist-note { font-size:9px;color:#3a3a3a;font-weight:normal;text-transform:none;letter-spacing:0; }
.vv-au-strike { font-size:9px;color:#ff9800;background:#1f1200;border:1px solid #3a2800;
border-radius:2px;padding:0 5px;white-space:nowrap; }
.vv-au-dot { width:7px;height:7px;border-radius:50%;flex-shrink:0;display:inline-block; }
</style>
<?php
@@ -375,6 +401,24 @@ $tabs = ['proxies' => 'Proxies', 'users' => 'Users &amp; Groups',
<div class="vv-au-loading">Loading…</div>
</div>
<div id="vv-au-cert-cfg" style="margin-top:10px;font-size:10px;color:#3a3a3a;"></div>
<!-- Everything below is history rather than current state, and comes from
DB_DIR/cert_history.json which Tools/cert_history.sh writes. The grid above answers "is
this cert about to expire"; this answers "has this domain ever been trouble". -->
<div id="vv-au-hist-totals" style="margin-top:14px"></div>
<div class="vv-au-ug-grid" style="margin-top:10px;grid-template-columns:2fr 1fr">
<div class="vv-au-card">
<div class="vv-au-card-h">
<span class="vv-au-card-title">Domain history</span>
<span class="vv-au-hist-note" id="vv-au-hist-when"></span>
</div>
<div id="vv-au-hist-list"><div class="vv-au-loading">Loading…</div></div>
</div>
<div class="vv-au-card">
<div class="vv-au-card-h"><span class="vv-au-card-title">DDNS</span></div>
<div id="vv-au-ddns"><div class="vv-au-loading">Loading…</div></div>
</div>
</div>
</div>
<?php endif; ?>
@@ -1769,9 +1813,84 @@ function _renderNpmCerts(data) {
}).join('');
}
// ── History, totals and DDNS ─────────────────────────────────────────────────
// Read-only: the counters advance when Tools/cert_history.sh runs, not when this page is opened.
// A page that wrote the history it displays would count a refresh as an observation.
function _loadCertHistory() {
const list = document.getElementById('vv-au-hist-list');
const tot = document.getElementById('vv-au-hist-totals');
const ddns = document.getElementById('vv-au-ddns');
if (!list) return;
fetch(CERT_API + '?action=history')
.then(r => r.json())
.then(d => {
if (!d.ok) throw new Error(d.error || 'history unavailable');
const t = d.totals || {};
if (!t.tracked) {
tot.innerHTML = '';
list.innerHTML = '<div class="vv-au-empty">No history yet — run '
+ '<code style="color:#5c9fd4">Tools/cert_history.sh</code> once to start tracking.</div>';
ddns.innerHTML = '';
return;
}
// Failures are only red when there are any: a zero in an alarm colour trains you to ignore
// the colour rather than the number.
tot.innerHTML = `<div class="vv-au-tot">
${_totBox(t.tracked, 'domains tracked')}
${_totBox(t.active, 'active')}
${_totBox(t.renewals, 'renewals seen')}
${_totBox(t.failures, 'failures', t.failures ? 'bad' : '')}
${_totBox(t.retired, 'retired', t.retired ? 'bad' : '')}
${_totBox(t.checks, 'checks')}
${_totBox(t.oldest_span || '—', 'longest tracked')}
</div>`;
const when = document.getElementById('vv-au-hist-when');
if (when) when.textContent = d.last_pass
? 'last pass ' + new Date(d.last_pass * 1000).toLocaleString()
: 'never run';
list.innerHTML = (d.rows || []).map(r => {
const cls = r.state === 'retired' ? ' retired' : (r.state === 'removed' ? ' removed' : '');
// Strikes are only worth showing while they are accruing; a retired domain already says so.
const strike = (r.strikes && r.state === 'active')
? `<span class="vv-au-strike">${r.strikes}/${d.strike_limit} strikes</span>` : '';
const tag = r.state === 'retired' ? '<span class="vv-au-badge deny">retired</span>'
: r.state === 'removed' ? '<span class="vv-au-badge nossl">removed</span>' : '';
return `<div class="vv-au-hist-row${cls}">
<span class="vv-au-hist-dom" title="${_esc(r.domain)}">${_esc(r.domain)}</span>
<span class="vv-au-hist-n ok" title="renewals observed">${r.renewals}</span>
<span class="vv-au-hist-n ${r.failures ? 'bad' : 'dim'}" title="times found expired">${r.failures}</span>
<span class="vv-au-hist-t" title="first seen ${_esc(new Date(r.first_seen*1000).toISOString().slice(0,10))}">${_esc(r.tracked)}</span>
<span class="vv-au-hist-e" title="current expiry">${_esc(r.expires || '—')}</span>
${strike}${tag}
</div>`;
}).join('') || '<div class="vv-au-empty">Nothing tracked yet.</div>';
ddns.innerHTML = (d.ddns || []).length
? d.ddns.map(c => `<div class="vv-au-user-row">
<span class="vv-au-dot" style="background:${c.running ? '#4caf50' : '#ef5350'}"></span>
<span class="vv-au-user-name" style="flex:1">${_esc(c.name)}</span>
<span class="vv-au-user-email">${_esc(c.status)}</span>
</div>`).join('')
: '<div class="vv-au-empty">No DDNS containers configured.</div>';
})
.catch(e => {
list.innerHTML = `<div class="vv-au-empty" style="color:#ef5350">${_esc(e.message || 'Failed')}</div>`;
});
}
function _totBox(n, label, cls) {
return `<div class="vv-au-tot-b"><div class="vv-au-tot-n ${cls || ''}">${_esc(String(n))}</div>`
+ `<div class="vv-au-tot-l">${_esc(label)}</div></div>`;
}
function _loadCerts(onDone) {
const grid = document.getElementById('vv-au-cert-grid');
grid.innerHTML = '<div class="vv-au-loading">Loading…</div>';
_loadCertHistory();
fetch(CERT_API + '?action=npm')
.then(r => r.json())
.then(d => {
+10
View File
@@ -1196,6 +1196,16 @@ Saved into `master.conf`, which does not need to be opened by hand.
| `BUG_REPORT_LOCAL_ENABLED` | a switch | in this section | LOCAL ON — reports go to your own Gitea (see HOSTN_BUG_REPORT_* in host*.conf) and stay there. They do NOT reach the Varaverk maintainer. Turn it on if you want your own backlog. LOCAL OFF — reports open a prefilled GitHub issue you submit under your own account. |
| `BUG_REPORT_GITHUB_REPO` | a text box | in this section | — |
## Certificate History
Route: Settings tab → All settings → *Certificate History*
Saved into `master.conf`, which does not need to be opened by hand.
| Setting | Control | Where | What it does |
|---|---|---|---|
| `CERT_HISTORY_STRIKES` | a number box | in this section | expired passes before a domain is retired from the active list |
## Certificate Monitor
Reachable from: