CluPilotCloud/app/Livewire/Admin/Integrations.php

222 lines
7.9 KiB
PHP

<?php
namespace App\Livewire\Admin;
use App\Livewire\Concerns\ConfirmsPassword;
use App\Services\Secrets\SecretVault;
use App\Support\ProvisioningSettings;
use App\Support\Settings;
use Illuminate\Support\Facades\Auth;
use Illuminate\Support\Facades\Gate;
use Livewire\Attributes\Layout;
use Livewire\Attributes\On;
use Livewire\Component;
use Throwable;
/**
* Connected services, one page, grouped by what they are for — Zahlungen,
* DNS, Monitoring, VPN/WireGuard, SSH-Identität — not by which of the two
* mechanisms below happens to hold a given value.
*
* Replaces Admin\Secrets and Admin\Infrastructure, which is what they were:
* one console area split across two pages by storage mechanism, reported as
* exactly the wrong axis to split on. An operator setting up an integration
* should not have to know whether a given field happens to be encrypted.
*
* Both mechanisms are unchanged underneath — this is a presentation change,
* not a data migration:
*
* - SecretVault: a curated, encrypted key/value store for credentials that
* actually stop the business when they leak or expire. Gated by
* `secrets.manage` (Owner only) AND a recently confirmed password — the
* realistic threat is an unlocked machine, not a stranger. A stored value
* is write-only: the page only ever shows an outline of it.
* - App\Support\Settings: plain deployment configuration. Gated by
* `hosts.manage` (Owner + Admin) alone — none of it opens anything by
* itself. Shows its value in full, because there is nothing to protect.
*
* mount() is reachable with EITHER capability, because the two halves are no
* longer two pages an operator's role happens to see one, both, or neither
* of — they are sections of the SAME page now. Each section, and each save
* action, still checks its OWN capability (guardSecrets()/guardInfra())
* server-side; an Admin who can reach this page for the DNS zone still gets
* 403 the moment they try to touch a vault entry.
*/
#[Layout('layouts.admin')]
class Integrations extends Component
{
use ConfirmsPassword;
// Vault entries — same shape ConfirmSaveSecret/ConfirmForgetSecret expect
// and dispatch back into (secret-save-confirmed / secret-forget-confirmed).
public array $entered = [];
public ?array $check = null;
// Plain settings — App\Support\ProvisioningSettings' full list.
public string $dnsZone = '';
public string $wgEndpoint = '';
public string $wgHubPubkey = '';
public string $traefikDynamicPath = '';
public string $sshPublicKey = '';
public string $monitoringUrl = '';
protected function confirmationGuard(): string
{
return 'operator';
}
public function mount(): void
{
abort_unless(Gate::any(['hosts.manage', 'secrets.manage']), 403);
if (Gate::allows('hosts.manage')) {
$this->dnsZone = ProvisioningSettings::dnsZone();
$this->wgEndpoint = ProvisioningSettings::wgEndpoint();
$this->wgHubPubkey = ProvisioningSettings::wgHubPublicKey();
$this->traefikDynamicPath = ProvisioningSettings::traefikDynamicPath();
$this->sshPublicKey = ProvisioningSettings::sshPublicKey();
$this->monitoringUrl = ProvisioningSettings::monitoringUrl();
}
}
// ---- Plain settings — App\Support\Settings, hosts.manage, no password. ----
public function saveInfra(): void
{
$this->guardInfra();
$data = $this->validate([
'dnsZone' => ['nullable', 'string', 'max:255'],
'wgEndpoint' => ['nullable', 'string', 'max:255'],
'wgHubPubkey' => ['nullable', 'string', 'max:255'],
'traefikDynamicPath' => ['nullable', 'string', 'max:255'],
'sshPublicKey' => ['nullable', 'string', 'max:1000'],
'monitoringUrl' => ['nullable', 'url', 'max:255'],
]);
Settings::set('provisioning.dns_zone', trim((string) $data['dnsZone']));
Settings::set('provisioning.wg_endpoint', trim((string) $data['wgEndpoint']));
Settings::set('provisioning.wg_hub_pubkey', trim((string) $data['wgHubPubkey']));
Settings::set('provisioning.traefik_dynamic_path', trim((string) $data['traefikDynamicPath']));
Settings::set('provisioning.ssh_public_key', trim((string) $data['sshPublicKey']));
Settings::set('monitoring.api_url', trim((string) $data['monitoringUrl']));
$this->dispatch('notify', message: __('integrations.settings_saved'));
}
// ---- Vault entries — SecretVault, secrets.manage + confirmed password. ----
public function save(string $key): void
{
$this->guardSecrets();
$field = self::field($key);
$value = trim((string) ($this->entered[$field] ?? ''));
if ($value === '') {
$this->addError('entered.'.$field, __('secrets.empty'));
return;
}
try {
app(SecretVault::class)->put($key, $value, Auth::guard('operator')->user());
} catch (Throwable $e) {
$this->addError('entered.'.$field, $e->getMessage());
return;
}
$this->entered[$field] = '';
$this->check = null;
$this->dispatch('notify', message: __('secrets.saved'));
}
/** ConfirmSaveSecret dispatches this back (R23) — see that class. */
#[On('secret-save-confirmed')]
public function onSaveConfirmed(string $key): void
{
$this->save($key);
}
public function forget(string $key): void
{
$this->guardSecrets();
app(SecretVault::class)->forget($key);
$this->check = null;
$this->dispatch('notify', message: __('secrets.removed'));
}
/** ConfirmForgetSecret dispatches this back (R23) — see that class. */
#[On('secret-forget-confirmed')]
public function onForgetConfirmed(string $key): void
{
$this->forget($key);
}
public function test(string $key): void
{
$this->guardSecrets();
$checker = SecretVault::REGISTRY[$key]['check'] ?? null;
abort_if($checker === null, 404);
$candidate = trim((string) ($this->entered[self::field($key)] ?? '')) ?: null;
$this->check = app($checker)->run($candidate);
}
/** The dotless form key for a registry key (a dot means nesting to Livewire). */
public static function field(string $key): string
{
return str_replace('.', '_', $key);
}
private function guardInfra(): void
{
$this->authorize('hosts.manage');
}
/** Capability AND a recently confirmed password, on every vault action. */
private function guardSecrets(): void
{
$this->authorize('secrets.manage');
abort_unless($this->passwordRecentlyConfirmed(), 403);
}
public function render()
{
$vault = app(SecretVault::class);
$canSecrets = Gate::allows('secrets.manage');
$canInfra = Gate::allows('hosts.manage');
$unlocked = $this->passwordRecentlyConfirmed();
return view('livewire.admin.integrations', [
'canSecrets' => $canSecrets,
'canInfra' => $canInfra,
'unlocked' => $unlocked,
'usable' => $vault->isUsable(),
'entries' => collect(SecretVault::REGISTRY)
->map(fn (array $meta, string $key) => [
'key' => $key,
'field' => self::field($key),
'label' => __($meta['label']),
'testable' => isset($meta['check']),
'source' => $vault->source($key),
'outline' => $unlocked ? $vault->outline($key) : null,
'updated_at' => $unlocked ? $vault->updatedAt($key) : null,
// The SSH identity is a multi-line PEM key: a single-line
// password field would mangle it on paste.
'multiline' => $key === 'ssh.private_key',
])
->keyBy('key'),
]);
}
}