From 9b835e9dbe8f37fdd2e190f4d7a278ace194b16f Mon Sep 17 00:00:00 2001 From: boban Date: Sat, 20 Jun 2026 23:32:44 +0200 Subject: [PATCH] feat(wg): WgStatus reader + auth-gated /wg-status.json route --- app/Services/WgStatus.php | 78 ++++++++++++++++++++++++++++++++++ routes/web.php | 2 + tests/Feature/WgStatusTest.php | 76 +++++++++++++++++++++++++++++++++ 3 files changed, 156 insertions(+) create mode 100644 app/Services/WgStatus.php create mode 100644 tests/Feature/WgStatusTest.php diff --git a/app/Services/WgStatus.php b/app/Services/WgStatus.php new file mode 100644 index 0000000..73e12d1 --- /dev/null +++ b/app/Services/WgStatus.php @@ -0,0 +1,78 @@ +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, + ]; + } +} diff --git a/routes/web.php b/routes/web.php index 9a566b1..ee2ab78 100644 --- a/routes/web.php +++ b/routes/web.php @@ -132,6 +132,8 @@ Route::middleware('auth')->group(function () { Route::get('/system', System\Index::class)->name('system'); 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 // container teardown that follows an update request (R1/R2 exception, by design). Route::get('/update-progress', function (Request $request) { diff --git a/tests/Feature/WgStatusTest.php b/tests/Feature/WgStatusTest.php new file mode 100644 index 0000000..4d2ab4b --- /dev/null +++ b/tests/Feature/WgStatusTest.php @@ -0,0 +1,76 @@ +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]); + } +}