[], '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); }