diff --git a/app/Console/Commands/WgSample.php b/app/Console/Commands/WgSample.php new file mode 100644 index 0000000..ee6b696 --- /dev/null +++ b/app/Console/Commands/WgSample.php @@ -0,0 +1,41 @@ +read(); + + if (($status['configured'] ?? false) && ! ($status['stale'] ?? true)) { + $now = now(); + $rows = []; + foreach ($status['peers'] ?? [] as $peer) { + $rows[] = [ + 'peer_pubkey' => (string) ($peer['pubkey'] ?? ''), + 'peer_name' => ($peer['name'] ?? '') !== '' ? (string) $peer['name'] : null, + 'rx' => (int) ($peer['rx'] ?? 0), + 'tx' => (int) ($peer['tx'] ?? 0), + 'sampled_at' => $now, + ]; + } + if ($rows !== []) { + WgTrafficSample::insert($rows); + } + } + + $retention = max(1, (int) $this->option('retention')); + WgTrafficSample::where('sampled_at', '<', now()->subDays($retention))->delete(); + + return self::SUCCESS; + } +} diff --git a/routes/console.php b/routes/console.php index 1122c93..8e1d432 100644 --- a/routes/console.php +++ b/routes/console.php @@ -12,3 +12,6 @@ Artisan::command('inspire', function () { // `audit_retention_days` is empty/0 (keep forever); otherwise it deletes // audit_events older than the configured number of days. Schedule::command('clusev:prune-audit')->daily(); + +// Sample WireGuard peer traffic every minute (no-op when the collector is unconfigured/stale). +Schedule::command('clusev:wg-sample')->everyMinute(); diff --git a/tests/Feature/WgSampleCommandTest.php b/tests/Feature/WgSampleCommandTest.php new file mode 100644 index 0000000..d44cee4 --- /dev/null +++ b/tests/Feature/WgSampleCommandTest.php @@ -0,0 +1,70 @@ +file = $dir.'/wg-status.json'; + @unlink($this->file); + } + + protected function tearDown(): void + { + @unlink($this->file); + parent::tearDown(); + } + + private function writeStatus(array $peers, bool $configured = true): void + { + file_put_contents($this->file, json_encode([ + 'at' => time(), 'configured' => $configured, + 'gate' => ['marker' => false, 'chain' => false], 'wg_quick' => 'active', + 'server' => ['subnet' => '10.99.0.0/24', 'server_ip' => '10.99.0.1', 'port' => 51820, 'endpoint' => 'x:1', 'pubkey' => 'k'], + 'peers' => $peers, + ])); + } + + public function test_stores_one_row_per_peer_when_configured(): void + { + $this->writeStatus([ + ['name' => 'laptop', 'pubkey' => 'p1', 'endpoint' => '', 'latest_handshake' => time(), 'rx' => 100, 'tx' => 200], + ['name' => 'phone', 'pubkey' => 'p2', 'endpoint' => '', 'latest_handshake' => time(), 'rx' => 5, 'tx' => 6], + ]); + + $this->artisan('clusev:wg-sample')->assertExitCode(0); + + $this->assertSame(2, WgTrafficSample::count()); + $this->assertDatabaseHas('wg_traffic_samples', ['peer_pubkey' => 'p1', 'rx' => 100, 'tx' => 200]); + } + + public function test_does_nothing_when_unconfigured_or_stale(): void + { + @unlink($this->file); + $this->artisan('clusev:wg-sample')->assertExitCode(0); + $this->assertSame(0, WgTrafficSample::count()); + } + + public function test_prunes_samples_older_than_retention(): void + { + WgTrafficSample::create(['peer_pubkey' => 'old', 'peer_name' => 'x', 'rx' => 1, 'tx' => 1, 'sampled_at' => now()->subDays(8)]); + $this->writeStatus([['name' => 'a', 'pubkey' => 'p1', 'endpoint' => '', 'latest_handshake' => time(), 'rx' => 1, 'tx' => 1]]); + + $this->artisan('clusev:wg-sample')->assertExitCode(0); + + $this->assertDatabaseMissing('wg_traffic_samples', ['peer_pubkey' => 'old']); + $this->assertDatabaseHas('wg_traffic_samples', ['peer_pubkey' => 'p1']); + } +}