feat(wg): WgStatus reader + auth-gated /wg-status.json route
parent
9aabd7fce5
commit
9b835e9dbe
|
|
@ -0,0 +1,78 @@
|
||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Services;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Reads + validates the host-written status file run/wg-status.json (bind-mounted to
|
||||||
|
* storage/app/restart-signal). Single source for the WireGuard page + the /wg-status.json route.
|
||||||
|
* Contains NO secrets — the collector never writes a private key.
|
||||||
|
*/
|
||||||
|
class WgStatus
|
||||||
|
{
|
||||||
|
/** Status older than this (seconds) means the host collector is offline. */
|
||||||
|
private const STALE_AFTER = 20;
|
||||||
|
|
||||||
|
/** A peer with a handshake within this many seconds is considered online. */
|
||||||
|
private const ONLINE_WITHIN = 180;
|
||||||
|
|
||||||
|
private function path(): string
|
||||||
|
{
|
||||||
|
return storage_path('app/restart-signal/wg-status.json');
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @return array{configured:bool, at:int, stale:bool, gate?:array, wg_quick?:string, server?:array, peers?:array}
|
||||||
|
*/
|
||||||
|
public function read(): array
|
||||||
|
{
|
||||||
|
$path = $this->path();
|
||||||
|
$data = is_file($path) ? json_decode((string) @file_get_contents($path), true) : null;
|
||||||
|
|
||||||
|
if (! is_array($data)) {
|
||||||
|
return ['configured' => false, 'at' => 0, 'stale' => true];
|
||||||
|
}
|
||||||
|
|
||||||
|
$at = (int) ($data['at'] ?? 0);
|
||||||
|
$stale = $at < (time() - self::STALE_AFTER);
|
||||||
|
|
||||||
|
if (empty($data['configured'])) {
|
||||||
|
return ['configured' => false, 'at' => $at, 'stale' => $stale];
|
||||||
|
}
|
||||||
|
|
||||||
|
$peers = [];
|
||||||
|
foreach ((array) ($data['peers'] ?? []) as $p) {
|
||||||
|
if (! is_array($p)) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
$hs = (int) ($p['latest_handshake'] ?? 0);
|
||||||
|
$peers[] = [
|
||||||
|
'name' => (string) ($p['name'] ?? ''),
|
||||||
|
'pubkey' => (string) ($p['pubkey'] ?? ''),
|
||||||
|
'endpoint' => (string) ($p['endpoint'] ?? ''),
|
||||||
|
'latest_handshake' => $hs,
|
||||||
|
'rx' => (int) ($p['rx'] ?? 0),
|
||||||
|
'tx' => (int) ($p['tx'] ?? 0),
|
||||||
|
'online' => $hs > (time() - self::ONLINE_WITHIN),
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
return [
|
||||||
|
'configured' => true,
|
||||||
|
'at' => $at,
|
||||||
|
'stale' => $stale,
|
||||||
|
'gate' => [
|
||||||
|
'marker' => (bool) ($data['gate']['marker'] ?? false),
|
||||||
|
'chain' => (bool) ($data['gate']['chain'] ?? false),
|
||||||
|
],
|
||||||
|
'wg_quick' => (string) ($data['wg_quick'] ?? 'inactive'),
|
||||||
|
'server' => [
|
||||||
|
'subnet' => (string) ($data['server']['subnet'] ?? ''),
|
||||||
|
'server_ip' => (string) ($data['server']['server_ip'] ?? ''),
|
||||||
|
'port' => (int) ($data['server']['port'] ?? 0),
|
||||||
|
'endpoint' => (string) ($data['server']['endpoint'] ?? ''),
|
||||||
|
'pubkey' => (string) ($data['server']['pubkey'] ?? ''),
|
||||||
|
],
|
||||||
|
'peers' => $peers,
|
||||||
|
];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -132,6 +132,8 @@ Route::middleware('auth')->group(function () {
|
||||||
Route::get('/system', System\Index::class)->name('system');
|
Route::get('/system', System\Index::class)->name('system');
|
||||||
Route::get('/help', Help\Index::class)->name('help');
|
Route::get('/help', Help\Index::class)->name('help');
|
||||||
|
|
||||||
|
Route::get('/wg-status.json', fn (\App\Services\WgStatus $wg) => response()->json($wg->read()))->name('wg.status');
|
||||||
|
|
||||||
// Plain Blade view — deliberately NOT a Livewire component so it survives the
|
// Plain Blade view — deliberately NOT a Livewire component so it survives the
|
||||||
// container teardown that follows an update request (R1/R2 exception, by design).
|
// container teardown that follows an update request (R1/R2 exception, by design).
|
||||||
Route::get('/update-progress', function (Request $request) {
|
Route::get('/update-progress', function (Request $request) {
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,76 @@
|
||||||
|
<?php
|
||||||
|
|
||||||
|
namespace Tests\Feature;
|
||||||
|
|
||||||
|
use App\Models\User;
|
||||||
|
use App\Services\WgStatus;
|
||||||
|
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||||
|
use Tests\TestCase;
|
||||||
|
|
||||||
|
class WgStatusTest 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();
|
||||||
|
}
|
||||||
|
|
||||||
|
public function test_absent_file_reads_as_unconfigured(): void
|
||||||
|
{
|
||||||
|
$s = app(WgStatus::class)->read();
|
||||||
|
$this->assertFalse($s['configured']);
|
||||||
|
$this->assertTrue($s['stale']);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function test_parses_a_configured_status_with_peers(): void
|
||||||
|
{
|
||||||
|
file_put_contents($this->file, json_encode([
|
||||||
|
'at' => time(),
|
||||||
|
'configured' => true,
|
||||||
|
'gate' => ['marker' => true, 'chain' => true],
|
||||||
|
'wg_quick' => 'active',
|
||||||
|
'server' => ['subnet' => '10.99.0.0/24', 'server_ip' => '10.99.0.1', 'port' => 51820, 'endpoint' => '1.2.3.4:51820', 'pubkey' => 'srvpub'],
|
||||||
|
'peers' => [
|
||||||
|
['name' => 'laptop', 'pubkey' => 'p1', 'endpoint' => '5.6.7.8:41820', 'latest_handshake' => time(), 'rx' => 100, 'tx' => 200],
|
||||||
|
['name' => 'phone', 'pubkey' => 'p2', 'endpoint' => '', 'latest_handshake' => 0, 'rx' => 0, 'tx' => 0],
|
||||||
|
],
|
||||||
|
]));
|
||||||
|
|
||||||
|
$s = app(WgStatus::class)->read();
|
||||||
|
$this->assertTrue($s['configured']);
|
||||||
|
$this->assertFalse($s['stale']);
|
||||||
|
$this->assertTrue($s['gate']['marker']);
|
||||||
|
$this->assertSame(51820, $s['server']['port']);
|
||||||
|
$this->assertCount(2, $s['peers']);
|
||||||
|
$this->assertTrue($s['peers'][0]['online']);
|
||||||
|
$this->assertFalse($s['peers'][1]['online']);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function test_stale_when_at_is_old(): void
|
||||||
|
{
|
||||||
|
file_put_contents($this->file, json_encode(['at' => time() - 999, 'configured' => true, 'peers' => []]));
|
||||||
|
$this->assertTrue(app(WgStatus::class)->read()['stale']);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function test_route_requires_auth_and_returns_status(): void
|
||||||
|
{
|
||||||
|
$this->get('/wg-status.json')->assertRedirect('/login');
|
||||||
|
|
||||||
|
file_put_contents($this->file, json_encode(['at' => time(), 'configured' => false]));
|
||||||
|
$this->actingAs(User::factory()->create(['must_change_password' => false]))
|
||||||
|
->getJson('/wg-status.json')->assertOk()->assertJson(['configured' => false]);
|
||||||
|
}
|
||||||
|
}
|
||||||
Loading…
Reference in New Issue