feat(vpn): ownership, a Developer role, and password-gated config retrieval
Reworked after a design consultation with Codex, which pushed back on my first proposal in three useful ways. Ownership and rights: - vpn.manage split into vpn.view.all and vpn.manage.all. Seeing an access is not managing it, and neither is holding its private key. - Record-level rules live in VpnPeerPolicy, not in permissions: an access belongs to a person, and ownership is what grants sight of it. Every operator reaches the page, but sees only their own unless they may see all. - Issuing an access is not self-service — it reaches the management network, so it needs vpn.manage.all even for oneself. - New Developer role: sees everything, manages nothing. Writing code does not imply authority over other people's access. - kind (staff|host|system) replaces a null user_id that had to mean two different things at once. Config storage, opt-in per access: - Downloading is owner-only — explicitly NOT for view.all or manage.all. An admin who needs access revokes this one and issues their own, which keeps the record of who holds what honest. - The password is asked on EVERY retrieval, rate-limited. Laravel's password.confirm keeps a 15-minute session stamp, which would authorise unlimited later downloads from an unattended browser. - Stored under VPN_CONFIG_KEY, not APP_KEY: a leaked application key must not also hand over the management network. Purged on revocation and when a staff member is revoked — console taken away, tunnel left open was the worst case. - The plaintext never enters a Livewire snapshot (the page polls every five seconds); the component carries an opaque handle instead. Also: copy button works without HTTPS again (navigator.clipboard only exists in a secure context, so over http it silently did nothing), plus config download as a file and a QR code for the mobile apps. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>feat/portal-design
parent
cad245c5df
commit
0d9b62eb50
|
|
@ -163,7 +163,12 @@ MONITORING_ATTEMPTS=2
|
|||
# dies ist die zweite Verteidigungslinie IN der App: auf jedem anderen Host
|
||||
# antwortet /admin mit 404 (nicht 403 — verrät nicht, dass es die Konsole gibt).
|
||||
# Komma-getrennt. LEER = keine Einschränkung (aktueller Dev-Stand).
|
||||
# Beispiel: ADMIN_HOSTS=admin.dev.clupilot.com,10.10.90.185
|
||||
# Beispiel: # Schluessel fuer gespeicherte VPN-Konfigurationen (32 Byte, base64).
|
||||
# Bewusst getrennt von APP_KEY. Leer = Speichern deaktiviert.
|
||||
# Erzeugen: head -c 32 /dev/urandom | base64
|
||||
VPN_CONFIG_KEY=
|
||||
|
||||
ADMIN_HOSTS=admin.dev.clupilot.com,10.10.90.185
|
||||
ADMIN_HOSTS=admin.dev.clupilot.com,10.10.90.185,localhost,127.0.0.1
|
||||
|
||||
# ── Nextcloud blueprint template ─────────────────────────────────────────
|
||||
|
|
|
|||
|
|
@ -21,8 +21,10 @@ class ConfirmDeleteVpnPeer extends ModalComponent
|
|||
|
||||
public function mount(string $uuid): void
|
||||
{
|
||||
$this->authorize('vpn.manage'); // modals are reachable without the route middleware
|
||||
// Modals are reachable without the route middleware, so this is the
|
||||
// real guard, not a convenience check.
|
||||
$peer = VpnPeer::query()->with('host')->where('uuid', $uuid)->firstOrFail();
|
||||
$this->authorize('delete', $peer);
|
||||
$this->uuid = $uuid;
|
||||
$this->name = $peer->name;
|
||||
$this->hostName = $peer->host?->name;
|
||||
|
|
@ -30,10 +32,12 @@ class ConfirmDeleteVpnPeer extends ModalComponent
|
|||
|
||||
public function delete(): void
|
||||
{
|
||||
$this->authorize('vpn.manage');
|
||||
|
||||
$peer = VpnPeer::query()->where('uuid', $this->uuid)->first();
|
||||
if ($peer !== null) {
|
||||
$this->authorize('delete', $peer);
|
||||
|
||||
// A tombstone that still carries a usable private key is a liability.
|
||||
$peer->purgeSecret();
|
||||
// Soft-delete, so the row survives as a tombstone until the hub has
|
||||
// actually dropped the peer. A hard delete here would let the next
|
||||
// sync adopt the still-present peer back as a live access —
|
||||
|
|
|
|||
|
|
@ -4,6 +4,8 @@ namespace App\Livewire\Admin;
|
|||
|
||||
use App\Models\Customer;
|
||||
use App\Models\User;
|
||||
use App\Models\VpnPeer;
|
||||
use App\Provisioning\Jobs\ApplyVpnPeer;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use Illuminate\Support\Facades\Hash;
|
||||
use Illuminate\Support\Str;
|
||||
|
|
@ -169,6 +171,15 @@ class Settings extends Component
|
|||
$target->syncRoles([]);
|
||||
$target->update(['is_admin' => false]);
|
||||
|
||||
// Taking away the console but leaving the tunnel would be the worst
|
||||
// of both: no access to the panel, still a route into the
|
||||
// management network. Same revocation path as the VPN page uses.
|
||||
foreach (VpnPeer::query()->where('user_id', $target->id)->get() as $peer) {
|
||||
$peer->purgeSecret();
|
||||
$peer->delete();
|
||||
ApplyVpnPeer::dispatch($peer->public_key, null, false);
|
||||
}
|
||||
|
||||
return 'revoked';
|
||||
});
|
||||
|
||||
|
|
|
|||
|
|
@ -6,10 +6,16 @@ use App\Models\Host;
|
|||
use App\Models\VpnPeer;
|
||||
use App\Provisioning\Jobs\ApplyVpnPeer;
|
||||
use App\Provisioning\Jobs\SyncVpnPeers;
|
||||
use App\Models\User;
|
||||
use App\Services\Wireguard\ConfigHandoff;
|
||||
use App\Services\Wireguard\ConfigVault;
|
||||
use App\Services\Wireguard\Keypair;
|
||||
use App\Services\Wireguard\QrCode;
|
||||
use App\Services\Wireguard\WireguardHub;
|
||||
use Illuminate\Database\QueryException;
|
||||
use Illuminate\Support\Facades\Cache;
|
||||
use Illuminate\Support\Str;
|
||||
use Illuminate\Validation\Rule;
|
||||
use Livewire\Attributes\Layout;
|
||||
use Livewire\Attributes\On;
|
||||
use Livewire\Component;
|
||||
|
|
@ -27,14 +33,23 @@ class Vpn extends Component
|
|||
/** 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;
|
||||
/** Opaque handle for the freshly created config; see ConfigHandoff. */
|
||||
public ?string $configToken = null;
|
||||
|
||||
public ?string $newConfigName = null;
|
||||
|
||||
/** Owner of the new access. Defaults to the operator creating it. */
|
||||
public ?int $ownerId = null;
|
||||
|
||||
/** Keep the config so its owner can fetch it again behind their password. */
|
||||
public bool $storeConfig = false;
|
||||
|
||||
public bool $showQr = false;
|
||||
|
||||
public function mount(): void
|
||||
{
|
||||
$this->authorize('vpn.manage');
|
||||
$this->authorize('viewAny', VpnPeer::class);
|
||||
$this->ownerId = auth()->id();
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
@ -45,19 +60,44 @@ class Vpn extends Component
|
|||
*/
|
||||
public function hydrate(): void
|
||||
{
|
||||
$this->authorize('vpn.manage');
|
||||
$this->authorize('viewAny', VpnPeer::class);
|
||||
}
|
||||
|
||||
public function create(): void
|
||||
{
|
||||
$this->authorize('vpn.manage');
|
||||
$this->authorize('create', VpnPeer::class);
|
||||
|
||||
$this->validate([
|
||||
'name' => 'required|string|max:255',
|
||||
'publicKey' => 'nullable|string|max:64',
|
||||
// An access belongs to a person, and only to an operator: a customer
|
||||
// account must never own a way into the management network.
|
||||
'ownerId' => ['required', Rule::exists('users', 'id')],
|
||||
]);
|
||||
|
||||
$owner = User::query()->find($this->ownerId);
|
||||
if ($owner === null || ! $owner->isOperator()) {
|
||||
$this->addError('ownerId', __('vpn.owner_must_be_operator'));
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
// Storing needs a key. Without one we would either write the credential
|
||||
// in the clear or pretend we stored it — both worse than saying no.
|
||||
if ($this->storeConfig && ! ConfigVault::available()) {
|
||||
$this->addError('storeConfig', __('vpn.vault_unavailable'));
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
$ownKey = trim($this->publicKey) !== '';
|
||||
|
||||
if ($this->storeConfig && $ownKey) {
|
||||
$this->addError('storeConfig', __('vpn.cannot_store_foreign_key'));
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
if ($ownKey && ! Keypair::isValidKey(trim($this->publicKey))) {
|
||||
$this->addError('publicKey', __('vpn.invalid_key'));
|
||||
|
||||
|
|
@ -94,6 +134,8 @@ class Vpn extends Component
|
|||
|
||||
return VpnPeer::create([
|
||||
'name' => trim($this->name),
|
||||
'kind' => VpnPeer::KIND_STAFF,
|
||||
'user_id' => $this->ownerId,
|
||||
'public_key' => $publicKey,
|
||||
'allowed_ip' => $hub->allocateIp(),
|
||||
'enabled' => true,
|
||||
|
|
@ -118,21 +160,31 @@ class Vpn extends Component
|
|||
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;
|
||||
if ($keypair !== null) {
|
||||
$config = $this->clientConfig($keypair, $peer->allowed_ip);
|
||||
$this->configToken = ConfigHandoff::put($config);
|
||||
$this->newConfigName = $peer->name;
|
||||
|
||||
$this->reset('name', 'publicKey');
|
||||
if ($this->storeConfig) {
|
||||
$peer->forceFill(['config_secret' => ConfigVault::encrypt($config)])->saveQuietly();
|
||||
}
|
||||
}
|
||||
|
||||
$this->reset('name', 'publicKey', 'storeConfig');
|
||||
$this->ownerId = auth()->id();
|
||||
$this->dispatch('notify', message: __('vpn.created'));
|
||||
}
|
||||
|
||||
public function toggle(string $uuid): void
|
||||
{
|
||||
$this->authorize('vpn.manage');
|
||||
|
||||
// Looked up globally and then judged by the policy: filtering it out of
|
||||
// the query here would turn "not allowed" into a button that silently
|
||||
// does nothing, which is exactly how the portal used to lie to people.
|
||||
$peer = VpnPeer::query()->where('uuid', $uuid)->first();
|
||||
if ($peer === null) {
|
||||
return;
|
||||
}
|
||||
$this->authorize('block', $peer);
|
||||
|
||||
$peer->update(['enabled' => ! $peer->enabled]);
|
||||
ApplyVpnPeer::dispatch($peer->public_key, $peer->allowed_ip, $peer->enabled);
|
||||
|
|
@ -148,7 +200,41 @@ class Vpn extends Component
|
|||
|
||||
public function dismissConfig(): void
|
||||
{
|
||||
$this->reset('newConfig', 'newConfigName');
|
||||
ConfigHandoff::forget($this->configToken);
|
||||
$this->reset('configToken', 'newConfigName', 'showQr');
|
||||
}
|
||||
|
||||
/**
|
||||
* The list this operator is allowed to see: their own accesses, plus
|
||||
* everything when they hold vpn.view.all. Filtering in the query as well as
|
||||
* in the actions, so a forged uuid cannot reach a peer the page never showed.
|
||||
*/
|
||||
private function visiblePeers()
|
||||
{
|
||||
$query = VpnPeer::query();
|
||||
|
||||
if (! auth()->user()?->can('vpn.view.all')) {
|
||||
$query->where('kind', VpnPeer::KIND_STAFF)->where('user_id', auth()->id());
|
||||
}
|
||||
|
||||
return $query;
|
||||
}
|
||||
|
||||
public function toggleQr(): void
|
||||
{
|
||||
$this->showQr = ! $this->showQr;
|
||||
}
|
||||
|
||||
/**
|
||||
* wg-quick takes the interface name from the filename, and Linux caps
|
||||
* interface names at 15 characters — so the label is slugged and cut rather
|
||||
* than handing the operator a file that fails to come up.
|
||||
*/
|
||||
public function configFilename(): string
|
||||
{
|
||||
$slug = Str::slug((string) $this->newConfigName) ?: 'clupilot';
|
||||
|
||||
return Str::limit($slug, 15, '').'.conf';
|
||||
}
|
||||
|
||||
/** Polled by the view; throttled so a room full of open tabs cannot flood the queue. */
|
||||
|
|
@ -182,7 +268,14 @@ class Vpn extends Component
|
|||
$hub = app(WireguardHub::class);
|
||||
|
||||
return view('livewire.admin.vpn', [
|
||||
'peers' => VpnPeer::query()->with('host')->orderByDesc('present')->orderBy('name')->get(),
|
||||
'newConfig' => $config = ConfigHandoff::get($this->configToken),
|
||||
'qrSvg' => $this->showQr && $config !== null ? QrCode::svg($config) : null,
|
||||
'peers' => $this->visiblePeers()->with(['host', 'owner'])->orderByDesc('present')->orderBy('name')->get(),
|
||||
'operators' => User::query()
|
||||
->whereHas('roles', fn ($q) => $q->whereIn('name', User::OPERATOR_ROLES))
|
||||
->orderBy('name')->get(['id', 'name', 'email']),
|
||||
'canManage' => auth()->user()?->can('vpn.manage.all') ?? false,
|
||||
'vaultAvailable' => ConfigVault::available(),
|
||||
'hubEndpoint' => $hub->endpoint(),
|
||||
'hubPublicKey' => $hub->publicKey(),
|
||||
'lastSync' => VpnPeer::query()->max('observed_at'),
|
||||
|
|
|
|||
|
|
@ -0,0 +1,111 @@
|
|||
<?php
|
||||
|
||||
namespace App\Livewire\Admin;
|
||||
|
||||
use App\Models\VpnPeer;
|
||||
use App\Services\Wireguard\ConfigHandoff;
|
||||
use App\Services\Wireguard\ConfigVault;
|
||||
use App\Services\Wireguard\QrCode;
|
||||
use Illuminate\Support\Facades\Hash;
|
||||
use Illuminate\Support\Facades\Log;
|
||||
use Illuminate\Support\Facades\RateLimiter;
|
||||
use Illuminate\Support\Str;
|
||||
use LivewireUI\Modal\ModalComponent;
|
||||
|
||||
/**
|
||||
* Retrieving a stored VPN config again, behind the owner's own password.
|
||||
*
|
||||
* The password is asked for on EVERY retrieval, not once per session: Laravel's
|
||||
* password.confirm middleware keeps a 15-minute session stamp, which would
|
||||
* authorise unlimited later downloads from an unattended browser — not what
|
||||
* "enter your password to download it" means.
|
||||
*/
|
||||
class VpnConfigAccess extends ModalComponent
|
||||
{
|
||||
public string $uuid;
|
||||
|
||||
public string $name = '';
|
||||
|
||||
public string $password = '';
|
||||
|
||||
/** Opaque handle; the plaintext never enters the component snapshot. */
|
||||
public ?string $token = null;
|
||||
|
||||
public bool $showQr = false;
|
||||
|
||||
public function mount(string $uuid): void
|
||||
{
|
||||
$peer = VpnPeer::query()->where('uuid', $uuid)->firstOrFail();
|
||||
$this->authorize('downloadConfig', $peer);
|
||||
|
||||
$this->uuid = $uuid;
|
||||
$this->name = $peer->name;
|
||||
}
|
||||
|
||||
public function reveal(): void
|
||||
{
|
||||
// A retry must not still be showing the previous attempt's complaint.
|
||||
$this->resetErrorBag('password');
|
||||
|
||||
$peer = VpnPeer::query()->where('uuid', $this->uuid)->firstOrFail();
|
||||
$this->authorize('downloadConfig', $peer);
|
||||
|
||||
// Rate-limited per user, so a stolen session cannot grind the password.
|
||||
$key = 'vpn-config:'.auth()->id();
|
||||
if (RateLimiter::tooManyAttempts($key, 5)) {
|
||||
$this->addError('password', __('vpn.too_many_attempts', ['seconds' => RateLimiter::availableIn($key)]));
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
if (! Hash::check($this->password, auth()->user()->password)) {
|
||||
RateLimiter::hit($key, 300);
|
||||
Log::warning('VPN config download refused: wrong password', [
|
||||
'user_id' => auth()->id(), 'peer' => $peer->uuid,
|
||||
]);
|
||||
$this->addError('password', __('vpn.wrong_password'));
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
RateLimiter::clear($key);
|
||||
|
||||
$plaintext = ConfigVault::decrypt((string) $peer->config_secret);
|
||||
if ($plaintext === null) {
|
||||
// Wrong or rotated key, or a tampered record. Never guess.
|
||||
$this->addError('password', __('vpn.config_unreadable'));
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
$peer->forceFill([
|
||||
'last_downloaded_at' => now(),
|
||||
'download_count' => $peer->download_count + 1,
|
||||
])->saveQuietly();
|
||||
|
||||
Log::info('VPN config downloaded', ['user_id' => auth()->id(), 'peer' => $peer->uuid]);
|
||||
|
||||
$this->reset('password');
|
||||
$this->token = ConfigHandoff::put($plaintext);
|
||||
}
|
||||
|
||||
public function toggleQr(): void
|
||||
{
|
||||
$this->showQr = ! $this->showQr;
|
||||
}
|
||||
|
||||
public function filename(): string
|
||||
{
|
||||
return Str::limit(Str::slug($this->name) ?: 'clupilot', 15, '').'.conf';
|
||||
}
|
||||
|
||||
public function render()
|
||||
{
|
||||
$config = ConfigHandoff::get($this->token);
|
||||
|
||||
return view('livewire.admin.vpn-config-access', [
|
||||
'config' => $config,
|
||||
'qrSvg' => $config !== null && $this->showQr ? QrCode::svg($config) : null,
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
|
@ -20,7 +20,7 @@ class User extends Authenticatable
|
|||
use HasFactory, HasRoles, Notifiable, TwoFactorAuthenticatable;
|
||||
|
||||
/** The five operator roles that grant access to the admin console. */
|
||||
public const OPERATOR_ROLES = ['Owner', 'Admin', 'Support', 'Billing', 'Read-only'];
|
||||
public const OPERATOR_ROLES = ['Owner', 'Admin', 'Support', 'Billing', 'Read-only', 'Developer'];
|
||||
|
||||
/** True when this user holds an operator role (the RBAC console boundary). */
|
||||
public function isOperator(): bool
|
||||
|
|
|
|||
|
|
@ -19,6 +19,15 @@ class VpnPeer extends Model
|
|||
* does not flap for an idle-but-connected peer. */
|
||||
public const ONLINE_AFTER_MINUTES = 3;
|
||||
|
||||
/** A person's access. Requires an owner. */
|
||||
public const KIND_STAFF = 'staff';
|
||||
|
||||
/** Registered by the host pipeline for a Proxmox host. Requires host_id. */
|
||||
public const KIND_HOST = 'host';
|
||||
|
||||
/** Found on the interface without a match — adopted so it stays visible. */
|
||||
public const KIND_SYSTEM = 'system';
|
||||
|
||||
protected $guarded = [];
|
||||
|
||||
protected function casts(): array
|
||||
|
|
@ -27,6 +36,8 @@ class VpnPeer extends Model
|
|||
'enabled' => 'boolean',
|
||||
'present' => 'boolean',
|
||||
'last_handshake_at' => 'datetime',
|
||||
'secret_purged_at' => 'datetime',
|
||||
'last_downloaded_at' => 'datetime',
|
||||
'observed_at' => 'datetime',
|
||||
'rx_bytes' => 'integer',
|
||||
'tx_bytes' => 'integer',
|
||||
|
|
@ -48,6 +59,30 @@ class VpnPeer extends Model
|
|||
return $this->belongsTo(User::class, 'created_by');
|
||||
}
|
||||
|
||||
/** The person this access belongs to (staff peers only). */
|
||||
public function owner(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(User::class, 'user_id');
|
||||
}
|
||||
|
||||
public function hasStoredConfig(): bool
|
||||
{
|
||||
return $this->config_secret !== null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Overwrite the stored credential. Called on revocation: a tombstone that
|
||||
* still carries a usable private key is a liability, not a record.
|
||||
*/
|
||||
public function purgeSecret(): void
|
||||
{
|
||||
if ($this->config_secret === null) {
|
||||
return;
|
||||
}
|
||||
|
||||
$this->forceFill(['config_secret' => null, 'secret_purged_at' => now()])->saveQuietly();
|
||||
}
|
||||
|
||||
public function isOnline(): bool
|
||||
{
|
||||
return $this->enabled
|
||||
|
|
|
|||
|
|
@ -0,0 +1,76 @@
|
|||
<?php
|
||||
|
||||
namespace App\Policies;
|
||||
|
||||
use App\Models\User;
|
||||
use App\Models\VpnPeer;
|
||||
|
||||
/**
|
||||
* Who may see and do what with a VPN access.
|
||||
*
|
||||
* Ownership is record-level and lives here; permissions describe elevated
|
||||
* capabilities (vpn.view.all, vpn.manage.all) and are checked through Gate.
|
||||
* The two are kept apart on purpose: seeing an access is not managing it, and
|
||||
* neither of them is holding its private key.
|
||||
*
|
||||
* There is no Gate::before super-admin shortcut in this application, so these
|
||||
* rules are the whole story — in particular downloadConfig() cannot be
|
||||
* out-voted by a role.
|
||||
*/
|
||||
class VpnPeerPolicy
|
||||
{
|
||||
public function viewAny(User $user): bool
|
||||
{
|
||||
// Every operator may at least reach the page to find their own access.
|
||||
return $user->isOperator();
|
||||
}
|
||||
|
||||
public function view(User $user, VpnPeer $peer): bool
|
||||
{
|
||||
return $this->owns($user, $peer) || $user->can('vpn.view.all');
|
||||
}
|
||||
|
||||
public function create(User $user): bool
|
||||
{
|
||||
// Not self-service: an access reaches the management network, so issuing
|
||||
// one is an administrative act even for oneself.
|
||||
return $user->can('vpn.manage.all');
|
||||
}
|
||||
|
||||
/** Rename, reassign, change stored-config settings. */
|
||||
public function update(User $user, VpnPeer $peer): bool
|
||||
{
|
||||
return $user->can('vpn.manage.all');
|
||||
}
|
||||
|
||||
/** Block / unblock. Owners may switch their own access off and on again. */
|
||||
public function block(User $user, VpnPeer $peer): bool
|
||||
{
|
||||
return $user->can('vpn.manage.all') || $this->owns($user, $peer);
|
||||
}
|
||||
|
||||
public function delete(User $user, VpnPeer $peer): bool
|
||||
{
|
||||
return $user->can('vpn.manage.all') || $this->owns($user, $peer);
|
||||
}
|
||||
|
||||
/**
|
||||
* The private half of someone's credential. Owner only — explicitly NOT
|
||||
* vpn.view.all and NOT vpn.manage.all: an administrator who needs access
|
||||
* revokes this one and issues themselves their own, which keeps the record
|
||||
* of who holds what intact.
|
||||
*/
|
||||
public function downloadConfig(User $user, VpnPeer $peer): bool
|
||||
{
|
||||
return $this->owns($user, $peer)
|
||||
&& $peer->config_secret !== null
|
||||
&& ! $peer->trashed();
|
||||
}
|
||||
|
||||
private function owns(User $user, VpnPeer $peer): bool
|
||||
{
|
||||
return $peer->kind === VpnPeer::KIND_STAFF
|
||||
&& $peer->user_id !== null
|
||||
&& $peer->user_id === $user->id;
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,45 @@
|
|||
<?php
|
||||
|
||||
namespace App\Services\Wireguard;
|
||||
|
||||
use Illuminate\Support\Facades\Cache;
|
||||
use Illuminate\Support\Str;
|
||||
use SensitiveParameter;
|
||||
|
||||
/**
|
||||
* Short-lived server-side holding area for a plaintext config on its way to the
|
||||
* browser.
|
||||
*
|
||||
* Livewire round-trips every public property in the component snapshot — and
|
||||
* the VPN page polls every five seconds. Keeping the private key in a public
|
||||
* property would therefore send that credential over the wire again and again,
|
||||
* and park it in the DOM's snapshot attribute for the life of the page. The
|
||||
* component holds an opaque token instead; the plaintext is looked up during
|
||||
* render and never enters the snapshot.
|
||||
*/
|
||||
final class ConfigHandoff
|
||||
{
|
||||
private const PREFIX = 'vpn:handoff:';
|
||||
|
||||
private const TTL_SECONDS = 600;
|
||||
|
||||
public static function put(#[SensitiveParameter] string $plaintext): string
|
||||
{
|
||||
$token = Str::random(40);
|
||||
Cache::put(self::PREFIX.$token, $plaintext, self::TTL_SECONDS);
|
||||
|
||||
return $token;
|
||||
}
|
||||
|
||||
public static function get(?string $token): ?string
|
||||
{
|
||||
return $token === null ? null : Cache::get(self::PREFIX.$token);
|
||||
}
|
||||
|
||||
public static function forget(?string $token): void
|
||||
{
|
||||
if ($token !== null) {
|
||||
Cache::forget(self::PREFIX.$token);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,79 @@
|
|||
<?php
|
||||
|
||||
namespace App\Services\Wireguard;
|
||||
|
||||
use RuntimeException;
|
||||
use SensitiveParameter;
|
||||
|
||||
/**
|
||||
* Encrypts a stored WireGuard config at rest.
|
||||
*
|
||||
* Deliberately NOT Laravel's Crypt: that uses APP_KEY, which every part of the
|
||||
* app already holds and which ends up in more places than it should. VPN
|
||||
* configs are credentials for the management network, so they get their own
|
||||
* key (VPN_CONFIG_KEY) that can be rotated without invalidating sessions,
|
||||
* signed URLs and everything else APP_KEY protects.
|
||||
*
|
||||
* The ciphertext carries a version prefix so a future key rotation can tell
|
||||
* old records apart instead of guessing.
|
||||
*
|
||||
* What this does NOT protect against: someone who has both the database and
|
||||
* the .env. There is no way around that on a single box — it buys protection
|
||||
* against a database-only leak (a dump, a backup, a stray replica).
|
||||
*/
|
||||
final class ConfigVault
|
||||
{
|
||||
private const VERSION = 'v1';
|
||||
|
||||
public static function available(): bool
|
||||
{
|
||||
return self::rawKey() !== null;
|
||||
}
|
||||
|
||||
public static function encrypt(#[SensitiveParameter] string $plaintext): string
|
||||
{
|
||||
$key = self::rawKey() ?? throw new RuntimeException(
|
||||
'VPN_CONFIG_KEY is not set — refusing to store a VPN config in plaintext.'
|
||||
);
|
||||
|
||||
$nonce = random_bytes(SODIUM_CRYPTO_SECRETBOX_NONCEBYTES);
|
||||
|
||||
return self::VERSION.':'.base64_encode($nonce.sodium_crypto_secretbox($plaintext, $nonce, $key));
|
||||
}
|
||||
|
||||
public static function decrypt(string $ciphertext): ?string
|
||||
{
|
||||
$key = self::rawKey();
|
||||
if ($key === null || ! str_starts_with($ciphertext, self::VERSION.':')) {
|
||||
return null;
|
||||
}
|
||||
|
||||
$raw = base64_decode(substr($ciphertext, strlen(self::VERSION) + 1), true);
|
||||
if ($raw === false || strlen($raw) <= SODIUM_CRYPTO_SECRETBOX_NONCEBYTES) {
|
||||
return null;
|
||||
}
|
||||
|
||||
$plaintext = sodium_crypto_secretbox_open(
|
||||
substr($raw, SODIUM_CRYPTO_SECRETBOX_NONCEBYTES),
|
||||
substr($raw, 0, SODIUM_CRYPTO_SECRETBOX_NONCEBYTES),
|
||||
$key,
|
||||
);
|
||||
|
||||
// False means the key is wrong or the record was tampered with. Both are
|
||||
// "you cannot have this", not an exception to swallow somewhere upstream.
|
||||
return $plaintext === false ? null : $plaintext;
|
||||
}
|
||||
|
||||
/** @return non-empty-string|null */
|
||||
private static function rawKey(): ?string
|
||||
{
|
||||
$configured = (string) config('admin_access.vpn_config_key', '');
|
||||
if ($configured === '') {
|
||||
return null;
|
||||
}
|
||||
|
||||
$key = base64_decode(str_starts_with($configured, 'base64:') ? substr($configured, 7) : $configured, true);
|
||||
|
||||
return $key !== false && strlen($key) === SODIUM_CRYPTO_SECRETBOX_KEYBYTES ? $key : null;
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,44 @@
|
|||
<?php
|
||||
|
||||
namespace App\Services\Wireguard;
|
||||
|
||||
use BaconQrCode\Common\ErrorCorrectionLevel;
|
||||
use BaconQrCode\Encoder\Encoder;
|
||||
|
||||
/**
|
||||
* Renders a WireGuard config as a QR code the mobile apps can scan.
|
||||
*
|
||||
* Hand-rolled SVG rather than Bacon's renderer: that one needs the imagick or
|
||||
* GD backend for images, and its SVG writer pulls in a dependency chain we do
|
||||
* not otherwise need. The matrix is the only interesting part, and Bacon gives
|
||||
* us that directly.
|
||||
*/
|
||||
final class QrCode
|
||||
{
|
||||
public static function svg(string $text, int $size = 220): string
|
||||
{
|
||||
// Level L: a WireGuard config is long, and the phone is held still
|
||||
// against a screen — more redundancy would only make the code denser.
|
||||
$matrix = Encoder::encode($text, ErrorCorrectionLevel::L())->getMatrix();
|
||||
$width = $matrix->getWidth();
|
||||
$quiet = 2;
|
||||
$total = $width + 2 * $quiet;
|
||||
|
||||
$cells = '';
|
||||
for ($y = 0; $y < $width; $y++) {
|
||||
for ($x = 0; $x < $width; $x++) {
|
||||
if ($matrix->get($x, $y) === 1) {
|
||||
$cells .= sprintf('<rect x="%d" y="%d" width="1" height="1"/>', $x + $quiet, $y + $quiet);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return sprintf(
|
||||
'<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 %1$d %1$d" width="%2$d" height="%2$d" shape-rendering="crispEdges" role="img">'
|
||||
.'<rect width="%1$d" height="%1$d" fill="#ffffff"/><g fill="#000000">%3$s</g></svg>',
|
||||
$total,
|
||||
$size,
|
||||
$cells,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
@ -7,6 +7,7 @@
|
|||
"license": "MIT",
|
||||
"require": {
|
||||
"php": "^8.3",
|
||||
"bacon/bacon-qr-code": "^3.1",
|
||||
"laravel/fortify": "^1.37",
|
||||
"laravel/framework": "^13.8",
|
||||
"laravel/reverb": "^1.11",
|
||||
|
|
|
|||
|
|
@ -4,7 +4,7 @@
|
|||
"Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies",
|
||||
"This file is @generated automatically"
|
||||
],
|
||||
"content-hash": "090088615979540ccfee56eba3d133af",
|
||||
"content-hash": "4afacce87fbd845b941534e375dc082b",
|
||||
"packages": [
|
||||
{
|
||||
"name": "bacon/bacon-qr-code",
|
||||
|
|
|
|||
|
|
@ -23,4 +23,14 @@ return [
|
|||
explode(',', (string) env('ADMIN_HOSTS', ''))
|
||||
))),
|
||||
|
||||
/*
|
||||
| Key for VPN configs stored at rest (32 bytes, base64). Separate from
|
||||
| APP_KEY on purpose — see App\Services\Wireguard\ConfigVault. Empty means
|
||||
| storing configs is switched off, and the console says so rather than
|
||||
| quietly writing credentials in the clear.
|
||||
|
|
||||
| Generate with: head -c 32 /dev/urandom | base64
|
||||
*/
|
||||
'vpn_config_key' => env('VPN_CONFIG_KEY', ''),
|
||||
|
||||
];
|
||||
|
|
|
|||
|
|
@ -2,6 +2,8 @@
|
|||
|
||||
namespace Database\Factories;
|
||||
|
||||
use App\Models\User;
|
||||
use App\Models\VpnPeer;
|
||||
use App\Services\Wireguard\Keypair;
|
||||
use Illuminate\Database\Eloquent\Factories\Factory;
|
||||
|
||||
|
|
@ -13,6 +15,8 @@ class VpnPeerFactory extends Factory
|
|||
|
||||
return [
|
||||
'name' => $this->faker->userName(),
|
||||
'kind' => VpnPeer::KIND_STAFF,
|
||||
'user_id' => User::factory()->operator('Admin'),
|
||||
'public_key' => Keypair::generate()->publicKey,
|
||||
'allowed_ip' => '10.66.0.'.($octet++),
|
||||
'enabled' => true,
|
||||
|
|
@ -20,6 +24,17 @@ class VpnPeerFactory extends Factory
|
|||
];
|
||||
}
|
||||
|
||||
/** A peer registered by the host pipeline: no human owner. */
|
||||
public function forHost(): static
|
||||
{
|
||||
return $this->state(['kind' => VpnPeer::KIND_HOST, 'user_id' => null]);
|
||||
}
|
||||
|
||||
public function ownedBy(User $user): static
|
||||
{
|
||||
return $this->state(['kind' => VpnPeer::KIND_STAFF, 'user_id' => $user->id]);
|
||||
}
|
||||
|
||||
public function blocked(): static
|
||||
{
|
||||
return $this->state(['enabled' => false, 'present' => false]);
|
||||
|
|
|
|||
|
|
@ -0,0 +1,51 @@
|
|||
<?php
|
||||
|
||||
use Illuminate\Database\Migrations\Migration;
|
||||
use Illuminate\Database\Schema\Blueprint;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use Illuminate\Support\Facades\Schema;
|
||||
|
||||
/**
|
||||
* Ownership + optional stored config for VPN accesses.
|
||||
*
|
||||
* `kind` exists because a null user_id previously had to mean two different
|
||||
* things — "belongs to the admins" and "this is a host peer". A staff access
|
||||
* without an owner is a bug, a host peer with one is nonsense, and no code can
|
||||
* tell them apart from a null.
|
||||
*
|
||||
* user_id is restrictOnDelete on purpose: silently orphaning a live credential
|
||||
* into the management network is worse than refusing to delete the account.
|
||||
* Revoking a staff member also revokes their peers (Admin\Settings).
|
||||
*/
|
||||
return new class extends Migration
|
||||
{
|
||||
public function up(): void
|
||||
{
|
||||
Schema::table('vpn_peers', function (Blueprint $table) {
|
||||
$table->string('kind')->default('staff')->after('name'); // staff|host|system
|
||||
$table->foreignId('user_id')->nullable()->after('kind')->constrained()->restrictOnDelete();
|
||||
// Encrypted with VPN_CONFIG_KEY (see ConfigVault) — never APP_KEY, so
|
||||
// a leaked application key does not also hand over VPN credentials.
|
||||
$table->text('config_secret')->nullable();
|
||||
$table->timestamp('secret_purged_at')->nullable();
|
||||
$table->timestamp('last_downloaded_at')->nullable();
|
||||
$table->unsignedInteger('download_count')->default(0);
|
||||
});
|
||||
|
||||
// Existing rows: pipeline peers are host peers, the rest belong to
|
||||
// whoever created them (created_by is the only ownership hint we have).
|
||||
DB::table('vpn_peers')->whereNotNull('host_id')->update(['kind' => 'host']);
|
||||
DB::table('vpn_peers')->whereNull('host_id')->update([
|
||||
'kind' => 'staff',
|
||||
'user_id' => DB::raw('created_by'),
|
||||
]);
|
||||
}
|
||||
|
||||
public function down(): void
|
||||
{
|
||||
Schema::table('vpn_peers', function (Blueprint $table) {
|
||||
$table->dropForeign(['user_id']);
|
||||
$table->dropColumn(['kind', 'user_id', 'config_secret', 'secret_purged_at', 'last_downloaded_at', 'download_count']);
|
||||
});
|
||||
}
|
||||
};
|
||||
|
|
@ -0,0 +1,55 @@
|
|||
<?php
|
||||
|
||||
use Illuminate\Database\Migrations\Migration;
|
||||
use Spatie\Permission\Models\Permission;
|
||||
use Spatie\Permission\Models\Role;
|
||||
use Spatie\Permission\PermissionRegistrar;
|
||||
|
||||
/**
|
||||
* Splits the coarse `vpn.manage` into what it actually conflated:
|
||||
*
|
||||
* vpn.view.all — see every access, including other people's and host peers
|
||||
* vpn.manage.all — create, assign, block and revoke any access
|
||||
*
|
||||
* Seeing is not managing, and neither implies holding someone else's private
|
||||
* key: downloading a stored config is owner-only and lives in VpnPeerPolicy,
|
||||
* not in a permission — ownership is record-level, permissions are elevated
|
||||
* capabilities.
|
||||
*
|
||||
* "Developer" is a view-only role. Writing code does not imply authority over
|
||||
* other people's access to the management network, so it deliberately gets no
|
||||
* manage capability.
|
||||
*/
|
||||
return new class extends Migration
|
||||
{
|
||||
public function up(): void
|
||||
{
|
||||
app(PermissionRegistrar::class)->forgetCachedPermissions();
|
||||
|
||||
Permission::findOrCreate('vpn.view.all', 'web');
|
||||
Permission::findOrCreate('vpn.manage.all', 'web');
|
||||
|
||||
foreach (['Owner', 'Admin'] as $role) {
|
||||
Role::findOrCreate($role, 'web')->givePermissionTo(['vpn.view.all', 'vpn.manage.all']);
|
||||
}
|
||||
Role::findOrCreate('Developer', 'web')->syncPermissions(['console.view', 'vpn.view.all']);
|
||||
|
||||
Permission::query()->where('name', 'vpn.manage')->delete();
|
||||
|
||||
app(PermissionRegistrar::class)->forgetCachedPermissions();
|
||||
}
|
||||
|
||||
public function down(): void
|
||||
{
|
||||
app(PermissionRegistrar::class)->forgetCachedPermissions();
|
||||
|
||||
Permission::findOrCreate('vpn.manage', 'web');
|
||||
foreach (['Owner', 'Admin'] as $role) {
|
||||
Role::findOrCreate($role, 'web')->givePermissionTo('vpn.manage');
|
||||
}
|
||||
Role::query()->where('name', 'Developer')->delete();
|
||||
Permission::query()->whereIn('name', ['vpn.view.all', 'vpn.manage.all'])->delete();
|
||||
|
||||
app(PermissionRegistrar::class)->forgetCachedPermissions();
|
||||
}
|
||||
};
|
||||
|
|
@ -41,6 +41,11 @@ return [
|
|||
|
||||
'config_ready' => 'Konfiguration für :name',
|
||||
'config_once' => 'Der private Schlüssel wird nicht gespeichert und ist nur jetzt sichtbar.',
|
||||
'download' => 'Herunterladen',
|
||||
'copy_failed' => 'Kopieren wurde vom Browser blockiert — bitte den Text markieren oder die Datei herunterladen.',
|
||||
'qr_show' => 'QR-Code',
|
||||
'qr_hide' => 'QR ausblenden',
|
||||
'qr_hint' => 'Mit der WireGuard-App scannen.',
|
||||
'copy' => 'Kopieren',
|
||||
'copied' => 'Kopiert',
|
||||
|
||||
|
|
@ -53,6 +58,24 @@ return [
|
|||
'delete_host_warning' => 'Achtung: Dieser Zugang gehört zum Host :host. CluPilot erreicht diesen Host danach nicht mehr.',
|
||||
'delete_confirm' => 'Zugang entfernen',
|
||||
|
||||
'owner' => 'Besitzer',
|
||||
'owner_hint' => 'Nur dem Besitzer gehört der Zugang — nur er kann die Konfiguration später abrufen.',
|
||||
'owner_must_be_operator' => 'Ein VPN-Zugang darf nur einem Mitarbeiter gehören.',
|
||||
'you' => 'Sie',
|
||||
'no_owner' => 'Ohne Besitzer',
|
||||
'store_config' => 'Konfiguration speichern',
|
||||
'store_config_hint' => 'Verschlüsselt abgelegt. Der Besitzer kann sie später mit seinem Passwort erneut abrufen.',
|
||||
'vault_unavailable' => 'Speichern ist nicht möglich: VPN_CONFIG_KEY ist nicht gesetzt.',
|
||||
'cannot_store_foreign_key' => 'Bei eigenem Public Key haben wir den privaten Schlüssel nie — es gibt nichts zu speichern.',
|
||||
'get_config' => 'Konfiguration abrufen',
|
||||
'get_config_title' => 'Konfiguration für :name',
|
||||
'get_config_body' => 'Zur Sicherheit bei jedem Abruf: bitte das eigene Passwort eingeben.',
|
||||
'your_password' => 'Ihr Passwort',
|
||||
'reveal' => 'Anzeigen',
|
||||
'wrong_password' => 'Passwort stimmt nicht.',
|
||||
'too_many_attempts' => 'Zu viele Versuche. Bitte in :seconds Sekunden erneut versuchen.',
|
||||
'config_unreadable' => 'Die gespeicherte Konfiguration lässt sich nicht entschlüsseln. Bitte den Zugang neu ausstellen.',
|
||||
|
||||
'hub' => 'Hub',
|
||||
'endpoint' => 'Endpunkt',
|
||||
'hub_key' => 'Public Key',
|
||||
|
|
|
|||
|
|
@ -41,6 +41,11 @@ return [
|
|||
|
||||
'config_ready' => 'Configuration for :name',
|
||||
'config_once' => 'The private key is not stored and is only visible now.',
|
||||
'download' => 'Download',
|
||||
'copy_failed' => 'The browser blocked copying — select the text or download the file instead.',
|
||||
'qr_show' => 'QR code',
|
||||
'qr_hide' => 'Hide QR',
|
||||
'qr_hint' => 'Scan with the WireGuard app.',
|
||||
'copy' => 'Copy',
|
||||
'copied' => 'Copied',
|
||||
|
||||
|
|
@ -53,6 +58,24 @@ return [
|
|||
'delete_host_warning' => 'Careful: this access belongs to host :host. CluPilot will no longer reach that host.',
|
||||
'delete_confirm' => 'Remove access',
|
||||
|
||||
'owner' => 'Owner',
|
||||
'owner_hint' => 'The access belongs to its owner — only they can retrieve the config later.',
|
||||
'owner_must_be_operator' => 'A VPN access may only belong to a staff member.',
|
||||
'you' => 'You',
|
||||
'no_owner' => 'No owner',
|
||||
'store_config' => 'Store configuration',
|
||||
'store_config_hint' => 'Kept encrypted. The owner can retrieve it later with their password.',
|
||||
'vault_unavailable' => 'Storing is unavailable: VPN_CONFIG_KEY is not set.',
|
||||
'cannot_store_foreign_key' => 'With your own public key we never hold the private half — there is nothing to store.',
|
||||
'get_config' => 'Retrieve configuration',
|
||||
'get_config_title' => 'Configuration for :name',
|
||||
'get_config_body' => 'Asked every time, on purpose: please enter your own password.',
|
||||
'your_password' => 'Your password',
|
||||
'reveal' => 'Show',
|
||||
'wrong_password' => 'That password is not correct.',
|
||||
'too_many_attempts' => 'Too many attempts. Try again in :seconds seconds.',
|
||||
'config_unreadable' => 'The stored configuration cannot be decrypted. Please re-issue the access.',
|
||||
|
||||
'hub' => 'Hub',
|
||||
'endpoint' => 'Endpoint',
|
||||
'hub_key' => 'Public key',
|
||||
|
|
|
|||
|
|
@ -27,6 +27,9 @@
|
|||
the restriction set admin_access.hosts themselves. -->
|
||||
<env name="APP_URL" value="http://localhost" force="true"/>
|
||||
<env name="ADMIN_HOSTS" value="" force="true"/>
|
||||
<!-- Fixed test key: storing a VPN config must be exercised, and a
|
||||
random one would make a decryption failure irreproducible. -->
|
||||
<env name="VPN_CONFIG_KEY" value="Y2x1cGlsb3QtdGVzdC1vbmx5LXZwbi1rZXktMzJieXQ=" force="true"/>
|
||||
<env name="APP_MAINTENANCE_DRIVER" value="file"/>
|
||||
<env name="BCRYPT_ROUNDS" value="4" force="true"/>
|
||||
<env name="BROADCAST_CONNECTION" value="null" force="true"/>
|
||||
|
|
|
|||
|
|
@ -73,6 +73,55 @@ function applyLineGradients(cfg) {
|
|||
}
|
||||
|
||||
document.addEventListener('alpine:init', () => {
|
||||
// ── VPN config: copy / download ──────────────────────────────────────
|
||||
// navigator.clipboard only exists in a secure context, so over plain http
|
||||
// (the panel reached by IP) the copy button silently did nothing. Falls
|
||||
// back to the legacy selection copy, and says so when even that fails.
|
||||
window.Alpine.data('vpnConfigActions', (filename) => ({
|
||||
copied: false,
|
||||
failed: false,
|
||||
text() {
|
||||
return this.$refs.config.textContent;
|
||||
},
|
||||
async copy() {
|
||||
try {
|
||||
if (navigator.clipboard && window.isSecureContext) {
|
||||
await navigator.clipboard.writeText(this.text());
|
||||
} else {
|
||||
const area = document.createElement('textarea');
|
||||
area.value = this.text();
|
||||
area.setAttribute('readonly', '');
|
||||
area.style.position = 'fixed';
|
||||
area.style.opacity = '0';
|
||||
document.body.appendChild(area);
|
||||
area.select();
|
||||
const ok = document.execCommand('copy');
|
||||
document.body.removeChild(area);
|
||||
if (!ok) throw new Error('copy rejected');
|
||||
}
|
||||
this.failed = false;
|
||||
this.copied = true;
|
||||
setTimeout(() => (this.copied = false), 2000);
|
||||
} catch (e) {
|
||||
this.copied = false;
|
||||
this.failed = true;
|
||||
setTimeout(() => (this.failed = false), 6000);
|
||||
}
|
||||
},
|
||||
download() {
|
||||
// Built in the browser from what is already on the page: the private
|
||||
// key never has to travel a second time, and no route has to exist.
|
||||
const url = URL.createObjectURL(new Blob([this.text()], { type: 'text/plain' }));
|
||||
const link = document.createElement('a');
|
||||
link.href = url;
|
||||
link.download = filename;
|
||||
document.body.appendChild(link);
|
||||
link.click();
|
||||
document.body.removeChild(link);
|
||||
setTimeout(() => URL.revokeObjectURL(url), 1000);
|
||||
},
|
||||
}));
|
||||
|
||||
// ── Segmented OTP input ──────────────────────────────────────────────
|
||||
window.Alpine.data('otpInput', ({ length = 6 } = {}) => ({
|
||||
length,
|
||||
|
|
|
|||
|
|
@ -18,6 +18,7 @@
|
|||
'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"/>',
|
||||
'qr-code' => '<rect width="5" height="5" x="3" y="3" rx="1"/><rect width="5" height="5" x="16" y="3" rx="1"/><rect width="5" height="5" x="3" y="16" rx="1"/><path d="M21 16h-3a2 2 0 0 0-2 2v3"/><path d="M21 21v.01"/><path d="M12 7v3a2 2 0 0 1-2 2H7"/><path d="M3 12h.01"/><path d="M12 3h.01"/><path d="M12 16v.01"/><path d="M16 12h1"/><path d="M21 12v.01"/><path d="M12 21v-1"/>',
|
||||
'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"/>',
|
||||
|
|
|
|||
|
|
@ -0,0 +1,48 @@
|
|||
<div class="p-6">
|
||||
<h3 class="text-base font-semibold text-ink">{{ __('vpn.get_config_title', ['name' => $name]) }}</h3>
|
||||
|
||||
@if ($config === null)
|
||||
<p class="mt-1 text-sm text-muted">{{ __('vpn.get_config_body') }}</p>
|
||||
|
||||
<form wire:submit="reveal" class="mt-4 space-y-3">
|
||||
{{-- autocomplete hint keeps password managers from offering to save
|
||||
this as a new login for the site. --}}
|
||||
<x-ui.input name="password" type="password" wire:model="password"
|
||||
:label="__('vpn.your_password')" autocomplete="current-password" />
|
||||
<div class="flex justify-end gap-2">
|
||||
<x-ui.button variant="secondary" type="button" x-on:click="Livewire.dispatch('closeModal')">{{ __('vpn.cancel') }}</x-ui.button>
|
||||
<x-ui.button variant="primary" type="submit" wire:loading.attr="disabled">
|
||||
<x-ui.icon name="unlock" class="size-4" />{{ __('vpn.reveal') }}
|
||||
</x-ui.button>
|
||||
</div>
|
||||
</form>
|
||||
@else
|
||||
<div class="mt-3" x-data="vpnConfigActions(@js($this->filename()))">
|
||||
<pre x-ref="config" class="max-h-64 overflow-auto rounded-lg border border-line bg-surface-2 p-4 font-mono text-xs leading-relaxed text-body">{{ $config }}</pre>
|
||||
<div class="mt-3 flex flex-wrap items-center gap-2">
|
||||
<x-ui.button variant="primary" x-on:click="download()">
|
||||
<x-ui.icon name="download" class="size-4" />{{ __('vpn.download') }}
|
||||
</x-ui.button>
|
||||
<x-ui.button variant="secondary" x-on:click="copy()">
|
||||
<x-ui.icon name="copy" class="size-4" />
|
||||
<span x-text="copied ? @js(__('vpn.copied')) : @js(__('vpn.copy'))"></span>
|
||||
</x-ui.button>
|
||||
<x-ui.button variant="secondary" wire:click="toggleQr" wire:loading.attr="disabled">
|
||||
<x-ui.icon name="qr-code" class="size-4" />{{ $showQr ? __('vpn.qr_hide') : __('vpn.qr_show') }}
|
||||
</x-ui.button>
|
||||
<p x-show="failed" x-cloak class="text-xs text-danger">{{ __('vpn.copy_failed') }}</p>
|
||||
</div>
|
||||
|
||||
@if ($qrSvg)
|
||||
<div class="mt-4 flex flex-col items-center gap-2 rounded-lg border border-line p-4">
|
||||
<div>{!! $qrSvg !!}</div>
|
||||
<p class="text-xs text-muted">{{ __('vpn.qr_hint') }}</p>
|
||||
</div>
|
||||
@endif
|
||||
|
||||
<div class="mt-5 flex justify-end">
|
||||
<x-ui.button variant="secondary" x-on:click="Livewire.dispatch('closeModal')">{{ __('common.close') }}</x-ui.button>
|
||||
</div>
|
||||
</div>
|
||||
@endif
|
||||
</div>
|
||||
|
|
@ -27,15 +27,31 @@
|
|||
<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)">
|
||||
<div class="mt-3" x-data="vpnConfigActions(@js($this->configFilename()))">
|
||||
<pre x-ref="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 items-center gap-2">
|
||||
<x-ui.button variant="primary" x-on:click="download()">
|
||||
<x-ui.icon name="download" class="size-4" />{{ __('vpn.download') }}
|
||||
</x-ui.button>
|
||||
<x-ui.button variant="secondary" x-on:click="copy()">
|
||||
<x-ui.icon name="copy" class="size-4" />
|
||||
<span x-text="copied ? @js(__('vpn.copied')) : @js(__('vpn.copy'))"></span>
|
||||
</x-ui.button>
|
||||
<x-ui.button variant="secondary" wire:click="toggleQr" wire:loading.attr="disabled">
|
||||
<x-ui.icon name="qr-code" class="size-4" />
|
||||
{{ $showQr ? __('vpn.qr_hide') : __('vpn.qr_show') }}
|
||||
</x-ui.button>
|
||||
{{-- Only surfaces when copying is impossible (no clipboard API
|
||||
outside a secure context) — silence would look like a dead button. --}}
|
||||
<p x-show="failed" x-cloak class="text-xs text-danger">{{ __('vpn.copy_failed') }}</p>
|
||||
</div>
|
||||
|
||||
@if ($qrSvg)
|
||||
<div class="mt-4 flex flex-col items-center gap-2 rounded-lg border border-line bg-surface p-4">
|
||||
<div class="[&>svg]:rounded [&>svg]:shadow-xs">{!! $qrSvg !!}</div>
|
||||
<p class="text-xs text-muted">{{ __('vpn.qr_hint') }}</p>
|
||||
</div>
|
||||
@endif
|
||||
</div>
|
||||
</div>
|
||||
@endif
|
||||
|
|
@ -58,6 +74,7 @@
|
|||
<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.owner') }}</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>
|
||||
|
|
@ -87,6 +104,16 @@
|
|||
@endif
|
||||
</div>
|
||||
</td>
|
||||
<td class="px-4 py-3 text-xs">
|
||||
@if ($peer->owner)
|
||||
<span class="text-body">{{ $peer->owner->name }}</span>
|
||||
@if ($peer->user_id === auth()->id())
|
||||
<span class="ml-1 rounded-pill border border-accent-border bg-accent-bg px-1.5 py-0.5 text-[10px] font-medium text-accent-text">{{ __('vpn.you') }}</span>
|
||||
@endif
|
||||
@else
|
||||
<span class="text-faint">{{ $peer->host ? __('vpn.host_peer') : __('vpn.no_owner') }}</span>
|
||||
@endif
|
||||
</td>
|
||||
<td class="px-4 py-3">
|
||||
<div class="font-mono text-xs text-body">{{ $peer->allowed_ip }}</div>
|
||||
@if ($peer->endpoint)
|
||||
|
|
@ -108,16 +135,27 @@
|
|||
</td>
|
||||
<td class="px-4 py-3 text-right">
|
||||
<div class="inline-flex gap-1">
|
||||
@can('downloadConfig', $peer)
|
||||
<button type="button" aria-label="{{ __('vpn.get_config') }}"
|
||||
x-on:click="$dispatch('openModal', { component: 'admin.vpn-config-access', arguments: { uuid: '{{ $peer->uuid }}' } })"
|
||||
class="grid size-8 place-items-center rounded-md border border-line text-muted hover:border-accent-border hover:text-accent-text">
|
||||
<x-ui.icon name="download" class="size-4" />
|
||||
</button>
|
||||
@endcan
|
||||
@can('block', $peer)
|
||||
<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>
|
||||
@endcan
|
||||
@can('delete', $peer)
|
||||
<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>
|
||||
@endcan
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
|
|
@ -130,14 +168,43 @@
|
|||
|
||||
<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>
|
||||
@if ($canManage)
|
||||
<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" />
|
||||
|
||||
<div>
|
||||
<label class="text-sm font-medium text-body" for="vpn-owner">{{ __('vpn.owner') }}</label>
|
||||
<select id="vpn-owner" wire:model="ownerId" class="mt-1.5 w-full rounded-md border border-line-strong bg-surface px-3 py-2 text-sm text-body">
|
||||
@foreach ($operators as $operator)
|
||||
<option value="{{ $operator->id }}">{{ $operator->name }} ({{ $operator->email }})</option>
|
||||
@endforeach
|
||||
</select>
|
||||
<p class="mt-1 text-xs text-muted">{{ __('vpn.owner_hint') }}</p>
|
||||
@error('ownerId') <p class="mt-1 text-xs text-danger">{{ $message }}</p> @enderror
|
||||
</div>
|
||||
|
||||
<x-ui.input name="publicKey" wire:model="publicKey" :label="__('vpn.own_key')" :hint="__('vpn.own_key_hint')" placeholder="{{ __('vpn.own_key_placeholder') }}" />
|
||||
|
||||
<div>
|
||||
<label class="flex items-start gap-2 text-sm text-body">
|
||||
<input type="checkbox" wire:model="storeConfig" @disabled(! $vaultAvailable)
|
||||
class="mt-0.5 size-4 rounded border-line-strong text-accent focus:ring-accent">
|
||||
<span>
|
||||
{{ __('vpn.store_config') }}
|
||||
<span class="mt-0.5 block text-xs text-muted">
|
||||
{{ $vaultAvailable ? __('vpn.store_config_hint') : __('vpn.vault_unavailable') }}
|
||||
</span>
|
||||
</span>
|
||||
</label>
|
||||
@error('storeConfig') <p class="mt-1 text-xs text-danger">{{ $message }}</p> @enderror
|
||||
</div>
|
||||
|
||||
<x-ui.button variant="primary" type="submit" class="w-full">
|
||||
<x-ui.icon name="plus" class="size-4" />{{ __('vpn.add') }}
|
||||
</x-ui.button>
|
||||
</form>
|
||||
@endif
|
||||
|
||||
{{-- 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]">
|
||||
|
|
|
|||
|
|
@ -2,6 +2,7 @@
|
|||
|
||||
use App\Livewire\Admin\ConfirmDeleteVpnPeer;
|
||||
use App\Livewire\Admin\Vpn;
|
||||
use App\Livewire\Admin\VpnConfigAccess;
|
||||
use App\Models\Host;
|
||||
use App\Models\VpnPeer;
|
||||
use App\Provisioning\Jobs\ApplyVpnPeer;
|
||||
|
|
@ -11,6 +12,7 @@ use App\Services\Wireguard\Keypair;
|
|||
use App\Services\Wireguard\LocalWireguardHub;
|
||||
use App\Services\Wireguard\PeerSnapshot;
|
||||
use App\Services\Wireguard\WireguardHub;
|
||||
use Illuminate\Support\Facades\Hash;
|
||||
use Illuminate\Support\Facades\Queue;
|
||||
use Livewire\Livewire;
|
||||
|
||||
|
|
@ -22,19 +24,46 @@ function vpnHub(): FakeWireguardHub
|
|||
return $hub;
|
||||
}
|
||||
|
||||
it('keeps the page and the nav entry away from operators without the capability', function () {
|
||||
it('shows each operator only their own access unless they may see all', function () {
|
||||
vpnHub();
|
||||
$support = operator('Support');
|
||||
$mine = VpnPeer::factory()->ownedBy($support)->create(['name' => 'Support-Notebook']);
|
||||
$foreign = VpnPeer::factory()->create(['name' => 'Fremdes-Notebook']);
|
||||
|
||||
// Support reaches the page — to find their own access — but sees only it.
|
||||
$this->actingAs($support)->get(route('admin.vpn'))
|
||||
->assertOk()
|
||||
->assertSee('Support-Notebook')
|
||||
->assertDontSee('Fremdes-Notebook');
|
||||
|
||||
// Developer may see everything, by capability rather than by ownership.
|
||||
$this->actingAs(operator('Developer'))->get(route('admin.vpn'))
|
||||
->assertOk()
|
||||
->assertSee('Support-Notebook')
|
||||
->assertSee('Fremdes-Notebook');
|
||||
|
||||
expect($mine->fresh()->user_id)->toBe($support->id)
|
||||
->and($foreign->fresh()->user_id)->not->toBe($support->id);
|
||||
});
|
||||
|
||||
it('lets only managers create an access', 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'))
|
||||
// The form is not even offered to someone who cannot use it.
|
||||
$this->actingAs(operator('Support'))->get(route('admin.vpn'))
|
||||
->assertOk()
|
||||
->assertDontSee(route('admin.vpn'));
|
||||
->assertDontSee(__('vpn.store_config'));
|
||||
|
||||
$this->actingAs(operator('Admin'))->get(route('admin.vpn'))->assertOk();
|
||||
$this->actingAs(operator('Owner'))->get(route('admin.overview'))
|
||||
$this->actingAs(operator('Owner'))->get(route('admin.vpn'))
|
||||
->assertOk()
|
||||
->assertSee(route('admin.vpn'));
|
||||
->assertSee(__('vpn.store_config'));
|
||||
|
||||
Livewire::actingAs(operator('Support'))->test(Vpn::class)
|
||||
->set('name', 'selbst ausgestellt')
|
||||
->call('create')
|
||||
->assertForbidden();
|
||||
|
||||
expect(VpnPeer::withTrashed()->count())->toBe(0);
|
||||
});
|
||||
|
||||
it('creates an access, hands out the config once and pushes it to the hub', function () {
|
||||
|
|
@ -52,16 +81,19 @@ it('creates an access, hands out the config once and pushes it to the hub', func
|
|||
->and($peer->enabled)->toBeTrue()
|
||||
->and($peer->present)->toBeFalse(); // not on the hub until the job ran
|
||||
|
||||
$config = $component->get('newConfig');
|
||||
$config = App\Services\Wireguard\ConfigHandoff::get($component->get('configToken'));
|
||||
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.
|
||||
// The private key is handed over once and not kept anywhere.
|
||||
preg_match('/PrivateKey = (\S+)/', $config, $m);
|
||||
expect(Keypair::isValidKey($m[1]))->toBeTrue()
|
||||
->and(VpnPeer::query()->where('public_key', $m[1])->exists())->toBeFalse();
|
||||
->and(VpnPeer::query()->where('public_key', $m[1])->exists())->toBeFalse()
|
||||
->and($peer->config_secret)->toBeNull()
|
||||
// …and it never travels in the component snapshot, only a handle does.
|
||||
->and($component->get('configToken'))->not->toContain($m[1]);
|
||||
|
||||
Queue::assertPushed(ApplyVpnPeer::class, fn ($job) => $job->publicKey === $peer->public_key && $job->enabled);
|
||||
});
|
||||
|
|
@ -79,7 +111,7 @@ it('accepts a peer-supplied public key and then has no private key to show', fun
|
|||
->assertHasNoErrors();
|
||||
|
||||
expect(VpnPeer::query()->where('public_key', $own)->exists())->toBeTrue()
|
||||
->and($component->get('newConfig'))->toBeNull();
|
||||
->and($component->get('configToken'))->toBeNull();
|
||||
});
|
||||
|
||||
it('rejects a malformed key and a key that already has an access', function () {
|
||||
|
|
@ -139,14 +171,15 @@ it('warns that deleting a host access cuts CluPilot off from that host', functio
|
|||
it('stops mutations from a session whose capability was revoked mid-flight', function () {
|
||||
vpnHub();
|
||||
Queue::fake();
|
||||
$peer = VpnPeer::factory()->create();
|
||||
$peer = VpnPeer::factory()->create(); // someone else's access
|
||||
$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.
|
||||
// powers: every action re-checks, it does not trust the mount. And it must
|
||||
// refuse out loud, not quietly do nothing.
|
||||
$user->syncRoles(['Support']);
|
||||
app(Spatie\Permission\PermissionRegistrar::class)->forgetCachedPermissions();
|
||||
|
||||
|
|
@ -156,6 +189,18 @@ it('stops mutations from a session whose capability was revoked mid-flight', fun
|
|||
Queue::assertNotPushed(ApplyVpnPeer::class);
|
||||
});
|
||||
|
||||
it('lets an owner switch their own access off and on', function () {
|
||||
vpnHub();
|
||||
Queue::fake();
|
||||
$support = operator('Support');
|
||||
$peer = VpnPeer::factory()->ownedBy($support)->create();
|
||||
|
||||
Livewire::actingAs($support)->test(Vpn::class)->call('toggle', $peer->uuid);
|
||||
|
||||
expect($peer->fresh()->enabled)->toBeFalse();
|
||||
Queue::assertPushed(ApplyVpnPeer::class, fn ($job) => ! $job->enabled);
|
||||
});
|
||||
|
||||
it('adopts host peers from the hub and marks vanished ones absent', function () {
|
||||
$hub = vpnHub();
|
||||
$key = Keypair::generate()->publicKey;
|
||||
|
|
@ -274,16 +319,18 @@ it('keeps a revoked address and key reserved until the hub confirms', function (
|
|||
->assertHasErrors('publicKey');
|
||||
});
|
||||
|
||||
it('stops an open page from polling once the capability is revoked', function () {
|
||||
it('narrows what an open page shows once the account is demoted', function () {
|
||||
vpnHub();
|
||||
$user = operator('Owner');
|
||||
$component = Livewire::actingAs($user)->test(Vpn::class);
|
||||
VpnPeer::factory()->create(['name' => 'Fremdes-Notebook']);
|
||||
$component = Livewire::actingAs($user)->test(Vpn::class)->assertSee('Fremdes-Notebook');
|
||||
|
||||
$user->syncRoles(['Support']);
|
||||
app(Spatie\Permission\PermissionRegistrar::class)->forgetCachedPermissions();
|
||||
|
||||
// The five-second poll must not keep serving peer state to a demoted account.
|
||||
$component->call('refreshPeers')->assertForbidden();
|
||||
// The five-second poll keeps working — the page is still theirs — but it
|
||||
// must stop serving other people's accesses to a demoted account.
|
||||
$component->call('refreshPeers')->assertDontSee('Fremdes-Notebook');
|
||||
});
|
||||
|
||||
it('holds the same allocation lock the host pipeline uses', function () {
|
||||
|
|
@ -451,3 +498,141 @@ it('does not leave a phantom access behind when a host is purged', function () {
|
|||
expect($hub->peerIps())->toBe([])
|
||||
->and(VpnPeer::withTrashed()->count())->toBe(0);
|
||||
});
|
||||
|
||||
it('stores the config only when asked, and encrypted', function () {
|
||||
vpnHub();
|
||||
Queue::fake();
|
||||
$owner = operator('Owner');
|
||||
|
||||
Livewire::actingAs($owner)->test(Vpn::class)
|
||||
->set('name', 'Notebook')->set('ownerId', $owner->id)
|
||||
->set('storeConfig', true)
|
||||
->call('create')
|
||||
->assertHasNoErrors();
|
||||
|
||||
$peer = VpnPeer::query()->firstOrFail();
|
||||
expect($peer->config_secret)->not->toBeNull()
|
||||
// Not readable without the key: no plaintext key material in the column.
|
||||
->and($peer->config_secret)->not->toContain('PrivateKey')
|
||||
->and($peer->config_secret)->toStartWith('v1:')
|
||||
->and(App\Services\Wireguard\ConfigVault::decrypt($peer->config_secret))->toContain('[Interface]');
|
||||
});
|
||||
|
||||
it('refuses to store when there is no key to store it under', function () {
|
||||
vpnHub();
|
||||
config()->set('admin_access.vpn_config_key', '');
|
||||
|
||||
Livewire::actingAs(operator('Owner'))->test(Vpn::class)
|
||||
->set('name', 'Notebook')->set('storeConfig', true)
|
||||
->call('create')
|
||||
->assertHasErrors('storeConfig');
|
||||
|
||||
// Nothing half-created: no access without the storage it was asked for.
|
||||
expect(VpnPeer::withTrashed()->count())->toBe(0);
|
||||
});
|
||||
|
||||
it('hands a stored config back only to its owner, and only with the password', function () {
|
||||
vpnHub();
|
||||
$support = operator('Support');
|
||||
$support->forceFill(['password' => Hash::make('geheim-1234')])->save();
|
||||
$peer = VpnPeer::factory()->ownedBy($support)->create([
|
||||
'config_secret' => App\Services\Wireguard\ConfigVault::encrypt("[Interface]\nPrivateKey = XYZ\n"),
|
||||
]);
|
||||
|
||||
// Wrong password: nothing comes back.
|
||||
$modal = Livewire::actingAs($support)->test(VpnConfigAccess::class, ['uuid' => $peer->uuid])
|
||||
->set('password', 'falsch')
|
||||
->call('reveal')
|
||||
->assertHasErrors('password');
|
||||
expect($modal->get('token'))->toBeNull();
|
||||
|
||||
// Right password: the config is handed over, and the retrieval is recorded.
|
||||
$modal->set('password', 'geheim-1234')->call('reveal')->assertHasNoErrors();
|
||||
expect(App\Services\Wireguard\ConfigHandoff::get($modal->get('token')))->toContain('PrivateKey = XYZ')
|
||||
->and($peer->fresh()->download_count)->toBe(1)
|
||||
->and($peer->fresh()->last_downloaded_at)->not->toBeNull();
|
||||
});
|
||||
|
||||
it('never hands someone else the private key, not even an Owner', function () {
|
||||
vpnHub();
|
||||
$support = operator('Support');
|
||||
$peer = VpnPeer::factory()->ownedBy($support)->create([
|
||||
'config_secret' => App\Services\Wireguard\ConfigVault::encrypt("[Interface]\nPrivateKey = XYZ\n"),
|
||||
]);
|
||||
|
||||
// The boss can see the access and revoke it — but holding its key is a
|
||||
// different thing, and the policy says no to everyone but the owner.
|
||||
foreach (['Owner', 'Admin', 'Developer'] as $role) {
|
||||
$other = operator($role);
|
||||
expect($other->can('view', $peer))->toBeTrue()
|
||||
->and($other->can('downloadConfig', $peer))->toBeFalse();
|
||||
|
||||
// The page shows them the access without offering its config…
|
||||
$this->actingAs($other)->get(route('admin.vpn'))
|
||||
->assertOk()
|
||||
->assertSee($peer->name)
|
||||
->assertDontSee('admin.vpn-config-access');
|
||||
|
||||
// …and going straight at the modal is refused, not merely hidden.
|
||||
Livewire::actingAs($other)
|
||||
->test(VpnConfigAccess::class, ['uuid' => $peer->uuid])
|
||||
->assertForbidden();
|
||||
}
|
||||
|
||||
// The owner still gets the button.
|
||||
$this->actingAs($support)->get(route('admin.vpn'))
|
||||
->assertOk()
|
||||
->assertSee('admin.vpn-config-access');
|
||||
});
|
||||
|
||||
it('locks out password guessing', function () {
|
||||
vpnHub();
|
||||
$support = operator('Support');
|
||||
$peer = VpnPeer::factory()->ownedBy($support)->create([
|
||||
'config_secret' => App\Services\Wireguard\ConfigVault::encrypt("[Interface]\n"),
|
||||
]);
|
||||
|
||||
$modal = Livewire::actingAs($support)->test(VpnConfigAccess::class, ['uuid' => $peer->uuid]);
|
||||
foreach (range(1, 5) as $attempt) {
|
||||
$modal->set('password', 'falsch'.$attempt)->call('reveal')->assertHasErrors('password');
|
||||
}
|
||||
|
||||
// Even the correct password is refused while the account is throttled.
|
||||
$modal->set('password', 'password')->call('reveal');
|
||||
expect($modal->get('token'))->toBeNull();
|
||||
});
|
||||
|
||||
it('destroys the stored config when the access is revoked', function () {
|
||||
vpnHub();
|
||||
Queue::fake();
|
||||
$support = operator('Support');
|
||||
$peer = VpnPeer::factory()->ownedBy($support)->create([
|
||||
'config_secret' => App\Services\Wireguard\ConfigVault::encrypt("[Interface]\nPrivateKey = XYZ\n"),
|
||||
]);
|
||||
|
||||
Livewire::actingAs(operator('Owner'))
|
||||
->test(ConfirmDeleteVpnPeer::class, ['uuid' => $peer->uuid])
|
||||
->call('delete');
|
||||
|
||||
// The tombstone must not keep a usable credential.
|
||||
$tombstone = VpnPeer::withTrashed()->firstOrFail();
|
||||
expect($tombstone->config_secret)->toBeNull()
|
||||
->and($tombstone->secret_purged_at)->not->toBeNull();
|
||||
});
|
||||
|
||||
it('takes the tunnel away when a staff member is revoked', function () {
|
||||
vpnHub();
|
||||
Queue::fake();
|
||||
$support = operator('Support');
|
||||
$peer = VpnPeer::factory()->ownedBy($support)->create([
|
||||
'config_secret' => App\Services\Wireguard\ConfigVault::encrypt("[Interface]\n"),
|
||||
]);
|
||||
|
||||
Livewire::actingAs(operator('Owner'))->test(App\Livewire\Admin\Settings::class)
|
||||
->call('revokeStaff', $support->id);
|
||||
|
||||
expect($support->fresh()->isOperator())->toBeFalse()
|
||||
->and(VpnPeer::query()->count())->toBe(0)
|
||||
->and(VpnPeer::withTrashed()->first()->config_secret)->toBeNull();
|
||||
Queue::assertPushed(ApplyVpnPeer::class, fn ($job) => $job->publicKey === $peer->public_key && ! $job->enabled);
|
||||
});
|
||||
|
|
|
|||
Loading…
Reference in New Issue