[]]; $j = json_decode((string) @file_get_contents($p), true); if (!is_array($j) || !isset($j['domains']) || !is_array($j['domains'])) return []; return $j; } function vv_uptime_write(array $d): bool { $p = vv_uptime_path(); if (!is_dir(dirname($p)) && !@mkdir(dirname($p), 0755, true)) return false; $d['updated'] = time(); $json = json_encode($d, JSON_UNESCAPED_SLASHES); if ($json === false) return false; $tmp = $p . '.vv.tmp'; if (@file_put_contents($tmp, $json) === false) return false; if (json_decode((string) @file_get_contents($tmp), true) === null) { @unlink($tmp); return false; } if (!@rename($tmp, $p)) { @unlink($tmp); return false; } return true; } // ── Report ──────────────────────────────────────────────────────────────────── // Anything that was not perfect over the last seven days, for the Sunday report. Prints nothing // and exits 0 when every domain was clean — the orchestrator's job is to be quiet on a good week, // and a report that always says something is a report nobody reads. if ($report) { $s = vv_uptime_read(); $doms = $s['domains'] ?? []; if (!$doms) { exit(0); } $down = $wobble = []; foreach ($doms as $d => $r) { $w = vv_uptime_window($r['days'] ?? [], 7); if (($r['state'] ?? '') === 'down') { $since = !empty($r['last_change']) ? ' since ' . date('D H:i', $r['last_change']) : ''; $down[] = sprintf(' %-34s DOWN%s — %s', $d, $since, $r['last_detail'] ?? ''); } elseif ($w !== null && $w < 100) { $wobble[] = sprintf(' %-34s %.2f%% over 7 days', $d, $w); } } if (!$down && !$wobble) exit(0); echo "Uptime — 7 day review\n"; if ($down) { echo "\nCurrently down:\n"; foreach ($down as $l) echo "$l\n"; } if ($wobble) { echo "\nNot perfect this week:\n"; foreach ($wobble as $l) echo "$l\n"; } // Non-zero so the calling wrapper can notify on "there is something to say" without parsing. exit(1); } // ── Status / events ─────────────────────────────────────────────────────────── if ($status || $events) { $s = vv_uptime_read(); if (!$s) { echo "uptime.json is unreadable\n"; exit(0); } $doms = $s['domains'] ?? []; if ($events) { $all = []; foreach ($doms as $d => $r) foreach ($r['events'] ?? [] as $e) $all[] = $e + ['domain' => $d]; usort($all, fn($a, $b) => $b['ts'] <=> $a['ts']); if (!$all) { echo "no state changes recorded\n"; exit(0); } foreach (array_slice($all, 0, 40) as $e) printf("%s %-34s %-5s %s\n", date('Y-m-d H:i', $e['ts']), $e['domain'], strtoupper($e['to']), $e['detail'] ?? ''); exit(0); } printf("%-34s %-6s %8s %8s %8s %8s %s\n", 'domain', 'state', '24h', '7d', '30d', '1y', 'since'); foreach ($doms as $d => $r) { $h24 = vv_uptime_window($r['hours'] ?? [], 24); $d7 = vv_uptime_window($r['days'] ?? [], 7); $d30 = vv_uptime_window($r['days'] ?? [], 30); $y1 = vv_uptime_window($r['months'] ?? [], 12); printf("%-34s %-6s %8s %8s %8s %8s %s\n", substr($d, 0, 34), $r['state'] ?? '-', $h24 === null ? '-' : $h24 . '%', $d7 === null ? '-' : $d7 . '%', $d30 === null ? '-' : $d30 . '%', $y1 === null ? '-' : $y1 . '%', !empty($r['last_change']) ? date('m-d H:i', $r['last_change']) : '-'); } printf("\n%d domains, last pass %s\n", count($doms), !empty($s['last_pass']) ? date('Y-m-d H:i:s', $s['last_pass']) : 'never'); exit(0); } // Shared by --status, --report and the API, and defined once in include/auth.php so the three // callers cannot drift apart on what "the last N" means. Buckets are keyed by time, so it is a key // sort rather than an assumption about how many samples a period should contain — a pass that did // not run leaves no bucket rather than a zero. function vv_uptime_window(array $buckets, int $n): ?float { return vv_auth_uptime_window($buckets, $n); } // ── One pass ────────────────────────────────────────────────────────────────── $lock = @fopen(sys_get_temp_dir() . '/vv_uptime_probe.lock', 'c'); if ($lock === false || !flock($lock, LOCK_EX | LOCK_NB)) { echo "another pass is running\n"; exit(0); } try { $v = vv_conf_vars(); if (strtolower(trim($v['UPTIME_PROBE_ENABLED'] ?? 'true')) === 'false') { echo "UPTIME_PROBE_ENABLED is false\n"; exit(0); } $timeout = max(2, (int) ($v['UPTIME_PROBE_TIMEOUT'] ?? 8)); $listTtl = max(60, (int) ($v['UPTIME_PROBE_LIST_TTL'] ?? 300)); $store = vv_uptime_read(); if (!$store) { echo "uptime.json is malformed — refusing to overwrite it\n"; exit(1); } // The domain list, refreshed occasionally rather than every minute. NPM's API is the source of // truth for what is being served; asking it sixty times an hour for a list that changes weekly // is the sort of thing that shows up later as unexplained load. $now = time(); if (empty($store['list']) || ($now - ($store['list_at'] ?? 0)) > $listTtl) { $p = vv_npm_list_proxies(); if ($p['ok'] ?? false) { $list = []; foreach ($p['proxies'] as $h) { // A disabled host is not expected to answer, so probing it would record a // permanent outage for something switched off on purpose. if (($h['enabled'] ?? true) === false) continue; foreach ($h['domain_names'] ?? [] as $d) { $d = strtolower(trim((string) $d)); // A wildcard is not a hostname you can connect to. if ($d !== '' && !str_contains($d, '*')) $list[$d] = true; } } if ($list) { $store['list'] = array_keys($list); $store['list_at'] = $now; } } } $domains = $store['list'] ?? []; if (!$domains) { echo "no domains to probe\n"; exit(0); } // ── Probe, in parallel ── $mh = curl_multi_init(); $handles = []; foreach ($domains as $d) { $ch = curl_init('https://' . $d . '/'); curl_setopt_array($ch, [ CURLOPT_NOBODY => true, // HEAD — liveness, not content CURLOPT_FOLLOWLOCATION => false, // a redirect to the auth portal is the answer CURLOPT_TIMEOUT => $timeout, CURLOPT_CONNECTTIMEOUT => min($timeout, 5), CURLOPT_USERAGENT => VV_UPTIME_UA, CURLOPT_RETURNTRANSFER => true, // These are this host's own certificates behind its own proxy. Verification is left on // because a cert that stopped validating is exactly the failure worth catching. CURLOPT_SSL_VERIFYPEER => true, CURLOPT_SSL_VERIFYHOST => 2, ]); curl_multi_add_handle($mh, $ch); $handles[$d] = $ch; } $t0 = microtime(true); $running = null; do { curl_multi_exec($mh, $running); if ($running) curl_multi_select($mh, 1.0); } while ($running); $upN = $downN = 0; $downList = []; $changes = []; $doms = $store['domains'] ?? []; $hourKey = date('YmdH', $now); $dayKey = date('Ymd', $now); $monKey = date('Ym', $now); foreach ($handles as $d => $ch) { $errno = curl_errno($ch); $code = (int) curl_getinfo($ch, CURLINFO_HTTP_CODE); $ms = (int) round(curl_getinfo($ch, CURLINFO_TOTAL_TIME) * 1000); $err = curl_error($ch); curl_multi_remove_handle($mh, $ch); curl_close($ch); $up = ($errno === 0 && $code > 0 && $code < 500); $detail = $errno !== 0 ? ($err ?: 'connection failed') : ('HTTP ' . $code); $up ? $upN++ : $downN++; if (!$up) $downList[] = "$d — $detail"; $r = $doms[$d] ?? ['checks' => 0, 'up' => 0, 'down' => 0, 'state' => null, 'last_change' => null, 'samples' => [], 'hours' => [], 'days' => [], 'months' => [], 'events' => [], 'since' => $now]; $r['checks']++; $up ? $r['up']++ : $r['down']++; $r['last_code'] = $code; $r['last_ms'] = $ms; $r['last_at'] = $now; $r['last_detail'] = $detail; $newState = $up ? 'up' : 'down'; if (($r['state'] ?? null) !== $newState) { // The first observation is not a transition — there was no previous state to leave. if ($r['state'] !== null) { $ev = ['ts' => $now, 'to' => $newState, 'detail' => $detail]; $r['events'][] = $ev; $r['events'] = array_slice($r['events'], -VV_EVENTS_KEEP); $changes[] = "$d → " . strtoupper($newState) . " ($detail)"; } $r['state'] = $newState; $r['last_change'] = $now; } $r['samples'][] = $up ? 1 : 0; if (count($r['samples']) > VV_SAMPLES_KEEP) $r['samples'] = array_slice($r['samples'], -VV_SAMPLES_KEEP); foreach ([['hours', $hourKey, VV_HOURS_KEEP], ['days', $dayKey, VV_DAYS_KEEP], ['months', $monKey, VV_MONTHS_KEEP]] as [$k, $key, $keep]) { $b = $r[$k][$key] ?? ['u' => 0, 't' => 0]; $b['t']++; if ($up) $b['u']++; $r[$k][$key] = $b; if (count($r[$k]) > $keep) { krsort($r[$k]); $r[$k] = array_slice($r[$k], 0, $keep, true); ksort($r[$k]); } } $doms[$d] = $r; } curl_multi_close($mh); // Retire domains nothing has probed in a long time. Keyed on last_at rather than on absence // from the current list, which matters when NPM is unreachable: the cached list keeps being // probed, every domain keeps getting a last_at, and an NPM outage therefore cannot empty the // store. Only a domain that genuinely left the list stops being stamped. $cutoff = $now - (VV_DOMAIN_DROP_DAYS * 86400); $dropped = []; foreach ($doms as $d => $r) { // A record with no last_at at all is kept. It should not be possible — every probe stamps // it — and deleting on missing data is the wrong way round for something irreversible. if (isset($r['last_at']) && $r['last_at'] < $cutoff) { unset($doms[$d]); $dropped[] = $d; } } $store['domains'] = $doms; $store['last_pass'] = $now; printf("%d domains — %d up, %d down, %.1fs%s\n", count($handles), $upN, $downN, microtime(true) - $t0, $changes ? '' : ' (no state changes)'); foreach ($changes as $c) echo " $c\n"; foreach ($downList as $c) echo " DOWN $c\n"; // Named, not silent. Dropping a record throws away months of history, and a line in the log is // the only trace that it was this and not the store being reset by something else. foreach ($dropped as $d) echo " RETIRED $d — not probed in " . VV_DOMAIN_DROP_DAYS . " days\n"; if ($dryRun) { echo "dry run — nothing written\n"; exit(0); } if (!vv_uptime_write($store)) { echo 'could not write ' . vv_uptime_path() . "\n"; exit(1); } exit(0); } finally { flock($lock, LOCK_UN); fclose($lock); }