From 71ca0e13948c7732adc679d75de49909a8d65ba4 Mon Sep 17 00:00:00 2001 From: nexxo Date: Sat, 25 Jul 2026 21:38:12 +0200 Subject: [PATCH] feat(admin): VPN access management with live peer state MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- app/Livewire/Admin/ConfirmDeleteVpnPeer.php | 51 +++++ app/Livewire/Admin/Vpn.php | 161 ++++++++++++++ app/Models/VpnPeer.php | 74 +++++++ app/Provisioning/Jobs/ApplyVpnPeer.php | 54 +++++ app/Provisioning/Jobs/SyncVpnPeers.php | 90 ++++++++ app/Services/Wireguard/FakeWireguardHub.php | 25 ++- app/Services/Wireguard/Keypair.php | 37 ++++ app/Services/Wireguard/LocalWireguardHub.php | 30 ++- app/Services/Wireguard/PeerSnapshot.php | 41 ++++ app/Services/Wireguard/WireguardHub.php | 9 + app/Support/Bytes.php | 26 +++ database/factories/VpnPeerFactory.php | 27 +++ ...26_07_25_200000_create_vpn_peers_table.php | 44 ++++ ...07_25_200001_add_vpn_manage_capability.php | 33 +++ lang/de/admin.php | 1 + lang/de/common.php | 1 + lang/de/vpn.php | 60 +++++ lang/en/admin.php | 1 + lang/en/common.php | 1 + lang/en/vpn.php | 60 +++++ resources/views/components/ui/icon.blade.php | 4 + resources/views/layouts/admin.blade.php | 20 +- .../admin/confirm-delete-vpn-peer.blade.php | 22 ++ resources/views/livewire/admin/vpn.blade.php | 160 ++++++++++++++ routes/console.php | 7 + routes/web.php | 1 + tests/Feature/Admin/VpnTest.php | 209 ++++++++++++++++++ tests/Feature/Provisioning/HostStepsTest.php | 2 +- tests/Feature/Provisioning/ServicesTest.php | 6 +- 29 files changed, 1241 insertions(+), 16 deletions(-) create mode 100644 app/Livewire/Admin/ConfirmDeleteVpnPeer.php create mode 100644 app/Livewire/Admin/Vpn.php create mode 100644 app/Models/VpnPeer.php create mode 100644 app/Provisioning/Jobs/ApplyVpnPeer.php create mode 100644 app/Provisioning/Jobs/SyncVpnPeers.php create mode 100644 app/Services/Wireguard/Keypair.php create mode 100644 app/Services/Wireguard/PeerSnapshot.php create mode 100644 app/Support/Bytes.php create mode 100644 database/factories/VpnPeerFactory.php create mode 100644 database/migrations/2026_07_25_200000_create_vpn_peers_table.php create mode 100644 database/migrations/2026_07_25_200001_add_vpn_manage_capability.php create mode 100644 lang/de/vpn.php create mode 100644 lang/en/vpn.php create mode 100644 resources/views/livewire/admin/confirm-delete-vpn-peer.blade.php create mode 100644 resources/views/livewire/admin/vpn.blade.php create mode 100644 tests/Feature/Admin/VpnTest.php diff --git a/app/Livewire/Admin/ConfirmDeleteVpnPeer.php b/app/Livewire/Admin/ConfirmDeleteVpnPeer.php new file mode 100644 index 0000000..b9aa9a0 --- /dev/null +++ b/app/Livewire/Admin/ConfirmDeleteVpnPeer.php @@ -0,0 +1,51 @@ +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'); + } +} diff --git a/app/Livewire/Admin/Vpn.php b/app/Livewire/Admin/Vpn.php new file mode 100644 index 0000000..7b1be51 --- /dev/null +++ b/app/Livewire/Admin/Vpn.php @@ -0,0 +1,161 @@ +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'), + ]); + } +} diff --git a/app/Models/VpnPeer.php b/app/Models/VpnPeer.php new file mode 100644 index 0000000..9dd59e8 --- /dev/null +++ b/app/Models/VpnPeer.php @@ -0,0 +1,74 @@ + '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'; + } +} diff --git a/app/Provisioning/Jobs/ApplyVpnPeer.php b/app/Provisioning/Jobs/ApplyVpnPeer.php new file mode 100644 index 0000000..6e6a53e --- /dev/null +++ b/app/Provisioning/Jobs/ApplyVpnPeer.php @@ -0,0 +1,54 @@ +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(), + ]); + } +} diff --git a/app/Provisioning/Jobs/SyncVpnPeers.php b/app/Provisioning/Jobs/SyncVpnPeers.php new file mode 100644 index 0000000..d47e01e --- /dev/null +++ b/app/Provisioning/Jobs/SyncVpnPeers.php @@ -0,0 +1,90 @@ +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]); + } +} diff --git a/app/Services/Wireguard/FakeWireguardHub.php b/app/Services/Wireguard/FakeWireguardHub.php index 0faa212..7e6422a 100644 --- a/app/Services/Wireguard/FakeWireguardHub.php +++ b/app/Services/Wireguard/FakeWireguardHub.php @@ -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 */ - 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; } diff --git a/app/Services/Wireguard/Keypair.php b/app/Services/Wireguard/Keypair.php new file mode 100644 index 0000000..2ba89f5 --- /dev/null +++ b/app/Services/Wireguard/Keypair.php @@ -0,0 +1,37 @@ +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'); diff --git a/app/Services/Wireguard/PeerSnapshot.php b/app/Services/Wireguard/PeerSnapshot.php new file mode 100644 index 0000000..d92c48a --- /dev/null +++ b/app/Services/Wireguard/PeerSnapshot.php @@ -0,0 +1,41 @@ + 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 $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], + ); + } +} diff --git a/app/Services/Wireguard/WireguardHub.php b/app/Services/Wireguard/WireguardHub.php index f9289e5..4edc194 100644 --- a/app/Services/Wireguard/WireguardHub.php +++ b/app/Services/Wireguard/WireguardHub.php @@ -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 + */ + public function peers(): array; } diff --git a/app/Support/Bytes.php b/app/Support/Bytes.php new file mode 100644 index 0000000..ed742f8 --- /dev/null +++ b/app/Support/Bytes.php @@ -0,0 +1,26 @@ += 100 ? 0 : $decimals; + + return number_format($value, $decimals, ',', '.').' '.$units[$power - 1]; + } +} diff --git a/database/factories/VpnPeerFactory.php b/database/factories/VpnPeerFactory.php new file mode 100644 index 0000000..c4736d3 --- /dev/null +++ b/database/factories/VpnPeerFactory.php @@ -0,0 +1,27 @@ + $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]); + } +} diff --git a/database/migrations/2026_07_25_200000_create_vpn_peers_table.php b/database/migrations/2026_07_25_200000_create_vpn_peers_table.php new file mode 100644 index 0000000..8037d5c --- /dev/null +++ b/database/migrations/2026_07_25_200000_create_vpn_peers_table.php @@ -0,0 +1,44 @@ +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'); + } +}; diff --git a/database/migrations/2026_07_25_200001_add_vpn_manage_capability.php b/database/migrations/2026_07_25_200001_add_vpn_manage_capability.php new file mode 100644 index 0000000..661dd99 --- /dev/null +++ b/database/migrations/2026_07_25_200001_add_vpn_manage_capability.php @@ -0,0 +1,33 @@ +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(); + } +}; diff --git a/lang/de/admin.php b/lang/de/admin.php index b709732..7f81f6f 100644 --- a/lang/de/admin.php +++ b/lang/de/admin.php @@ -13,6 +13,7 @@ return [ 'datacenters' => 'Rechenzentren', 'provisioning' => 'Provisioning', 'maintenance' => 'Wartungen', + 'vpn' => 'VPN', 'revenue' => 'Umsatz', 'settings' => 'Einstellungen', ], diff --git a/lang/de/common.php b/lang/de/common.php index 0966525..18de928 100644 --- a/lang/de/common.php +++ b/lang/de/common.php @@ -1,6 +1,7 @@ 'Schließen', 'tagline' => 'Kontrollzentrum für Ihre Cloud.', 'sign_in' => 'Anmelden', 'save' => 'Speichern', diff --git a/lang/de/vpn.php b/lang/de/vpn.php new file mode 100644 index 0000000..ce8e800 --- /dev/null +++ b/lang/de/vpn.php @@ -0,0 +1,60 @@ + '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.', +]; diff --git a/lang/en/admin.php b/lang/en/admin.php index e0d5cca..2187d96 100644 --- a/lang/en/admin.php +++ b/lang/en/admin.php @@ -13,6 +13,7 @@ return [ 'datacenters' => 'Datacenters', 'provisioning' => 'Provisioning', 'maintenance' => 'Maintenance', + 'vpn' => 'VPN', 'revenue' => 'Revenue', 'settings' => 'Settings', ], diff --git a/lang/en/common.php b/lang/en/common.php index 5807a70..aef88d2 100644 --- a/lang/en/common.php +++ b/lang/en/common.php @@ -1,6 +1,7 @@ 'Close', 'tagline' => 'Control center for your cloud.', 'sign_in' => 'Sign in', 'save' => 'Save', diff --git a/lang/en/vpn.php b/lang/en/vpn.php new file mode 100644 index 0000000..0e69260 --- /dev/null +++ b/lang/en/vpn.php @@ -0,0 +1,60 @@ + '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.', +]; diff --git a/resources/views/components/ui/icon.blade.php b/resources/views/components/ui/icon.blade.php index 5a942ce..0b5d6d2 100644 --- a/resources/views/components/ui/icon.blade.php +++ b/resources/views/components/ui/icon.blade.php @@ -18,6 +18,10 @@ 'download' => '', 'alert-triangle' => '', 'check' => '', + 'lock' => '', + 'unlock' => '', + 'copy' => '', + 'x' => '', 'plus' => '', 'server' => '', 'activity' => '', diff --git a/resources/views/layouts/admin.blade.php b/resources/views/layouts/admin.blade.php index 381e0a5..80021ff 100644 --- a/resources/views/layouts/admin.blade.php +++ b/resources/views/layouts/admin.blade.php @@ -29,15 +29,17 @@