The old card totalled rsync's own logs, so it reported 'no data moved' across a link that had carried hundreds of gigabytes over SSH, the arr APIs, conf pushes and the Unraid API.
117 lines
5.2 KiB
PHP
117 lines
5.2 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
|
|
//
|
|
// 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);
|
|
}
|
|
}
|