feat(wg): clusev:wg-sample command (sample peers + prune) + schedule
parent
ae4937654d
commit
9e6f3775b7
|
|
@ -0,0 +1,41 @@
|
||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Console\Commands;
|
||||||
|
|
||||||
|
use App\Models\WgTrafficSample;
|
||||||
|
use App\Services\WgStatus;
|
||||||
|
use Illuminate\Console\Command;
|
||||||
|
|
||||||
|
class WgSample extends Command
|
||||||
|
{
|
||||||
|
protected $signature = 'clusev:wg-sample {--retention=7 : Days of history to keep}';
|
||||||
|
|
||||||
|
protected $description = 'Persist a WireGuard traffic sample per peer (and prune old samples)';
|
||||||
|
|
||||||
|
public function handle(WgStatus $wg): int
|
||||||
|
{
|
||||||
|
$status = $wg->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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -12,3 +12,6 @@ Artisan::command('inspire', function () {
|
||||||
// `audit_retention_days` is empty/0 (keep forever); otherwise it deletes
|
// `audit_retention_days` is empty/0 (keep forever); otherwise it deletes
|
||||||
// audit_events older than the configured number of days.
|
// audit_events older than the configured number of days.
|
||||||
Schedule::command('clusev:prune-audit')->daily();
|
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();
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,70 @@
|
||||||
|
<?php
|
||||||
|
|
||||||
|
namespace Tests\Feature;
|
||||||
|
|
||||||
|
use App\Models\WgTrafficSample;
|
||||||
|
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||||
|
use Tests\TestCase;
|
||||||
|
|
||||||
|
class WgSampleCommandTest extends TestCase
|
||||||
|
{
|
||||||
|
use RefreshDatabase;
|
||||||
|
|
||||||
|
private string $file;
|
||||||
|
|
||||||
|
protected function setUp(): void
|
||||||
|
{
|
||||||
|
parent::setUp();
|
||||||
|
$dir = storage_path('app/restart-signal');
|
||||||
|
@mkdir($dir, 0775, true);
|
||||||
|
$this->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']);
|
||||||
|
}
|
||||||
|
}
|
||||||
Loading…
Reference in New Issue