feat(admin): VPN access management with live peer state
The console can now create, block and remove VPN accesses, and shows who is actually connected — traffic counters, source endpoint and last handshake. Design notes: - The web container has no wg0, so the page never talks to WireGuard. Live state is copied in by SyncVpnPeers on the provisioning queue (the only worker whose container owns the interface); the page polls the database and merely nudges that job, throttled so many open tabs cannot flood the queue. - enabled (operator intent) and present (observed on the hub) are stored separately, so a sync can never quietly undo a block and drift stays visible as "wird angewendet". - The sync adopts peers the host pipeline registered, naming them after their host — otherwise "who is on the VPN" would have blind spots. - Keypairs are generated in PHP via libsodium rather than shelling out to wg genkey (no interface in this container, and it keeps generation testable). The private key is shown once and never stored. - allocateIp() now also considers vpn_peers, which would otherwise be handed a tunnel address a host already holds. - vpn.manage is a new capability held by Owner/Admin only, and it hides the nav entry too — a VPN access reaches the management network. Verified end to end against the real hub, not just mocks: created an access in the browser, connected with the generated config, watched the console flip to "Verbunden" with real counters, then blocked it and confirmed the peer was gone from wg0 and the client could no longer handshake. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>feat/portal-design
parent
a5a261fa37
commit
71ca0e1394
|
|
@ -0,0 +1,51 @@
|
|||
<?php
|
||||
|
||||
namespace App\Livewire\Admin;
|
||||
|
||||
use App\Models\VpnPeer;
|
||||
use App\Provisioning\Jobs\ApplyVpnPeer;
|
||||
use LivewireUI\Modal\ModalComponent;
|
||||
|
||||
/**
|
||||
* Confirmation for revoking a VPN access for good (R5). Deleting a peer that
|
||||
* belongs to a host cuts CluPilot's own management path to that machine, so the
|
||||
* modal says so plainly rather than hiding the option.
|
||||
*/
|
||||
class ConfirmDeleteVpnPeer extends ModalComponent
|
||||
{
|
||||
public string $uuid;
|
||||
|
||||
public string $name = '';
|
||||
|
||||
public ?string $hostName = null;
|
||||
|
||||
public function mount(string $uuid): void
|
||||
{
|
||||
$this->authorize('vpn.manage'); // modals are reachable without the route middleware
|
||||
$peer = VpnPeer::query()->with('host')->where('uuid', $uuid)->firstOrFail();
|
||||
$this->uuid = $uuid;
|
||||
$this->name = $peer->name;
|
||||
$this->hostName = $peer->host?->name;
|
||||
}
|
||||
|
||||
public function delete(): void
|
||||
{
|
||||
$this->authorize('vpn.manage');
|
||||
|
||||
$peer = VpnPeer::query()->where('uuid', $this->uuid)->first();
|
||||
if ($peer !== null) {
|
||||
// Remove from the hub first: if the row went first and the job then
|
||||
// failed, the peer would keep its tunnel with nothing left to show it.
|
||||
ApplyVpnPeer::dispatch($peer->public_key, null, false);
|
||||
$peer->delete();
|
||||
}
|
||||
|
||||
$this->dispatch('vpn-peer-deleted');
|
||||
$this->closeModal();
|
||||
}
|
||||
|
||||
public function render()
|
||||
{
|
||||
return view('livewire.admin.confirm-delete-vpn-peer');
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,161 @@
|
|||
<?php
|
||||
|
||||
namespace App\Livewire\Admin;
|
||||
|
||||
use App\Models\VpnPeer;
|
||||
use App\Provisioning\Jobs\ApplyVpnPeer;
|
||||
use App\Provisioning\Jobs\SyncVpnPeers;
|
||||
use App\Services\Wireguard\Keypair;
|
||||
use App\Services\Wireguard\WireguardHub;
|
||||
use Illuminate\Database\QueryException;
|
||||
use Illuminate\Support\Facades\Cache;
|
||||
use Livewire\Attributes\Layout;
|
||||
use Livewire\Attributes\On;
|
||||
use Livewire\Component;
|
||||
|
||||
/**
|
||||
* VPN access management. The console container has no wg0, so live state is
|
||||
* read from the database and refreshed by SyncVpnPeers on the provisioning
|
||||
* queue — the poll below only nudges that job, it never talks to WireGuard.
|
||||
*/
|
||||
#[Layout('layouts.admin')]
|
||||
class Vpn extends Component
|
||||
{
|
||||
public string $name = '';
|
||||
|
||||
/** Optional: a peer that generated its own key never hands us the private half. */
|
||||
public string $publicKey = '';
|
||||
|
||||
/** Shown once, right after creation — the private key is never stored. */
|
||||
public ?string $newConfig = null;
|
||||
|
||||
public ?string $newConfigName = null;
|
||||
|
||||
public function mount(): void
|
||||
{
|
||||
$this->authorize('vpn.manage');
|
||||
}
|
||||
|
||||
public function create(): void
|
||||
{
|
||||
$this->authorize('vpn.manage');
|
||||
|
||||
$this->validate([
|
||||
'name' => 'required|string|max:255',
|
||||
'publicKey' => 'nullable|string|max:64',
|
||||
]);
|
||||
|
||||
$ownKey = trim($this->publicKey) !== '';
|
||||
if ($ownKey && ! Keypair::isValidKey(trim($this->publicKey))) {
|
||||
$this->addError('publicKey', __('vpn.invalid_key'));
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
$keypair = $ownKey ? null : Keypair::generate();
|
||||
$publicKey = $ownKey ? trim($this->publicKey) : $keypair->publicKey;
|
||||
|
||||
if (VpnPeer::query()->where('public_key', $publicKey)->exists()) {
|
||||
$this->addError('publicKey', __('vpn.duplicate_key'));
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
$hub = app(WireguardHub::class);
|
||||
|
||||
// allocateIp reads the addresses already handed out; two operators
|
||||
// creating at the same moment can still pick the same one, so the unique
|
||||
// index is the real guard and we retry once against it.
|
||||
$peer = null;
|
||||
foreach (range(1, 3) as $attempt) {
|
||||
try {
|
||||
$peer = VpnPeer::create([
|
||||
'name' => trim($this->name),
|
||||
'public_key' => $publicKey,
|
||||
'allowed_ip' => $hub->allocateIp(),
|
||||
'enabled' => true,
|
||||
'present' => false,
|
||||
'created_by' => auth()->id(),
|
||||
]);
|
||||
break;
|
||||
} catch (QueryException $e) {
|
||||
if ($attempt === 3) {
|
||||
throw $e;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
ApplyVpnPeer::dispatch($peer->public_key, $peer->allowed_ip, true);
|
||||
|
||||
// Only a key we generated can be turned into a ready-to-use config.
|
||||
$this->newConfig = $keypair === null ? null : $this->clientConfig($keypair, $peer->allowed_ip);
|
||||
$this->newConfigName = $peer->name;
|
||||
|
||||
$this->reset('name', 'publicKey');
|
||||
$this->dispatch('notify', message: __('vpn.created'));
|
||||
}
|
||||
|
||||
public function toggle(string $uuid): void
|
||||
{
|
||||
$this->authorize('vpn.manage');
|
||||
|
||||
$peer = VpnPeer::query()->where('uuid', $uuid)->first();
|
||||
if ($peer === null) {
|
||||
return;
|
||||
}
|
||||
|
||||
$peer->update(['enabled' => ! $peer->enabled]);
|
||||
ApplyVpnPeer::dispatch($peer->public_key, $peer->allowed_ip, $peer->enabled);
|
||||
|
||||
$this->dispatch('notify', message: $peer->enabled ? __('vpn.unblocked') : __('vpn.blocked'));
|
||||
}
|
||||
|
||||
#[On('vpn-peer-deleted')]
|
||||
public function peerDeleted(): void
|
||||
{
|
||||
$this->dispatch('notify', message: __('vpn.deleted'));
|
||||
}
|
||||
|
||||
public function dismissConfig(): void
|
||||
{
|
||||
$this->reset('newConfig', 'newConfigName');
|
||||
}
|
||||
|
||||
/** Polled by the view; throttled so a room full of open tabs cannot flood the queue. */
|
||||
public function refreshPeers(): void
|
||||
{
|
||||
if (Cache::add('vpn:sync-dispatched', true, 8)) {
|
||||
SyncVpnPeers::dispatch();
|
||||
}
|
||||
}
|
||||
|
||||
private function clientConfig(Keypair $keypair, string $ip): string
|
||||
{
|
||||
$hub = app(WireguardHub::class);
|
||||
|
||||
return implode("\n", [
|
||||
'[Interface]',
|
||||
'PrivateKey = '.$keypair->privateKey,
|
||||
'Address = '.$ip.'/32',
|
||||
'',
|
||||
'[Peer]',
|
||||
'PublicKey = '.$hub->publicKey(),
|
||||
'Endpoint = '.$hub->endpoint(),
|
||||
'AllowedIPs = '.config('provisioning.wireguard.subnet', '10.66.0.0/24'),
|
||||
'PersistentKeepalive = 25',
|
||||
'',
|
||||
]);
|
||||
}
|
||||
|
||||
public function render()
|
||||
{
|
||||
$hub = app(WireguardHub::class);
|
||||
|
||||
return view('livewire.admin.vpn', [
|
||||
'peers' => VpnPeer::query()->with('host')->orderByDesc('present')->orderBy('name')->get(),
|
||||
'hubEndpoint' => $hub->endpoint(),
|
||||
'hubPublicKey' => $hub->publicKey(),
|
||||
'lastSync' => VpnPeer::query()->max('observed_at'),
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,74 @@
|
|||
<?php
|
||||
|
||||
namespace App\Models;
|
||||
|
||||
use Illuminate\Database\Eloquent\Concerns\HasUuids;
|
||||
use Illuminate\Database\Eloquent\Factories\HasFactory;
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
||||
|
||||
class VpnPeer extends Model
|
||||
{
|
||||
use HasFactory;
|
||||
use HasUuids;
|
||||
|
||||
/** A tunnel is considered live if the last handshake is recent. WireGuard
|
||||
* re-handshakes about every two minutes, so three is the first value that
|
||||
* does not flap for an idle-but-connected peer. */
|
||||
public const ONLINE_AFTER_MINUTES = 3;
|
||||
|
||||
protected $guarded = [];
|
||||
|
||||
protected function casts(): array
|
||||
{
|
||||
return [
|
||||
'enabled' => 'boolean',
|
||||
'present' => 'boolean',
|
||||
'last_handshake_at' => 'datetime',
|
||||
'observed_at' => 'datetime',
|
||||
'rx_bytes' => 'integer',
|
||||
'tx_bytes' => 'integer',
|
||||
];
|
||||
}
|
||||
|
||||
public function uniqueIds(): array
|
||||
{
|
||||
return ['uuid'];
|
||||
}
|
||||
|
||||
public function host(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(Host::class);
|
||||
}
|
||||
|
||||
public function creator(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(User::class, 'created_by');
|
||||
}
|
||||
|
||||
public function isOnline(): bool
|
||||
{
|
||||
return $this->enabled
|
||||
&& $this->present
|
||||
&& $this->last_handshake_at !== null
|
||||
&& $this->last_handshake_at->gt(now()->subMinutes(self::ONLINE_AFTER_MINUTES));
|
||||
}
|
||||
|
||||
/**
|
||||
* blocked — operator revoked it; it must not be on the hub
|
||||
* pending — desired and observed state disagree; a job is still applying it
|
||||
* online — configured and handshaking
|
||||
* idle — configured, but no recent handshake
|
||||
*/
|
||||
public function status(): string
|
||||
{
|
||||
if (! $this->enabled) {
|
||||
return $this->present ? 'pending' : 'blocked';
|
||||
}
|
||||
if (! $this->present) {
|
||||
return 'pending';
|
||||
}
|
||||
|
||||
return $this->isOnline() ? 'online' : 'idle';
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,54 @@
|
|||
<?php
|
||||
|
||||
namespace App\Provisioning\Jobs;
|
||||
|
||||
use App\Models\VpnPeer;
|
||||
use App\Services\Wireguard\WireguardHub;
|
||||
use Illuminate\Bus\Queueable;
|
||||
use Illuminate\Contracts\Queue\ShouldQueue;
|
||||
use Illuminate\Foundation\Bus\Dispatchable;
|
||||
use Illuminate\Queue\InteractsWithQueue;
|
||||
use Illuminate\Queue\SerializesModels;
|
||||
|
||||
/**
|
||||
* Push one peer's desired state onto the hub. Runs on the provisioning queue
|
||||
* because that is the only worker whose container owns wg0.
|
||||
*
|
||||
* Takes the public key rather than the model for the removal case: the row is
|
||||
* already gone by then, and the hub still has to be told.
|
||||
*/
|
||||
class ApplyVpnPeer implements ShouldQueue
|
||||
{
|
||||
use Dispatchable, InteractsWithQueue, Queueable, SerializesModels;
|
||||
|
||||
public int $tries = 3;
|
||||
|
||||
public function __construct(
|
||||
public string $publicKey,
|
||||
public ?string $allowedIp,
|
||||
public bool $enabled,
|
||||
) {
|
||||
$this->onQueue('provisioning');
|
||||
}
|
||||
|
||||
public static function for(VpnPeer $peer): self
|
||||
{
|
||||
return new self($peer->public_key, $peer->allowed_ip, $peer->enabled);
|
||||
}
|
||||
|
||||
public function handle(WireguardHub $hub): void
|
||||
{
|
||||
if ($this->enabled && $this->allowedIp !== null) {
|
||||
$hub->addPeer($this->publicKey, $this->allowedIp);
|
||||
} else {
|
||||
$hub->removePeer($this->publicKey);
|
||||
}
|
||||
|
||||
// Reflect the change immediately instead of waiting for the next sync,
|
||||
// so the console does not sit on "wird angewendet" for a whole minute.
|
||||
VpnPeer::query()->where('public_key', $this->publicKey)->update([
|
||||
'present' => $this->enabled,
|
||||
'observed_at' => now(),
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,90 @@
|
|||
<?php
|
||||
|
||||
namespace App\Provisioning\Jobs;
|
||||
|
||||
use App\Models\Host;
|
||||
use App\Models\VpnPeer;
|
||||
use App\Services\Wireguard\WireguardHub;
|
||||
use Illuminate\Bus\Queueable;
|
||||
use Illuminate\Contracts\Queue\ShouldBeUnique;
|
||||
use Illuminate\Contracts\Queue\ShouldQueue;
|
||||
use Illuminate\Foundation\Bus\Dispatchable;
|
||||
use Illuminate\Queue\InteractsWithQueue;
|
||||
use Illuminate\Queue\SerializesModels;
|
||||
|
||||
/**
|
||||
* Copy the hub's live peer state into the database so the console can show it.
|
||||
*
|
||||
* The console runs in the web container, which has no wg0 — only this worker
|
||||
* can read `wg show`. Everything the operator sees (handshakes, traffic, who is
|
||||
* connected) therefore travels through this job.
|
||||
*
|
||||
* Reconciles in both directions: peers found on the interface are upserted
|
||||
* (adopting host peers the provisioning pipeline registered, so the list has no
|
||||
* blind spots), and rows no longer on the interface are marked absent rather
|
||||
* than deleted — a missing peer is information, not a reason to forget it.
|
||||
*/
|
||||
class SyncVpnPeers implements ShouldBeUnique, ShouldQueue
|
||||
{
|
||||
use Dispatchable, InteractsWithQueue, Queueable, SerializesModels;
|
||||
|
||||
/** Long enough that a stuck job cannot block syncing forever. */
|
||||
public int $uniqueFor = 120;
|
||||
|
||||
public int $tries = 1;
|
||||
|
||||
public function __construct()
|
||||
{
|
||||
$this->onQueue('provisioning');
|
||||
}
|
||||
|
||||
public function handle(WireguardHub $hub): void
|
||||
{
|
||||
$snapshots = $hub->peers();
|
||||
$now = now();
|
||||
|
||||
// hosts.wg_pubkey => host id, so pipeline-created peers show up
|
||||
// under their host name instead of as an unknown key.
|
||||
$hostsByKey = Host::query()
|
||||
->whereNotNull('wg_pubkey')
|
||||
->pluck('id', 'wg_pubkey');
|
||||
|
||||
foreach ($snapshots as $publicKey => $snapshot) {
|
||||
$peer = VpnPeer::query()->where('public_key', $publicKey)->first();
|
||||
$hostId = $hostsByKey[$publicKey] ?? null;
|
||||
|
||||
$observed = [
|
||||
'present' => true,
|
||||
'endpoint' => $snapshot->endpoint,
|
||||
'last_handshake_at' => $snapshot->latestHandshake,
|
||||
'rx_bytes' => $snapshot->rxBytes,
|
||||
'tx_bytes' => $snapshot->txBytes,
|
||||
'observed_at' => $now,
|
||||
];
|
||||
|
||||
if ($peer === null) {
|
||||
VpnPeer::create($observed + [
|
||||
'public_key' => $publicKey,
|
||||
// Fall back to the dump's allowed-ips for peers that were
|
||||
// never created through the console.
|
||||
'allowed_ip' => strtok($snapshot->allowedIps, '/') ?: $snapshot->allowedIps,
|
||||
'host_id' => $hostId,
|
||||
'name' => $hostId !== null
|
||||
? (Host::find($hostId)?->name ?? __('vpn.unknown_peer'))
|
||||
: __('vpn.unknown_peer'),
|
||||
'enabled' => true,
|
||||
]);
|
||||
|
||||
continue;
|
||||
}
|
||||
|
||||
// Never overwrite `enabled`: that is the operator's intent, and a
|
||||
// peer being present only says the hub has not caught up yet.
|
||||
$peer->update($observed + ['host_id' => $peer->host_id ?? $hostId]);
|
||||
}
|
||||
|
||||
VpnPeer::query()
|
||||
->whereNotIn('public_key', array_keys($snapshots))
|
||||
->update(['present' => false, 'observed_at' => $now]);
|
||||
}
|
||||
}
|
||||
|
|
@ -13,6 +13,9 @@ class FakeWireguardHub implements WireguardHub
|
|||
|
||||
public string $publicKeyValue = 'HUBPUBLICKEY0000000000000000000000000000000=';
|
||||
|
||||
/** Snapshots the tests can shape; defaults to "configured, never handshaked". */
|
||||
public array $snapshots = [];
|
||||
|
||||
public function allocateIp(): string
|
||||
{
|
||||
return '10.66.0.'.$this->next++;
|
||||
|
|
@ -28,6 +31,23 @@ class FakeWireguardHub implements WireguardHub
|
|||
unset($this->peers[$publicKey]);
|
||||
}
|
||||
|
||||
public function peers(): array
|
||||
{
|
||||
$peers = [];
|
||||
foreach ($this->peers as $publicKey => $ip) {
|
||||
$peers[$publicKey] = $this->snapshots[$publicKey] ?? new PeerSnapshot(
|
||||
publicKey: $publicKey,
|
||||
endpoint: null,
|
||||
allowedIps: $ip.'/32',
|
||||
latestHandshake: null,
|
||||
rxBytes: 0,
|
||||
txBytes: 0,
|
||||
);
|
||||
}
|
||||
|
||||
return $peers;
|
||||
}
|
||||
|
||||
public function endpoint(): string
|
||||
{
|
||||
return $this->endpointValue;
|
||||
|
|
@ -38,8 +58,9 @@ class FakeWireguardHub implements WireguardHub
|
|||
return $this->publicKeyValue;
|
||||
}
|
||||
|
||||
/** @return array<string, string> */
|
||||
public function peers(): array
|
||||
/** Raw pubkey => ip map — what the tests assert on. Not the interface's
|
||||
* peers(), which returns live snapshots. */
|
||||
public function peerIps(): array
|
||||
{
|
||||
return $this->peers;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -0,0 +1,37 @@
|
|||
<?php
|
||||
|
||||
namespace App\Services\Wireguard;
|
||||
|
||||
/**
|
||||
* A WireGuard keypair. Generated in PHP via libsodium rather than by shelling
|
||||
* out to `wg genkey`: the console runs in the web container, which has no
|
||||
* wg0 interface, and this keeps key generation testable.
|
||||
*
|
||||
* WireGuard keys are Curve25519. The private key is 32 random bytes with the
|
||||
* standard clamping applied; the public key is its scalar multiplication with
|
||||
* the curve base point — exactly what `wg pubkey` computes.
|
||||
*/
|
||||
final readonly class Keypair
|
||||
{
|
||||
public function __construct(public string $privateKey, public string $publicKey) {}
|
||||
|
||||
public static function generate(): self
|
||||
{
|
||||
$bytes = random_bytes(32);
|
||||
$bytes[0] = chr(ord($bytes[0]) & 248);
|
||||
$bytes[31] = chr((ord($bytes[31]) & 127) | 64);
|
||||
|
||||
return new self(
|
||||
privateKey: base64_encode($bytes),
|
||||
publicKey: base64_encode(sodium_crypto_scalarmult_base($bytes)),
|
||||
);
|
||||
}
|
||||
|
||||
/** A base64-encoded 32-byte key, the only shape WireGuard accepts. */
|
||||
public static function isValidKey(string $key): bool
|
||||
{
|
||||
$raw = base64_decode($key, true);
|
||||
|
||||
return $raw !== false && strlen($raw) === 32 && base64_encode($raw) === $key;
|
||||
}
|
||||
}
|
||||
|
|
@ -3,6 +3,7 @@
|
|||
namespace App\Services\Wireguard;
|
||||
|
||||
use App\Models\Host;
|
||||
use App\Models\VpnPeer;
|
||||
use Illuminate\Support\Facades\Process;
|
||||
use RuntimeException;
|
||||
|
||||
|
|
@ -16,7 +17,12 @@ class LocalWireguardHub implements WireguardHub
|
|||
{
|
||||
public function allocateIp(): string
|
||||
{
|
||||
$used = array_flip(Host::query()->whereNotNull('wg_ip')->pluck('wg_ip')->all());
|
||||
// Both tables hand out addresses from the same subnet — an operator VPN
|
||||
// access and a host peer would otherwise be given the same tunnel IP.
|
||||
$used = array_flip(array_merge(
|
||||
Host::query()->whereNotNull('wg_ip')->pluck('wg_ip')->all(),
|
||||
VpnPeer::query()->pluck('allowed_ip')->all(),
|
||||
));
|
||||
$hubIp = (string) config('provisioning.wireguard.hub_ip');
|
||||
|
||||
[$network, $bits] = explode('/', config('provisioning.wireguard.subnet', '10.66.0.0/24'));
|
||||
|
|
@ -48,6 +54,28 @@ class LocalWireguardHub implements WireguardHub
|
|||
Process::run('wg-quick save wg0')->throw(); // persist, else the peer is lost on restart
|
||||
}
|
||||
|
||||
public function peers(): array
|
||||
{
|
||||
$result = Process::run(['wg', 'show', 'wg0', 'dump']);
|
||||
if (! $result->successful()) {
|
||||
throw new RuntimeException('wg show failed: '.trim($result->errorOutput()));
|
||||
}
|
||||
|
||||
$peers = [];
|
||||
// The first line describes the interface itself, not a peer.
|
||||
foreach (array_slice(preg_split('/\R/', trim($result->output())) ?: [], 1) as $line) {
|
||||
if (trim($line) === '') {
|
||||
continue;
|
||||
}
|
||||
$snapshot = PeerSnapshot::fromDumpLine(explode("\t", $line));
|
||||
if ($snapshot !== null) {
|
||||
$peers[$snapshot->publicKey] = $snapshot;
|
||||
}
|
||||
}
|
||||
|
||||
return $peers;
|
||||
}
|
||||
|
||||
public function endpoint(): string
|
||||
{
|
||||
return (string) config('provisioning.wireguard.endpoint');
|
||||
|
|
|
|||
|
|
@ -0,0 +1,41 @@
|
|||
<?php
|
||||
|
||||
namespace App\Services\Wireguard;
|
||||
|
||||
use Carbon\CarbonImmutable;
|
||||
|
||||
/**
|
||||
* One line of `wg show <if> dump` — what the hub currently reports about a peer.
|
||||
* A value object rather than an array so callers cannot silently mistype a key.
|
||||
*/
|
||||
final readonly class PeerSnapshot
|
||||
{
|
||||
public function __construct(
|
||||
public string $publicKey,
|
||||
public ?string $endpoint,
|
||||
public string $allowedIps,
|
||||
public ?CarbonImmutable $latestHandshake,
|
||||
public int $rxBytes,
|
||||
public int $txBytes,
|
||||
) {}
|
||||
|
||||
/** @param list<string> $columns one dump line, already split on tabs */
|
||||
public static function fromDumpLine(array $columns): ?self
|
||||
{
|
||||
// publickey, presharedkey, endpoint, allowed-ips, latest-handshake, rx, tx, keepalive
|
||||
if (count($columns) < 8) {
|
||||
return null;
|
||||
}
|
||||
$handshake = (int) $columns[4];
|
||||
|
||||
return new self(
|
||||
publicKey: $columns[0],
|
||||
endpoint: $columns[2] === '(none)' ? null : $columns[2],
|
||||
allowedIps: $columns[3],
|
||||
// 0 means "never handshaked" — not the unix epoch.
|
||||
latestHandshake: $handshake > 0 ? CarbonImmutable::createFromTimestamp($handshake) : null,
|
||||
rxBytes: (int) $columns[5],
|
||||
txBytes: (int) $columns[6],
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
@ -20,4 +20,13 @@ interface WireguardHub
|
|||
|
||||
/** The hub's WireGuard public key. */
|
||||
public function publicKey(): string;
|
||||
|
||||
/**
|
||||
* What the interface currently reports, keyed by public key. Live state —
|
||||
* traffic counters and handshakes only exist here, never in the database
|
||||
* until SyncVpnPeers copies them over.
|
||||
*
|
||||
* @return array<string, PeerSnapshot>
|
||||
*/
|
||||
public function peers(): array;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -0,0 +1,26 @@
|
|||
<?php
|
||||
|
||||
namespace App\Support;
|
||||
|
||||
/**
|
||||
* Byte counts for humans. WireGuard reports raw octets, and an operator
|
||||
* scanning a table needs "1,4 GB", not 1503238553.
|
||||
*/
|
||||
final class Bytes
|
||||
{
|
||||
public static function human(int $bytes, int $decimals = 1): string
|
||||
{
|
||||
if ($bytes < 1024) {
|
||||
return $bytes.' B';
|
||||
}
|
||||
|
||||
$units = ['kB', 'MB', 'GB', 'TB', 'PB'];
|
||||
$power = min((int) floor(log($bytes, 1024)), count($units));
|
||||
$value = $bytes / (1024 ** $power);
|
||||
|
||||
// Whole numbers read better without a trailing ",0".
|
||||
$decimals = $value >= 100 ? 0 : $decimals;
|
||||
|
||||
return number_format($value, $decimals, ',', '.').' '.$units[$power - 1];
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,27 @@
|
|||
<?php
|
||||
|
||||
namespace Database\Factories;
|
||||
|
||||
use App\Services\Wireguard\Keypair;
|
||||
use Illuminate\Database\Eloquent\Factories\Factory;
|
||||
|
||||
class VpnPeerFactory extends Factory
|
||||
{
|
||||
public function definition(): array
|
||||
{
|
||||
static $octet = 20;
|
||||
|
||||
return [
|
||||
'name' => $this->faker->userName(),
|
||||
'public_key' => Keypair::generate()->publicKey,
|
||||
'allowed_ip' => '10.66.0.'.($octet++),
|
||||
'enabled' => true,
|
||||
'present' => true,
|
||||
];
|
||||
}
|
||||
|
||||
public function blocked(): static
|
||||
{
|
||||
return $this->state(['enabled' => false, 'present' => false]);
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,44 @@
|
|||
<?php
|
||||
|
||||
use Illuminate\Database\Migrations\Migration;
|
||||
use Illuminate\Database\Schema\Blueprint;
|
||||
use Illuminate\Support\Facades\Schema;
|
||||
|
||||
/**
|
||||
* VPN accesses on the management hub. Two kinds share this table:
|
||||
* operator accesses created in the console, and the peers the host pipeline
|
||||
* registers for Proxmox hosts (linked via host_id). The console lists both,
|
||||
* because "who is connected to the VPN" must not have blind spots.
|
||||
*
|
||||
* `enabled` is the DESIRED state (operator intent), `present` the OBSERVED
|
||||
* state on the interface, refreshed by SyncVpnPeers. Keeping them apart makes
|
||||
* drift visible instead of silently overwriting what the operator asked for.
|
||||
*/
|
||||
return new class extends Migration
|
||||
{
|
||||
public function up(): void
|
||||
{
|
||||
Schema::create('vpn_peers', function (Blueprint $table) {
|
||||
$table->id();
|
||||
$table->uuid()->unique(); // R11: UUIDs in URLs
|
||||
$table->string('name');
|
||||
$table->string('public_key')->unique();
|
||||
$table->string('allowed_ip')->unique(); // one tunnel address per peer
|
||||
$table->foreignId('host_id')->nullable()->constrained()->nullOnDelete();
|
||||
$table->boolean('enabled')->default(true);
|
||||
$table->boolean('present')->default(false);
|
||||
$table->string('endpoint')->nullable(); // last source address seen
|
||||
$table->timestamp('last_handshake_at')->nullable();
|
||||
$table->unsignedBigInteger('rx_bytes')->default(0);
|
||||
$table->unsignedBigInteger('tx_bytes')->default(0);
|
||||
$table->timestamp('observed_at')->nullable(); // when the hub last reported it
|
||||
$table->foreignId('created_by')->nullable()->constrained('users')->nullOnDelete();
|
||||
$table->timestamps();
|
||||
});
|
||||
}
|
||||
|
||||
public function down(): void
|
||||
{
|
||||
Schema::dropIfExists('vpn_peers');
|
||||
}
|
||||
};
|
||||
|
|
@ -0,0 +1,33 @@
|
|||
<?php
|
||||
|
||||
use Illuminate\Database\Migrations\Migration;
|
||||
use Spatie\Permission\Models\Permission;
|
||||
use Spatie\Permission\Models\Role;
|
||||
use Spatie\Permission\PermissionRegistrar;
|
||||
|
||||
/**
|
||||
* `vpn.manage` — create, block and delete VPN accesses. Granted to Owner and
|
||||
* Admin only: a VPN access reaches the management network, so it is a stronger
|
||||
* capability than the customer-facing ones Support/Billing hold.
|
||||
*/
|
||||
return new class extends Migration
|
||||
{
|
||||
public function up(): void
|
||||
{
|
||||
app(PermissionRegistrar::class)->forgetCachedPermissions();
|
||||
|
||||
Permission::findOrCreate('vpn.manage', 'web');
|
||||
foreach (['Owner', 'Admin'] as $role) {
|
||||
Role::findOrCreate($role, 'web')->givePermissionTo('vpn.manage');
|
||||
}
|
||||
|
||||
app(PermissionRegistrar::class)->forgetCachedPermissions();
|
||||
}
|
||||
|
||||
public function down(): void
|
||||
{
|
||||
app(PermissionRegistrar::class)->forgetCachedPermissions();
|
||||
Permission::query()->where('name', 'vpn.manage')->delete();
|
||||
app(PermissionRegistrar::class)->forgetCachedPermissions();
|
||||
}
|
||||
};
|
||||
|
|
@ -13,6 +13,7 @@ return [
|
|||
'datacenters' => 'Rechenzentren',
|
||||
'provisioning' => 'Provisioning',
|
||||
'maintenance' => 'Wartungen',
|
||||
'vpn' => 'VPN',
|
||||
'revenue' => 'Umsatz',
|
||||
'settings' => 'Einstellungen',
|
||||
],
|
||||
|
|
|
|||
|
|
@ -1,6 +1,7 @@
|
|||
<?php
|
||||
|
||||
return [
|
||||
'close' => 'Schließen',
|
||||
'tagline' => 'Kontrollzentrum für Ihre Cloud.',
|
||||
'sign_in' => 'Anmelden',
|
||||
'save' => 'Speichern',
|
||||
|
|
|
|||
|
|
@ -0,0 +1,60 @@
|
|||
<?php
|
||||
|
||||
return [
|
||||
'title' => 'VPN-Zugänge',
|
||||
'subtitle' => 'Wer ist mit dem Management-Netz verbunden — Zugänge anlegen, sperren oder entfernen.',
|
||||
|
||||
'peers' => 'Zugänge',
|
||||
'peer' => 'Zugang',
|
||||
'address' => 'Adresse',
|
||||
'traffic' => 'Datenverkehr',
|
||||
'handshake' => 'Letzter Kontakt',
|
||||
'status' => 'Status',
|
||||
'actions' => 'Aktionen',
|
||||
'empty' => 'Noch keine Zugänge. Der erste angelegte Zugang erscheint hier, sobald der Hub ihn übernommen hat.',
|
||||
'never' => 'nie',
|
||||
'last_sync' => 'Stand: :time',
|
||||
'never_synced' => 'Noch keine Daten vom Hub',
|
||||
|
||||
'host_peer' => 'Host-Zugang',
|
||||
'operator_peer' => 'Mitarbeiter-Zugang',
|
||||
'unknown_peer' => 'Unbekannter Zugang',
|
||||
|
||||
'status_online' => 'Verbunden',
|
||||
'status_idle' => 'Eingerichtet',
|
||||
'status_blocked' => 'Gesperrt',
|
||||
'status_pending' => 'Wird angewendet',
|
||||
|
||||
'add' => 'Zugang anlegen',
|
||||
'name' => 'Bezeichnung',
|
||||
'own_key' => 'Eigener Public Key',
|
||||
'own_key_hint' => 'Leer lassen, dann erzeugen wir ein Schlüsselpaar und zeigen die fertige Konfiguration einmalig an.',
|
||||
'own_key_placeholder' => 'optional',
|
||||
'invalid_key' => 'Das ist kein gültiger WireGuard-Schlüssel (32 Byte, Base64).',
|
||||
'duplicate_key' => 'Für diesen Schlüssel gibt es bereits einen Zugang.',
|
||||
'created' => 'Zugang angelegt.',
|
||||
'blocked' => 'Zugang gesperrt.',
|
||||
'unblocked' => 'Zugang wieder freigegeben.',
|
||||
'deleted' => 'Zugang entfernt.',
|
||||
|
||||
'config_ready' => 'Konfiguration für :name',
|
||||
'config_once' => 'Der private Schlüssel wird nicht gespeichert und ist nur jetzt sichtbar.',
|
||||
'copy' => 'Kopieren',
|
||||
'copied' => 'Kopiert',
|
||||
|
||||
'block' => 'Sperren',
|
||||
'unblock' => 'Freigeben',
|
||||
'delete' => 'Entfernen',
|
||||
'cancel' => 'Abbrechen',
|
||||
'delete_title' => ':name endgültig entfernen?',
|
||||
'delete_body' => 'Der Zugang wird vom Hub gelöscht. Eine Wiederherstellung ist nur mit einem neuen Schlüsselpaar möglich.',
|
||||
'delete_host_warning' => 'Achtung: Dieser Zugang gehört zum Host :host. CluPilot erreicht diesen Host danach nicht mehr.',
|
||||
'delete_confirm' => 'Zugang entfernen',
|
||||
|
||||
'hub' => 'Hub',
|
||||
'endpoint' => 'Endpunkt',
|
||||
'hub_key' => 'Public Key',
|
||||
'subnet' => 'Subnetz',
|
||||
'hub_incomplete_title' => 'Hub ist noch nicht vollständig konfiguriert',
|
||||
'hub_incomplete_body' => 'CLUPILOT_WG_ENDPOINT und CLUPILOT_WG_HUB_PUBKEY sind leer — erzeugte Konfigurationen wären unbrauchbar.',
|
||||
];
|
||||
|
|
@ -13,6 +13,7 @@ return [
|
|||
'datacenters' => 'Datacenters',
|
||||
'provisioning' => 'Provisioning',
|
||||
'maintenance' => 'Maintenance',
|
||||
'vpn' => 'VPN',
|
||||
'revenue' => 'Revenue',
|
||||
'settings' => 'Settings',
|
||||
],
|
||||
|
|
|
|||
|
|
@ -1,6 +1,7 @@
|
|||
<?php
|
||||
|
||||
return [
|
||||
'close' => 'Close',
|
||||
'tagline' => 'Control center for your cloud.',
|
||||
'sign_in' => 'Sign in',
|
||||
'save' => 'Save',
|
||||
|
|
|
|||
|
|
@ -0,0 +1,60 @@
|
|||
<?php
|
||||
|
||||
return [
|
||||
'title' => 'VPN accesses',
|
||||
'subtitle' => 'Who is connected to the management network — create, block or remove accesses.',
|
||||
|
||||
'peers' => 'Accesses',
|
||||
'peer' => 'Access',
|
||||
'address' => 'Address',
|
||||
'traffic' => 'Traffic',
|
||||
'handshake' => 'Last contact',
|
||||
'status' => 'Status',
|
||||
'actions' => 'Actions',
|
||||
'empty' => 'No accesses yet. The first one appears here once the hub has picked it up.',
|
||||
'never' => 'never',
|
||||
'last_sync' => 'As of :time',
|
||||
'never_synced' => 'No data from the hub yet',
|
||||
|
||||
'host_peer' => 'Host access',
|
||||
'operator_peer' => 'Staff access',
|
||||
'unknown_peer' => 'Unknown access',
|
||||
|
||||
'status_online' => 'Connected',
|
||||
'status_idle' => 'Configured',
|
||||
'status_blocked' => 'Blocked',
|
||||
'status_pending' => 'Applying',
|
||||
|
||||
'add' => 'Create access',
|
||||
'name' => 'Label',
|
||||
'own_key' => 'Own public key',
|
||||
'own_key_hint' => 'Leave empty and we generate a keypair, showing the ready-made config once.',
|
||||
'own_key_placeholder' => 'optional',
|
||||
'invalid_key' => 'That is not a valid WireGuard key (32 bytes, base64).',
|
||||
'duplicate_key' => 'An access already exists for this key.',
|
||||
'created' => 'Access created.',
|
||||
'blocked' => 'Access blocked.',
|
||||
'unblocked' => 'Access re-enabled.',
|
||||
'deleted' => 'Access removed.',
|
||||
|
||||
'config_ready' => 'Configuration for :name',
|
||||
'config_once' => 'The private key is not stored and is only visible now.',
|
||||
'copy' => 'Copy',
|
||||
'copied' => 'Copied',
|
||||
|
||||
'block' => 'Block',
|
||||
'unblock' => 'Unblock',
|
||||
'delete' => 'Remove',
|
||||
'cancel' => 'Cancel',
|
||||
'delete_title' => 'Remove :name for good?',
|
||||
'delete_body' => 'The access is deleted from the hub. Restoring it requires a new keypair.',
|
||||
'delete_host_warning' => 'Careful: this access belongs to host :host. CluPilot will no longer reach that host.',
|
||||
'delete_confirm' => 'Remove access',
|
||||
|
||||
'hub' => 'Hub',
|
||||
'endpoint' => 'Endpoint',
|
||||
'hub_key' => 'Public key',
|
||||
'subnet' => 'Subnet',
|
||||
'hub_incomplete_title' => 'Hub is not fully configured yet',
|
||||
'hub_incomplete_body' => 'CLUPILOT_WG_ENDPOINT and CLUPILOT_WG_HUB_PUBKEY are empty — generated configs would be unusable.',
|
||||
];
|
||||
|
|
@ -18,6 +18,10 @@
|
|||
'download' => '<path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4"/><polyline points="7 10 12 15 17 10"/><line x1="12" x2="12" y1="15" y2="3"/>',
|
||||
'alert-triangle' => '<path d="m21.73 18-8-14a2 2 0 0 0-3.48 0l-8 14A2 2 0 0 0 4 21h16a2 2 0 0 0 1.73-3Z"/><line x1="12" x2="12" y1="9" y2="13"/><line x1="12" x2="12.01" y1="17" y2="17"/>',
|
||||
'check' => '<polyline points="20 6 9 17 4 12"/>',
|
||||
'lock' => '<rect width="18" height="11" x="3" y="11" rx="2" ry="2"/><path d="M7 11V7a5 5 0 0 1 10 0v4"/>',
|
||||
'unlock' => '<rect width="18" height="11" x="3" y="11" rx="2" ry="2"/><path d="M7 11V7a5 5 0 0 1 9.9-1"/>',
|
||||
'copy' => '<rect width="14" height="14" x="8" y="8" rx="2" ry="2"/><path d="M4 16c-1.1 0-2-.9-2-2V4c0-1.1.9-2 2-2h10c1.1 0 2 .9 2 2"/>',
|
||||
'x' => '<path d="M18 6 6 18"/><path d="m6 6 12 12"/>',
|
||||
'plus' => '<path d="M5 12h14"/><path d="M12 5v14"/>',
|
||||
'server' => '<rect width="20" height="8" x="2" y="2" rx="2"/><rect width="20" height="8" x="2" y="14" rx="2"/><line x1="6" x2="6.01" y1="6" y2="6"/><line x1="6" x2="6.01" y1="18" y2="18"/>',
|
||||
'activity' => '<path d="M22 12h-4l-3 9L9 3l-3 9H2"/>',
|
||||
|
|
|
|||
|
|
@ -29,15 +29,17 @@
|
|||
</div>
|
||||
<nav class="space-y-1">
|
||||
@foreach ([
|
||||
['admin.overview', 'gauge', 'overview'],
|
||||
['admin.customers', 'users', 'customers'],
|
||||
['admin.instances', 'box', 'instances'],
|
||||
['admin.hosts', 'server', 'hosts'],
|
||||
['admin.datacenters', 'database', 'datacenters'],
|
||||
['admin.provisioning', 'activity', 'provisioning'],
|
||||
['admin.maintenance', 'alert-triangle', 'maintenance'],
|
||||
['admin.revenue', 'trending-up', 'revenue'],
|
||||
] as [$route, $icon, $key])
|
||||
['admin.overview', 'gauge', 'overview', null],
|
||||
['admin.customers', 'users', 'customers', null],
|
||||
['admin.instances', 'box', 'instances', null],
|
||||
['admin.hosts', 'server', 'hosts', null],
|
||||
['admin.datacenters', 'database', 'datacenters', null],
|
||||
['admin.provisioning', 'activity', 'provisioning', null],
|
||||
['admin.maintenance', 'alert-triangle', 'maintenance', null],
|
||||
['admin.vpn', 'shield', 'vpn', 'vpn.manage'],
|
||||
['admin.revenue', 'trending-up', 'revenue', null],
|
||||
] as [$route, $icon, $key, $capability])
|
||||
@continue($capability !== null && ! auth()->user()?->can($capability))
|
||||
<x-ui.nav-item :href="route($route)" :active="request()->routeIs($route)">
|
||||
<x-slot:icon><x-ui.icon :name="$icon" /></x-slot:icon>
|
||||
{{ __('admin.nav.'.$key) }}
|
||||
|
|
|
|||
|
|
@ -0,0 +1,22 @@
|
|||
<div class="p-6">
|
||||
<div class="flex items-start gap-4">
|
||||
<div class="grid size-10 shrink-0 place-items-center rounded-full border border-danger-border bg-danger-bg">
|
||||
<x-ui.icon name="alert-triangle" class="size-5 text-danger" />
|
||||
</div>
|
||||
<div class="min-w-0">
|
||||
<h3 class="text-base font-semibold text-ink">{{ __('vpn.delete_title', ['name' => $name]) }}</h3>
|
||||
<p class="mt-1 text-sm text-muted">{{ __('vpn.delete_body') }}</p>
|
||||
@if ($hostName)
|
||||
<p class="mt-2 rounded-lg border border-warning-border bg-warning-bg p-3 text-sm text-body">
|
||||
{{ __('vpn.delete_host_warning', ['host' => $hostName]) }}
|
||||
</p>
|
||||
@endif
|
||||
</div>
|
||||
</div>
|
||||
<div class="mt-6 flex justify-end gap-2">
|
||||
<x-ui.button variant="secondary" x-on:click="Livewire.dispatch('closeModal')">{{ __('vpn.cancel') }}</x-ui.button>
|
||||
<x-ui.button variant="danger" wire:click="delete" wire:loading.attr="disabled">
|
||||
{{ __('vpn.delete_confirm') }}
|
||||
</x-ui.button>
|
||||
</div>
|
||||
</div>
|
||||
|
|
@ -0,0 +1,160 @@
|
|||
<div class="space-y-5" wire:poll.5s="refreshPeers">
|
||||
<div class="animate-rise">
|
||||
<h1 class="text-2xl font-semibold tracking-tight text-ink">{{ __('vpn.title') }}</h1>
|
||||
<p class="mt-1 text-sm text-muted">{{ __('vpn.subtitle') }}</p>
|
||||
</div>
|
||||
|
||||
@if ($hubEndpoint === '' || $hubPublicKey === '')
|
||||
<div class="flex items-start gap-3 rounded-xl border border-warning-border bg-warning-bg p-4 animate-rise">
|
||||
<x-ui.icon name="alert-triangle" class="mt-0.5 size-5 shrink-0 text-warning" />
|
||||
<div class="text-sm">
|
||||
<p class="font-medium text-ink">{{ __('vpn.hub_incomplete_title') }}</p>
|
||||
<p class="mt-0.5 text-muted">{{ __('vpn.hub_incomplete_body') }}</p>
|
||||
</div>
|
||||
</div>
|
||||
@endif
|
||||
|
||||
{{-- Shown once. The private key exists only in this response; a reload loses it. --}}
|
||||
@if ($newConfig)
|
||||
<div class="rounded-xl border border-accent-border bg-accent-bg p-5 animate-rise">
|
||||
<div class="flex items-start justify-between gap-4">
|
||||
<div>
|
||||
<h2 class="font-semibold text-ink">{{ __('vpn.config_ready', ['name' => $newConfigName]) }}</h2>
|
||||
<p class="mt-0.5 text-sm text-muted">{{ __('vpn.config_once') }}</p>
|
||||
</div>
|
||||
<button type="button" wire:click="dismissConfig" aria-label="{{ __('common.close') }}"
|
||||
class="grid size-8 shrink-0 place-items-center rounded-md border border-line text-muted hover:text-ink">
|
||||
<x-ui.icon name="x" class="size-4" />
|
||||
</button>
|
||||
</div>
|
||||
<div x-data="{ copied: false }" class="mt-3">
|
||||
<pre id="vpn-config" class="max-h-64 overflow-auto rounded-lg border border-line bg-surface p-4 font-mono text-xs leading-relaxed text-body">{{ $newConfig }}</pre>
|
||||
<div class="mt-3 flex flex-wrap gap-2">
|
||||
<x-ui.button variant="secondary"
|
||||
x-on:click="navigator.clipboard.writeText(document.getElementById('vpn-config').textContent); copied = true; setTimeout(() => copied = false, 2000)">
|
||||
<x-ui.icon name="copy" class="size-4" />
|
||||
<span x-text="copied ? @js(__('vpn.copied')) : @js(__('vpn.copy'))"></span>
|
||||
</x-ui.button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@endif
|
||||
|
||||
<div class="grid grid-cols-1 gap-5 lg:grid-cols-[1fr_320px] lg:items-start">
|
||||
{{-- Peer list --}}
|
||||
<div class="overflow-hidden rounded-xl border border-line bg-surface shadow-xs animate-rise [animation-delay:60ms]">
|
||||
<div class="flex items-center justify-between border-b border-line px-4 py-3">
|
||||
<h2 class="text-sm font-semibold text-ink">{{ __('vpn.peers') }}</h2>
|
||||
<span class="text-xs text-faint">
|
||||
{{ $lastSync ? __('vpn.last_sync', ['time' => \Illuminate\Support\Carbon::parse($lastSync)->diffForHumans()]) : __('vpn.never_synced') }}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
@if ($peers->isEmpty())
|
||||
<p class="p-8 text-center text-sm text-muted">{{ __('vpn.empty') }}</p>
|
||||
@else
|
||||
<div class="overflow-x-auto">
|
||||
<table class="w-full text-sm">
|
||||
<thead>
|
||||
<tr class="border-b border-line bg-surface-2 text-left text-xs uppercase tracking-wide text-faint">
|
||||
<th class="px-4 py-3 font-semibold">{{ __('vpn.peer') }}</th>
|
||||
<th class="px-4 py-3 font-semibold">{{ __('vpn.address') }}</th>
|
||||
<th class="px-4 py-3 font-semibold">{{ __('vpn.traffic') }}</th>
|
||||
<th class="px-4 py-3 font-semibold">{{ __('vpn.handshake') }}</th>
|
||||
<th class="px-4 py-3 font-semibold">{{ __('vpn.status') }}</th>
|
||||
<th class="px-4 py-3 text-right font-semibold">{{ __('vpn.actions') }}</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
@foreach ($peers as $peer)
|
||||
@php
|
||||
$status = $peer->status();
|
||||
$tone = [
|
||||
'online' => 'border-success-border bg-success-bg text-success',
|
||||
'idle' => 'border-line-strong bg-surface-2 text-muted',
|
||||
'blocked' => 'border-danger-border bg-danger-bg text-danger',
|
||||
'pending' => 'border-warning-border bg-warning-bg text-warning',
|
||||
][$status];
|
||||
@endphp
|
||||
<tr wire:key="peer-{{ $peer->uuid }}" class="border-b border-line last:border-0">
|
||||
<td class="px-4 py-3">
|
||||
<div class="font-medium text-ink">{{ $peer->name }}</div>
|
||||
<div class="mt-0.5 flex items-center gap-1.5 text-xs text-faint">
|
||||
@if ($peer->host)
|
||||
<x-ui.icon name="server" class="size-3.5" />{{ __('vpn.host_peer') }}
|
||||
@else
|
||||
<x-ui.icon name="users" class="size-3.5" />{{ __('vpn.operator_peer') }}
|
||||
@endif
|
||||
</div>
|
||||
</td>
|
||||
<td class="px-4 py-3">
|
||||
<div class="font-mono text-xs text-body">{{ $peer->allowed_ip }}</div>
|
||||
@if ($peer->endpoint)
|
||||
<div class="mt-0.5 font-mono text-xs text-faint">{{ $peer->endpoint }}</div>
|
||||
@endif
|
||||
</td>
|
||||
<td class="px-4 py-3 font-mono text-xs text-muted whitespace-nowrap">
|
||||
↓ {{ \App\Support\Bytes::human($peer->rx_bytes) }}<br>
|
||||
↑ {{ \App\Support\Bytes::human($peer->tx_bytes) }}
|
||||
</td>
|
||||
<td class="px-4 py-3 text-xs text-muted whitespace-nowrap">
|
||||
{{ $peer->last_handshake_at?->diffForHumans() ?? __('vpn.never') }}
|
||||
</td>
|
||||
<td class="px-4 py-3">
|
||||
<span class="inline-flex items-center gap-1.5 rounded-pill border px-2.5 py-0.5 text-xs font-medium {{ $tone }}">
|
||||
<span class="size-1.5 rounded-pill bg-current" aria-hidden="true"></span>
|
||||
{{ __('vpn.status_'.$status) }}
|
||||
</span>
|
||||
</td>
|
||||
<td class="px-4 py-3 text-right">
|
||||
<div class="inline-flex gap-1">
|
||||
<button type="button" wire:click="toggle('{{ $peer->uuid }}')"
|
||||
aria-label="{{ $peer->enabled ? __('vpn.block') : __('vpn.unblock') }}"
|
||||
class="grid size-8 place-items-center rounded-md border border-line text-muted hover:border-warning-border hover:text-warning">
|
||||
<x-ui.icon :name="$peer->enabled ? 'lock' : 'unlock'" class="size-4" />
|
||||
</button>
|
||||
<button type="button" aria-label="{{ __('vpn.delete') }}"
|
||||
x-on:click="$dispatch('openModal', { component: 'admin.confirm-delete-vpn-peer', arguments: { uuid: '{{ $peer->uuid }}' } })"
|
||||
class="grid size-8 place-items-center rounded-md border border-line text-muted hover:border-danger hover:text-danger">
|
||||
<x-ui.icon name="trash-2" class="size-4" />
|
||||
</button>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
@endforeach
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
@endif
|
||||
</div>
|
||||
|
||||
<div class="space-y-5">
|
||||
{{-- New access --}}
|
||||
<form wire:submit="create" class="space-y-4 rounded-xl border border-line bg-surface p-5 shadow-xs animate-rise [animation-delay:120ms]">
|
||||
<h2 class="font-semibold text-ink">{{ __('vpn.add') }}</h2>
|
||||
<x-ui.input name="name" wire:model="name" :label="__('vpn.name')" placeholder="Notebook Boban" />
|
||||
<x-ui.input name="publicKey" wire:model="publicKey" :label="__('vpn.own_key')" :hint="__('vpn.own_key_hint')" placeholder="{{ __('vpn.own_key_placeholder') }}" />
|
||||
<x-ui.button variant="primary" type="submit" class="w-full">
|
||||
<x-ui.icon name="plus" class="size-4" />{{ __('vpn.add') }}
|
||||
</x-ui.button>
|
||||
</form>
|
||||
|
||||
{{-- Hub facts an operator needs when configuring a peer by hand --}}
|
||||
<div class="space-y-3 rounded-xl border border-line bg-surface p-5 shadow-xs animate-rise [animation-delay:180ms]">
|
||||
<h2 class="font-semibold text-ink">{{ __('vpn.hub') }}</h2>
|
||||
<div>
|
||||
<div class="text-xs uppercase tracking-wide text-faint">{{ __('vpn.endpoint') }}</div>
|
||||
<div class="mt-0.5 break-all font-mono text-xs text-body">{{ $hubEndpoint ?: '—' }}</div>
|
||||
</div>
|
||||
<div>
|
||||
<div class="text-xs uppercase tracking-wide text-faint">{{ __('vpn.hub_key') }}</div>
|
||||
<div class="mt-0.5 break-all font-mono text-xs text-body">{{ $hubPublicKey ?: '—' }}</div>
|
||||
</div>
|
||||
<div>
|
||||
<div class="text-xs uppercase tracking-wide text-faint">{{ __('vpn.subnet') }}</div>
|
||||
<div class="mt-0.5 font-mono text-xs text-body">{{ config('provisioning.wireguard.subnet') }}</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
|
@ -14,3 +14,10 @@ Schedule::call(fn () => app(TickProvisioning::class)())
|
|||
->everyMinute()
|
||||
->name('provisioning-tick')
|
||||
->withoutOverlapping();
|
||||
|
||||
// Refresh the VPN peer state (handshakes, traffic) so the console does not go
|
||||
// stale while nobody has it open. The job itself runs on the provisioning
|
||||
// queue — that worker owns wg0.
|
||||
Schedule::job(new \App\Provisioning\Jobs\SyncVpnPeers)
|
||||
->everyMinute()
|
||||
->name('vpn-sync');
|
||||
|
|
|
|||
|
|
@ -74,6 +74,7 @@ Route::middleware(['admin.host', 'auth', 'admin'])->prefix('admin')->name('admin
|
|||
Route::post('/impersonate/{customer}', [ImpersonationController::class, 'start'])->name('impersonate');
|
||||
Route::get('/provisioning', Admin\Provisioning::class)->name('provisioning');
|
||||
Route::get('/maintenance', Admin\Maintenance::class)->name('maintenance');
|
||||
Route::get('/vpn', Admin\Vpn::class)->name('vpn');
|
||||
Route::get('/revenue', Admin\Revenue::class)->name('revenue');
|
||||
Route::get('/settings', Admin\Settings::class)->name('settings');
|
||||
});
|
||||
|
|
|
|||
|
|
@ -0,0 +1,209 @@
|
|||
<?php
|
||||
|
||||
use App\Livewire\Admin\ConfirmDeleteVpnPeer;
|
||||
use App\Livewire\Admin\Vpn;
|
||||
use App\Models\Host;
|
||||
use App\Models\VpnPeer;
|
||||
use App\Provisioning\Jobs\ApplyVpnPeer;
|
||||
use App\Provisioning\Jobs\SyncVpnPeers;
|
||||
use App\Services\Wireguard\FakeWireguardHub;
|
||||
use App\Services\Wireguard\Keypair;
|
||||
use App\Services\Wireguard\LocalWireguardHub;
|
||||
use App\Services\Wireguard\PeerSnapshot;
|
||||
use App\Services\Wireguard\WireguardHub;
|
||||
use Illuminate\Support\Facades\Queue;
|
||||
use Livewire\Livewire;
|
||||
|
||||
function vpnHub(): FakeWireguardHub
|
||||
{
|
||||
$hub = new FakeWireguardHub;
|
||||
app()->instance(WireguardHub::class, $hub);
|
||||
|
||||
return $hub;
|
||||
}
|
||||
|
||||
it('keeps the page and the nav entry away from operators without the capability', function () {
|
||||
vpnHub();
|
||||
|
||||
// Support may run the console, but a VPN access reaches the management network.
|
||||
$this->actingAs(operator('Support'))->get(route('admin.vpn'))->assertForbidden();
|
||||
$this->actingAs(operator('Support'))->get(route('admin.overview'))
|
||||
->assertOk()
|
||||
->assertDontSee(route('admin.vpn'));
|
||||
|
||||
$this->actingAs(operator('Admin'))->get(route('admin.vpn'))->assertOk();
|
||||
$this->actingAs(operator('Owner'))->get(route('admin.overview'))
|
||||
->assertOk()
|
||||
->assertSee(route('admin.vpn'));
|
||||
});
|
||||
|
||||
it('creates an access, hands out the config once and pushes it to the hub', function () {
|
||||
vpnHub();
|
||||
Queue::fake();
|
||||
|
||||
$component = Livewire::actingAs(operator('Owner'))
|
||||
->test(Vpn::class)
|
||||
->set('name', 'Notebook Boban')
|
||||
->call('create')
|
||||
->assertHasNoErrors();
|
||||
|
||||
$peer = VpnPeer::query()->firstOrFail();
|
||||
expect($peer->name)->toBe('Notebook Boban')
|
||||
->and($peer->enabled)->toBeTrue()
|
||||
->and($peer->present)->toBeFalse(); // not on the hub until the job ran
|
||||
|
||||
$config = $component->get('newConfig');
|
||||
expect($config)->toContain('[Interface]')
|
||||
->toContain('Address = '.$peer->allowed_ip.'/32')
|
||||
->toContain('PublicKey = HUBPUBLICKEY0000000000000000000000000000000=')
|
||||
->toContain('Endpoint = vpn.clupilot.test:51820');
|
||||
|
||||
// The private key is shown, never stored.
|
||||
preg_match('/PrivateKey = (\S+)/', $config, $m);
|
||||
expect(Keypair::isValidKey($m[1]))->toBeTrue()
|
||||
->and(VpnPeer::query()->where('public_key', $m[1])->exists())->toBeFalse();
|
||||
|
||||
Queue::assertPushed(ApplyVpnPeer::class, fn ($job) => $job->publicKey === $peer->public_key && $job->enabled);
|
||||
});
|
||||
|
||||
it('accepts a peer-supplied public key and then has no private key to show', function () {
|
||||
vpnHub();
|
||||
Queue::fake();
|
||||
$own = Keypair::generate()->publicKey;
|
||||
|
||||
$component = Livewire::actingAs(operator('Owner'))
|
||||
->test(Vpn::class)
|
||||
->set('name', 'Proxmox fsn1')
|
||||
->set('publicKey', $own)
|
||||
->call('create')
|
||||
->assertHasNoErrors();
|
||||
|
||||
expect(VpnPeer::query()->where('public_key', $own)->exists())->toBeTrue()
|
||||
->and($component->get('newConfig'))->toBeNull();
|
||||
});
|
||||
|
||||
it('rejects a malformed key and a key that already has an access', function () {
|
||||
vpnHub();
|
||||
$existing = VpnPeer::factory()->create();
|
||||
|
||||
Livewire::actingAs(operator('Owner'))->test(Vpn::class)
|
||||
->set('name', 'kaputt')->set('publicKey', 'nope')
|
||||
->call('create')
|
||||
->assertHasErrors('publicKey');
|
||||
|
||||
Livewire::actingAs(operator('Owner'))->test(Vpn::class)
|
||||
->set('name', 'doppelt')->set('publicKey', $existing->public_key)
|
||||
->call('create')
|
||||
->assertHasErrors('publicKey');
|
||||
|
||||
expect(VpnPeer::query()->count())->toBe(1);
|
||||
});
|
||||
|
||||
it('blocks and unblocks an access without losing the record', function () {
|
||||
vpnHub();
|
||||
Queue::fake();
|
||||
$peer = VpnPeer::factory()->create();
|
||||
|
||||
Livewire::actingAs(operator('Owner'))->test(Vpn::class)->call('toggle', $peer->uuid);
|
||||
expect($peer->fresh()->enabled)->toBeFalse();
|
||||
Queue::assertPushed(ApplyVpnPeer::class, fn ($job) => ! $job->enabled);
|
||||
|
||||
Livewire::actingAs(operator('Owner'))->test(Vpn::class)->call('toggle', $peer->uuid);
|
||||
expect($peer->fresh()->enabled)->toBeTrue()
|
||||
->and(VpnPeer::query()->count())->toBe(1);
|
||||
});
|
||||
|
||||
it('removes the peer from the hub before deleting the row', function () {
|
||||
vpnHub();
|
||||
Queue::fake();
|
||||
$peer = VpnPeer::factory()->create();
|
||||
|
||||
Livewire::actingAs(operator('Owner'))
|
||||
->test(ConfirmDeleteVpnPeer::class, ['uuid' => $peer->uuid])
|
||||
->call('delete');
|
||||
|
||||
expect(VpnPeer::query()->count())->toBe(0);
|
||||
Queue::assertPushed(ApplyVpnPeer::class, fn ($job) => $job->publicKey === $peer->public_key && ! $job->enabled);
|
||||
});
|
||||
|
||||
it('warns that deleting a host access cuts CluPilot off from that host', function () {
|
||||
vpnHub();
|
||||
$host = Host::factory()->active()->create(['name' => 'pve-fsn1']);
|
||||
$peer = VpnPeer::factory()->create(['host_id' => $host->id]);
|
||||
|
||||
Livewire::actingAs(operator('Owner'))
|
||||
->test(ConfirmDeleteVpnPeer::class, ['uuid' => $peer->uuid])
|
||||
->assertSee('pve-fsn1');
|
||||
});
|
||||
|
||||
it('stops mutations from a session whose capability was revoked mid-flight', function () {
|
||||
vpnHub();
|
||||
Queue::fake();
|
||||
$peer = VpnPeer::factory()->create();
|
||||
$user = operator('Owner');
|
||||
|
||||
// Page is open and legitimate…
|
||||
$component = Livewire::actingAs($user)->test(Vpn::class);
|
||||
|
||||
// …then the account is demoted. The already-rendered page must not keep its
|
||||
// powers: every action re-checks, it does not trust the mount.
|
||||
$user->syncRoles(['Support']);
|
||||
app(Spatie\Permission\PermissionRegistrar::class)->forgetCachedPermissions();
|
||||
|
||||
$component->call('toggle', $peer->uuid)->assertForbidden();
|
||||
|
||||
expect($peer->fresh()->enabled)->toBeTrue();
|
||||
Queue::assertNotPushed(ApplyVpnPeer::class);
|
||||
});
|
||||
|
||||
it('adopts host peers from the hub and marks vanished ones absent', function () {
|
||||
$hub = vpnHub();
|
||||
$key = Keypair::generate()->publicKey;
|
||||
$host = Host::factory()->active()->create(['name' => 'pve-fsn1', 'wg_pubkey' => $key]);
|
||||
|
||||
$hub->addPeer($key, '10.66.0.7');
|
||||
$hub->snapshots[$key] = new PeerSnapshot(
|
||||
publicKey: $key,
|
||||
endpoint: '203.0.113.9:51820',
|
||||
allowedIps: '10.66.0.7/32',
|
||||
latestHandshake: now()->subSeconds(30)->toImmutable(),
|
||||
rxBytes: 2048,
|
||||
txBytes: 4096,
|
||||
);
|
||||
|
||||
// A peer that exists in the console but is no longer on the interface.
|
||||
$stale = VpnPeer::factory()->create(['present' => true]);
|
||||
|
||||
(new SyncVpnPeers)->handle($hub);
|
||||
|
||||
$adopted = VpnPeer::query()->where('public_key', $key)->firstOrFail();
|
||||
expect($adopted->name)->toBe('pve-fsn1') // named after its host, not "unknown"
|
||||
->and($adopted->host_id)->toBe($host->id)
|
||||
->and($adopted->present)->toBeTrue()
|
||||
->and($adopted->rx_bytes)->toBe(2048)
|
||||
->and($adopted->endpoint)->toBe('203.0.113.9:51820')
|
||||
->and($adopted->status())->toBe('online')
|
||||
->and($stale->fresh()->present)->toBeFalse();
|
||||
});
|
||||
|
||||
it('never lets a sync overwrite the operator intent to block', function () {
|
||||
$hub = vpnHub();
|
||||
$peer = VpnPeer::factory()->blocked()->create();
|
||||
|
||||
// The hub still reports it (removal has not been applied yet).
|
||||
$hub->addPeer($peer->public_key, $peer->allowed_ip);
|
||||
(new SyncVpnPeers)->handle($hub);
|
||||
|
||||
$peer->refresh();
|
||||
expect($peer->enabled)->toBeFalse() // intent survives
|
||||
->and($peer->present)->toBeTrue() // reality is recorded
|
||||
->and($peer->status())->toBe('pending');
|
||||
});
|
||||
|
||||
it('does not hand the same tunnel address to a host and a VPN access', function () {
|
||||
$host = Host::factory()->active()->create(['wg_ip' => '10.66.0.2']);
|
||||
VpnPeer::factory()->create(['allowed_ip' => '10.66.0.3']);
|
||||
|
||||
// The real allocator, not the fake — this is the collision the fake cannot show.
|
||||
expect((new LocalWireguardHub)->allocateIp())->toBe('10.66.0.4');
|
||||
});
|
||||
|
|
@ -98,7 +98,7 @@ it('configures wireguard and registers the peer exactly once', function () {
|
|||
$host->refresh();
|
||||
expect($host->wg_ip)->not->toBeNull()
|
||||
->and($host->wg_pubkey)->toBe('HOSTPUB=')
|
||||
->and($s['hub']->peers())->toHaveCount(1);
|
||||
->and($s['hub']->peerIps())->toHaveCount(1);
|
||||
|
||||
// Re-run: idempotent short-circuit, still one peer resource.
|
||||
app(ConfigureWireguard::class)->execute($run->fresh());
|
||||
|
|
|
|||
|
|
@ -27,7 +27,7 @@ it('removes a peer via the RemoveWireguardPeer job', function () {
|
|||
|
||||
(new App\Provisioning\Jobs\RemoveWireguardPeer('PK'))->handle($hub);
|
||||
|
||||
expect($hub->peers())->toBe([]);
|
||||
expect($hub->peerIps())->toBe([]);
|
||||
});
|
||||
|
||||
it('provides a deterministic VM lifecycle (FakeProxmoxClient)', function () {
|
||||
|
|
@ -72,10 +72,10 @@ it('allocates ips and tracks peers (FakeWireguardHub)', function () {
|
|||
expect($a)->not->toBe($b);
|
||||
|
||||
$hub->addPeer('PUBKEY', '10.66.0.9');
|
||||
expect($hub->peers())->toBe(['PUBKEY' => '10.66.0.9']);
|
||||
expect($hub->peerIps())->toBe(['PUBKEY' => '10.66.0.9']);
|
||||
|
||||
$hub->removePeer('PUBKEY');
|
||||
expect($hub->peers())->toBe([]);
|
||||
expect($hub->peerIps())->toBe([]);
|
||||
});
|
||||
|
||||
it('allocates addresses within a /24 subnet, skipping hub and used (LocalWireguardHub)', function () {
|
||||
|
|
|
|||
Loading…
Reference in New Issue