Files
Varaverk/Plugin/unraid/Tools/npm_access_stats.php
T
Gmer4Lfe 1001c25487 Probe every domain every minute, and keep it out of its own traffic numbers
NPM's access log only describes hosts somebody visited; the host most likely to be quietly broken
is the one nobody does. Probes carry a User-Agent npm_access_stats.sh drops — unmarked, this
monitor would be fifty thousand requests a day in the very logs it reports on.
2026-08-15 20:28:30 -04:00

235 lines
12 KiB
PHP

<?php
// ═══════════════════════════════════════════════════════════════════════════════════════════════
// PURPOSE
// Aggregates Nginx Proxy Manager's per-host access logs into a small store the Proxies tab can
// read: how many requests each host has served, how many bytes went out, how the responses
// broke down by status, and when it was last hit.
//
// WHY IT EXISTS
// NPM writes one access log per proxy host and nothing that counts them. The logs on this
// installation are 475 MB across 41 files — one of them 330 MB on its own — so the question
// "how many times has this site been hit" cannot be answered inside a page load. This runs on a
// schedule and leaves behind a few kilobytes of JSON.
//
// OPERATIONAL MODEL
// Incremental. Each pass records the byte offset it reached in every log and starts there next
// time, so the 475 MB is read once and each later pass reads only what has arrived since.
//
// Rotation is detected by the file being smaller than the offset already recorded. The counters
// are cumulative and are never reset by it — but the lines that rotated out between two passes
// are not counted, so a total is "requests seen since tracking began", not a claim about the
// whole history of the host. Running daily keeps that gap to whatever NPM rotates in a day.
//
// DESIGN PRINCIPLES
// Read forward, never re-read.
// fseek to the stored offset and read to the end. Re-parsing a 330 MB log every pass to
// recompute a number that only grows is the kind of job that quietly becomes the reason a
// nightly run takes an hour.
//
// A partial last line is not counted.
// nginx is appending while this reads. The offset advances only to the end of the last
// complete line, so the remainder is picked up whole on the next pass rather than parsed
// as a truncated record and then parsed again.
//
// Totals only. No per-client or per-path breakdown is kept — that is an analytics product, and
// this exists to answer "is anything using this host, and is it erroring".
//
// OPERATIONAL SAFEGUARDS
// Non-fatal, always: a missing log directory, an unreadable file or an absent NPM exits 0.
// One pass at a time, flock non-blocking.
// The store is written tmp + rename and verified before it replaces the previous one.
// A bounded amount of work per pass — VV_NPM_MAX_BYTES per file — so a log that grew enormously
// between passes cannot make this run unboundedly long.
//
// RUNTIME MODES
// npm_access_stats.php one pass
// npm_access_stats.php --dry-run parse and report, write nothing
// npm_access_stats.php --status print the store
// npm_access_stats.php --reset forget offsets and totals, start again from the current logs
//
// CONFIGURATION
// NPM_LOG_DIR where NPM's per-host logs live; derived from the container mount when unset
// DB_DIR npm_access.json is written here
// ═══════════════════════════════════════════════════════════════════════════════════════════════
require_once dirname(__DIR__) . '/include/auth.php';
$dryRun = in_array('--dry-run', $argv, true);
$status = in_array('--status', $argv, true);
$reset = in_array('--reset', $argv, true);
// 512 MB per file per pass. The first pass over a 330 MB log is the only one that should ever come
// near it; the cap exists so an unattended run cannot be surprised by a log that exploded.
const VV_NPM_MAX_BYTES = 536870912;
function vv_npm_stats_path(): string {
return rtrim(defined('DB_DIR') ? DB_DIR : (DATA_DIR . '/db'), '/') . '/npm_access.json';
}
// The host path to NPM's log directory. Taken from the container's own mount table rather than
// hardcoded, because that mapping is the thing most likely to differ between installations.
function vv_npm_log_dir(): string {
$conf = trim(vv_conf_vars()['NPM_LOG_DIR'] ?? '');
if ($conf !== '' && is_dir($conf)) return rtrim($conf, '/');
$name = trim(vv_conf_vars()[strtoupper(vv_detect_host()) . '_NPM_CONTAINER'] ?? 'NginxProxyManager');
// {{println}}, not a \n escape: the format string is passed through to docker as written, and
// a backslash-n in a single-quoted PHP string arrives as two literal characters — which is how
// this silently found no mounts and reported the log directory missing.
$out = shell_exec('docker inspect ' . escapeshellarg($name)
. ' --format ' . escapeshellarg('{{range .Mounts}}{{println .Source ":" .Destination}}{{end}}')
. ' 2>/dev/null');
foreach (explode("\n", trim((string) $out)) as $line) {
// println space-separates its arguments, so the mapping arrives as "src : dst".
$line = str_replace(' : ', ':', trim($line));
[$src, $dst] = array_pad(explode(':', $line, 2), 2, '');
if ($dst === '/config' && $src !== '' && is_dir("$src/log")) return "$src/log";
if ($dst === '/data' && $src !== '' && is_dir("$src/logs")) return "$src/logs";
}
return '';
}
function vv_npm_stats_read(): array {
$p = vv_npm_stats_path();
if (!is_file($p)) return ['hosts' => []];
$j = json_decode((string) @file_get_contents($p), true);
if (!is_array($j) || !isset($j['hosts']) || !is_array($j['hosts'])) return [];
return $j;
}
function vv_npm_stats_write(array $data): bool {
$p = vv_npm_stats_path();
if (!is_dir(dirname($p)) && !@mkdir(dirname($p), 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;
if (json_decode((string) @file_get_contents($tmp), true) === null) { @unlink($tmp); return false; }
if (!@rename($tmp, $p)) { @unlink($tmp); return false; }
return true;
}
// ── Status ────────────────────────────────────────────────────────────────────
if ($status) {
$s = vv_npm_stats_read();
if (!$s) { echo "npm_access.json is unreadable\n"; exit(0); }
printf("%-6s %12s %12s %8s %8s %8s %s\n", 'host', 'requests', 'sent', '2xx', '4xx', '5xx', 'last hit');
foreach ($s['hosts'] ?? [] as $id => $h) {
printf("%-6s %12s %12s %8s %8s %8s %s\n", $id,
number_format($h['requests'] ?? 0), vv_npm_bytes($h['bytes'] ?? 0),
number_format($h['s2xx'] ?? 0), number_format($h['s4xx'] ?? 0),
number_format($h['s5xx'] ?? 0),
!empty($h['last_seen']) ? date('Y-m-d H:i', $h['last_seen']) : '-');
}
exit(0);
}
// Decimal, because this is a quantity of traffic and every network tool that will be compared
// against it is decimal too. Memory is the binary one.
function vv_npm_bytes(float $b): string {
$u = ['B', 'kB', 'MB', 'GB', 'TB'];
$i = 0;
while ($b >= 1000 && $i < count($u) - 1) { $b /= 1000; $i++; }
return round($b, $b < 10 && $i ? 1 : 0) . ' ' . $u[$i];
}
$lock = @fopen(sys_get_temp_dir() . '/vv_npm_access.lock', 'c');
if ($lock === false || !flock($lock, LOCK_EX | LOCK_NB)) { echo "another pass is running\n"; exit(0); }
try {
$dir = vv_npm_log_dir();
if ($dir === '') { echo "NPM log directory not found — set NPM_LOG_DIR\n"; exit(0); }
$store = $reset ? ['hosts' => []] : vv_npm_stats_read();
if (!$store) { echo "npm_access.json is malformed — refusing to overwrite it\n"; exit(1); }
$hosts = $store['hosts'] ?? [];
$files = glob("$dir/proxy-host-*_access.log") ?: [];
if (!$files) { echo "no per-host access logs in $dir\n"; exit(0); }
$t0 = microtime(true);
$readTotal = 0; $newLines = 0;
$skipped = 0;
foreach ($files as $f) {
if (!preg_match('/proxy-host-(\d+)_access\.log$/', $f, $m)) continue;
$id = (string) (int) $m[1];
$size = @filesize($f);
if ($size === false) continue;
$h = $hosts[$id] ?? ['requests' => 0, 'bytes' => 0, 's2xx' => 0, 's3xx' => 0,
's4xx' => 0, 's5xx' => 0, 'offset' => 0, 'last_seen' => null,
'rotations' => 0, 'since' => time()];
$off = (int) ($h['offset'] ?? 0);
// Smaller than where we stopped means the file was rotated out from under us. Counters are
// cumulative and stay; only the offset resets, and the rotation is counted so the store can
// say the totals have a gap in them.
if ($size < $off) { $off = 0; $h['rotations'] = ($h['rotations'] ?? 0) + 1; }
if ($size === $off) { $hosts[$id] = $h; continue; }
$fp = @fopen($f, 'rb');
if (!$fp) { $hosts[$id] = $h; continue; }
@fseek($fp, $off);
$budget = VV_NPM_MAX_BYTES;
$read = 0;
$lastCompleteOffset = $off;
while (!feof($fp) && $budget > 0) {
$line = fgets($fp, 8192);
if ($line === false) break;
$len = strlen($line);
$budget -= $len;
$read += $len;
// No trailing newline means nginx is mid-write. Stop and leave the offset before it.
if (substr($line, -1) !== "\n") break;
$lastCompleteOffset += $len;
// Varaverk's own uptime probe, dropped before it is counted. It hits every host once a
// minute — fifty thousand requests a day — so counting it would make this monitor the
// overwhelming majority of the traffic it reports, and a host nobody visits would look
// as busy as one that is genuinely used. The string is VV_UPTIME_UA in uptime_probe.php
// and the two must stay in step.
if (strpos($line, 'Varaverk-Uptime/') !== false) { $skipped++; continue; }
// [09/Aug/2026:05:52:08 +0000] - 200 200 - GET https host "/" [Client 1.2.3.4] [Length 567] ...
if (!preg_match('/^\[([^\]]+)\]\s+\S+\s+(\d{3})/', $line, $lm)) continue;
$code = (int) $lm[2];
$h['requests']++;
$newLines++;
if ($code >= 500) $h['s5xx']++;
elseif ($code >= 400) $h['s4xx']++;
elseif ($code >= 300) $h['s3xx']++;
elseif ($code >= 200) $h['s2xx']++;
if (preg_match('/\[Length (\d+)\]/', $line, $bm)) $h['bytes'] += (int) $bm[1];
// The log stamp is nginx's own format; a line that will not parse is not worth a
// guessed timestamp, so last_seen simply does not move for it.
$ts = strtotime(str_replace('/', ' ', preg_replace('/^(\d+)\/(\w+)\/(\d+):/', '$1 $2 $3 ', $lm[1])));
if ($ts !== false && ($h['last_seen'] === null || $ts > $h['last_seen'])) $h['last_seen'] = $ts;
}
fclose($fp);
$h['offset'] = $lastCompleteOffset;
$hosts[$id] = $h;
$readTotal += $read;
}
ksort($hosts, SORT_NUMERIC);
$store['hosts'] = $hosts;
$store['last_pass'] = time();
printf("%d logs, read %s this pass, %s new requests, %s own probes ignored, %.1fs\n",
count($files), vv_npm_bytes($readTotal), number_format($newLines),
number_format($skipped), microtime(true) - $t0);
if ($dryRun) { echo "dry run — nothing written\n"; exit(0); }
if (!vv_npm_stats_write($store)) { echo 'could not write ' . vv_npm_stats_path() . "\n"; exit(1); }
echo 'wrote ' . vv_npm_stats_path() . "\n";
exit(0);
} finally {
flock($lock, LOCK_UN);
fclose($lock);
}