177 lines
5.4 KiB
PHP
177 lines
5.4 KiB
PHP
<?php
|
|
|
|
namespace App\Livewire\Wireguard;
|
|
|
|
use App\Models\AuditEvent;
|
|
use App\Services\WgBridge;
|
|
use App\Services\WgStatus;
|
|
use App\Services\WgTraffic;
|
|
use App\Support\Confirm\ConfirmToken;
|
|
use App\Support\Confirm\InvalidConfirmToken;
|
|
use Illuminate\Support\Facades\Auth;
|
|
use Illuminate\Support\Facades\RateLimiter;
|
|
use Livewire\Attributes\Layout;
|
|
use Livewire\Attributes\On;
|
|
use Livewire\Component;
|
|
|
|
/**
|
|
* WireGuard dashboard — live status (P1) + traffic (P2) + peer management (P3). Reads the
|
|
* host-collected status; mutations go through the host write-bridge (WgBridge), never a shell.
|
|
*/
|
|
#[Layout('layouts.app')]
|
|
class Index extends Component
|
|
{
|
|
public int $window = 3600;
|
|
|
|
public const WINDOWS = [3600, 86400, 604800];
|
|
|
|
public string $newPeer = '';
|
|
|
|
/** id of an in-flight write-request we are polling for. */
|
|
public ?string $pendingId = null;
|
|
|
|
public ?string $pendingAction = null;
|
|
|
|
/** Show-once add-peer result (config text + QR svg). Never persisted. */
|
|
public ?string $resultConfig = null;
|
|
|
|
public ?string $resultQr = null;
|
|
|
|
public function setWindow(int $seconds): void
|
|
{
|
|
$this->window = in_array($seconds, self::WINDOWS, true) ? $seconds : 3600;
|
|
}
|
|
|
|
public function addPeer(WgBridge $bridge): void
|
|
{
|
|
$this->validate(['newPeer' => ['required', 'regex:/^[A-Za-z0-9._-]{1,64}$/']], [
|
|
'newPeer.regex' => __('wireguard.peer_name_invalid'),
|
|
'newPeer.required' => __('wireguard.peer_name_invalid'),
|
|
]);
|
|
if (! $this->throttle()) {
|
|
return;
|
|
}
|
|
$name = $this->newPeer;
|
|
$this->pendingId = $bridge->request('add-peer', ['name' => $name]);
|
|
$this->pendingAction = 'add-peer';
|
|
$this->newPeer = '';
|
|
$this->audit('wg.add-peer', $name);
|
|
}
|
|
|
|
/** Opens the wire-elements/modal confirm dialog for peer removal. */
|
|
public function confirmRemovePeer(string $name): void
|
|
{
|
|
if (preg_match('/^[A-Za-z0-9._-]{1,64}$/', $name) !== 1) {
|
|
return;
|
|
}
|
|
|
|
$this->dispatch('openModal',
|
|
component: 'modals.confirm-action',
|
|
arguments: [
|
|
'heading' => __('wireguard.remove_confirm_title'),
|
|
'body' => __('wireguard.remove_confirm_body'),
|
|
'confirmLabel' => __('wireguard.remove'),
|
|
'danger' => true,
|
|
'icon' => 'trash',
|
|
'notify' => '',
|
|
'token' => ConfirmToken::issue(
|
|
'wgPeerRemoved',
|
|
['name' => $name],
|
|
'wg.remove-peer',
|
|
$name,
|
|
),
|
|
],
|
|
);
|
|
}
|
|
|
|
/** Apply handler — called after the ConfirmAction modal confirms the removal. */
|
|
#[On('wgPeerRemoved')]
|
|
public function applyRemovePeer(string $confirmToken, WgBridge $bridge): void
|
|
{
|
|
try {
|
|
$payload = ConfirmToken::consume($confirmToken, 'wgPeerRemoved');
|
|
} catch (InvalidConfirmToken) {
|
|
return;
|
|
}
|
|
|
|
$name = $payload['params']['name'] ?? '';
|
|
if ($name === '' || preg_match('/^[A-Za-z0-9._-]{1,64}$/', $name) !== 1) {
|
|
return;
|
|
}
|
|
|
|
$this->removePeer($bridge, $name);
|
|
}
|
|
|
|
public function removePeer(WgBridge $bridge, string $name): void
|
|
{
|
|
if (preg_match('/^[A-Za-z0-9._-]{1,64}$/', $name) !== 1 || ! $this->throttle()) {
|
|
return;
|
|
}
|
|
$this->pendingId = $bridge->request('remove-peer', ['name' => $name]);
|
|
$this->pendingAction = 'remove-peer';
|
|
$this->audit('wg.remove-peer', $name);
|
|
}
|
|
|
|
public function pollResult(WgBridge $bridge): void
|
|
{
|
|
if ($this->pendingId === null) {
|
|
return;
|
|
}
|
|
$res = $bridge->result($this->pendingId);
|
|
if ($res === null) {
|
|
return;
|
|
}
|
|
$this->pendingId = null;
|
|
if ($res['ok'] && $this->pendingAction === 'add-peer' && $res['config'] !== null) {
|
|
$this->resultConfig = $res['config'];
|
|
$this->resultQr = $res['qr'];
|
|
} elseif (! $res['ok']) {
|
|
$this->dispatch('notify', message: __('wireguard.action_failed'), level: 'error');
|
|
} else {
|
|
$this->dispatch('notify', message: __('wireguard.action_done'));
|
|
}
|
|
$this->pendingAction = null;
|
|
}
|
|
|
|
public function dismissResult(): void
|
|
{
|
|
$this->resultConfig = null;
|
|
$this->resultQr = null;
|
|
}
|
|
|
|
private function throttle(): bool
|
|
{
|
|
$key = 'wg-request:'.Auth::id();
|
|
if (RateLimiter::tooManyAttempts($key, 10)) {
|
|
$this->dispatch('notify', message: __('wireguard.throttled'), level: 'error');
|
|
|
|
return false;
|
|
}
|
|
RateLimiter::hit($key, 60);
|
|
|
|
return true;
|
|
}
|
|
|
|
private function audit(string $action, string $target): void
|
|
{
|
|
AuditEvent::create([
|
|
'user_id' => Auth::id(),
|
|
'actor' => Auth::user()?->name ?? 'system',
|
|
'action' => $action,
|
|
'target' => $target,
|
|
'ip' => request()->ip(),
|
|
]);
|
|
}
|
|
|
|
public function render(WgStatus $wg, WgTraffic $traffic)
|
|
{
|
|
$window = in_array($this->window, self::WINDOWS, true) ? $this->window : 3600;
|
|
|
|
return view('livewire.wireguard.index', [
|
|
'status' => $wg->read(),
|
|
'traffic' => $traffic->series($window),
|
|
'windows' => self::WINDOWS,
|
|
])->title(__('wireguard.title'));
|
|
}
|
|
}
|