Files
Varaverk/Plugin/unraid/Tools/mesh_traffic_sample.php
T

128 lines
5.9 KiB
PHP

<?php
// ═══════════════════════════════════════════════════════════════════════════════════════════════
// PURPOSE
// Sample per-peer Tailscale byte counters, once a minute, so the Partnership page can report
// how much has actually moved between the servers in this mesh over a window.
//
// OPERATIONAL MODEL
// Appends one line per mesh peer to data/db/mesh_traffic.db:
//
// epoch|peer-hostname|txbytes|rxbytes
//
// Called from Tools/api_cache_writer.sh, which already runs every minute. A window total is
// then the difference between the newest sample and the oldest one still inside that window.
//
// DESIGN PRINCIPLES
// Measure the link, not the tool.
// The previous card totalled rsync's own logs, so it could only ever describe rsync — and
// said "no data moved" while SSH, the arr APIs, conf pushes and the Unraid API were all
// using the same link. Tailscale counts the bytes on the wire, whatever sent them.
//
// Mesh peers only.
// The tailnet holds phones and workstations. Filtered against the HOST* hostnames in
// master.conf so this measures the partnership, not the tailnet.
//
// Absolute counters are stored, never deltas.
// A delta computed at write time bakes in whatever the sampling interval happened to be
// and cannot be re-derived if a run is missed. Storing the raw counter means a gap costs
// resolution, not correctness.
//
// OPERATIONAL SAFEGUARDS
// Counter resets are the reader's problem, not this file's — tailscaled restarting returns
// the counters to zero, and a sampler that tried to compensate would have to guess when.
// Storing raw values leaves the evidence intact: a sample lower than the one before it is a
// restart, and it is unambiguous.
//
// Trimmed to VV_MESH_KEEP_DAYS on every run, so the file cannot grow without bound. At one
// sample per peer per minute that is ~1,440 lines/peer/day.
//
// Silent no-op when tailscale is absent or returns nothing parseable. This runs every minute
// from a cache writer; a mesh sampler is not worth a log line per minute when it has nothing
// to add.
//
// RUNTIME MODES
// php mesh_traffic_sample.php append one sample per mesh peer
// php mesh_traffic_sample.php --show print what it would record, write nothing
//
// CONFIGURATION
// master.conf
// HOST1, HOST2, … the hostname of each node in the mesh. Every HOST<n> with a non-empty
// value becomes a name matched against tailscale's peer list; a peer that
// is not one of them is skipped, so a device that merely shares the
// tailnet is never recorded as mesh traffic. No HOST<n> set at all means
// there is no mesh to sample and the run exits without writing.
//
// Nothing is read from host*.conf. This samples the local tailscale daemon, so it needs to
// know which peers count and nothing about how to reach them.
//
// DEPENDS ON
// tailscale status --json the counters
// include/config.php DATA_DIR, vv_conf_vars() for the HOST* list
// ═══════════════════════════════════════════════════════════════════════════════════════════════
require_once dirname(__DIR__) . '/include/config.php';
define('VV_MESH_DB', DATA_DIR . '/db/mesh_traffic.db');
define('VV_MESH_KEEP_DAYS', 40); // a 30-day window needs a sample older than 30 days
$show = in_array('--show', $argv ?? [], true);
$raw = shell_exec('tailscale status --json 2>/dev/null');
$js = json_decode((string)$raw, true);
if (!is_array($js) || empty($js['Peer'])) exit(0);
// The hostnames this mesh is made of. Anything else on the tailnet is somebody's laptop.
$vars = vv_conf_vars();
$mesh = [];
foreach ($vars as $k => $v) {
if (preg_match('/^HOST\d+$/', $k) && trim((string)$v) !== '') $mesh[strtolower(trim($v))] = true;
}
if (!$mesh) exit(0);
$now = time();
$lines = [];
foreach ($js['Peer'] as $peer) {
$host = strtolower(trim((string)($peer['HostName'] ?? '')));
if ($host === '') continue;
// Same unambiguous-prefix rule the rest of the partnership layer uses: the tailnet name and
// the conf hostname are different strings and nothing keeps them in step.
$match = null;
if (isset($mesh[$host])) {
$match = $host;
} else {
$cand = [];
foreach (array_keys($mesh) as $m) {
if (str_starts_with($host, $m) || str_starts_with($m, $host)) $cand[] = $m;
}
if (count($cand) === 1) $match = $cand[0];
}
if ($match === null) continue;
$tx = (int)($peer['TxBytes'] ?? 0);
$rx = (int)($peer['RxBytes'] ?? 0);
if ($tx === 0 && $rx === 0) continue;
$lines[] = $now . '|' . $match . '|' . $tx . '|' . $rx;
}
if (!$lines) exit(0);
if ($show) { echo implode("\n", $lines) . "\n"; exit(0); }
@mkdir(dirname(VV_MESH_DB), 0755, true);
@file_put_contents(VV_MESH_DB, implode("\n", $lines) . "\n", FILE_APPEND | LOCK_EX);
// Trim in place. Read-filter-rewrite rather than append-only truncation, because the cut is by
// age and the file is not ordered by peer.
$cutoff = $now - (VV_MESH_KEEP_DAYS * 86400);
$all = @file(VV_MESH_DB, FILE_IGNORE_NEW_LINES | FILE_SKIP_EMPTY_LINES) ?: [];
if (count($all) > 200) {
$keep = [];
foreach ($all as $l) {
$ts = (int)strtok($l, '|');
if ($ts >= $cutoff) $keep[] = $l;
}
if (count($keep) !== count($all)) {
$tmp = VV_MESH_DB . '.tmp';
if (@file_put_contents($tmp, implode("\n", $keep) . "\n") !== false) @rename($tmp, VV_MESH_DB);
}
}