CluPilotCloud/app/Livewire/Admin/Integrations.php

313 lines
12 KiB
PHP

<?php
namespace App\Livewire\Admin;
use App\Livewire\Concerns\ConfirmsPassword;
use App\Services\Env\EnvFileEditor;
use App\Services\Env\InvalidEnvContentException;
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.
*
* The .env editor (Part B) shares the secrets lock: it needs `secrets.manage`
* and the same confirmed password, because it is the one place on this page
* that can reach every credential the vault deliberately keeps write-only —
* see EnvFileEditor for the safeguards that make that survivable.
*/
#[Layout('layouts.admin')]
class Integrations extends Component
{
use ConfirmsPassword {
forgetPasswordConfirmation as private lockAgain;
}
// 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 = '';
// .env editor.
public string $envContent = '';
/**
* Loaded lazily, on the first render after unlocking — never in mount(),
* so a merely-reachable page never pulls the whole credential file into
* component state for an operator who has not confirmed anything yet.
*/
public bool $envLoaded = false;
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);
}
// ---- The .env editor — Part B. secrets.manage + confirmed password. ----
/**
* Validate, back up, write. EnvFileEditor does the actual work and the
* actual refusing; this only translates its one exception into a form
* error instead of a 500, and reports where the backup landed.
*/
public function saveEnv(): void
{
$this->guardSecrets();
try {
$backup = app(EnvFileEditor::class)->write($this->envContent);
} catch (InvalidEnvContentException $e) {
$this->addError('envContent', $e->invalidLine !== null
? __('integrations.env_invalid', ['line' => $e->invalidLine])
: __('integrations.env_empty'));
return;
}
// '' means there was no previous file to protect — EnvFileEditor's
// own docblock explains why that is not an error.
$this->dispatch('notify', message: __('integrations.env_saved', [
'backup' => $backup !== '' ? basename($backup) : __('integrations.env_no_previous_file'),
]));
}
/** ConfirmSaveEnv dispatches this back (R23) — see that class. */
#[On('env-save-confirmed')]
public function onEnvSaveConfirmed(): void
{
$this->saveEnv();
}
/**
* Overrides ConfirmsPassword's own method (aliased above to lockAgain) to
* also drop the Stripe check result — the render()-side check just below
* handles $envContent/$envLoaded, and handles it for BOTH ways a session
* can end up locked: this explicit click, and the confirmation window
* simply expiring with nobody clicking anything. A clear here alone would
* only ever cover the first.
*/
public function forgetPasswordConfirmation(): void
{
$this->lockAgain();
$this->check = null;
}
private function guardInfra(): void
{
$this->authorize('hosts.manage');
}
/** Capability AND a recently confirmed password, on every vault/.env 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();
// Loaded here rather than in mount(): a page that is merely reachable
// must not already hold the whole credential file in component state
// for an operator who has not confirmed a password this session.
if ($canSecrets && $unlocked && ! $this->envLoaded) {
$this->envContent = app(EnvFileEditor::class)->read();
$this->envLoaded = true;
}
// The mirror case, and the one forgetPasswordConfirmation() alone
// does not cover: the confirmation window can also expire on its
// own, with nobody clicking "Wieder sperren" — $unlocked simply goes
// false on whatever the next render happens to be. Without this, the
// textarea disappears from the page but $envContent — the ENTIRE
// credential file — stays sitting in the component snapshot, reachable
// to anyone with the browser this was left open on (Codex review,
// P1). Checked on every render, not only the explicit lock action.
if (! $unlocked && $this->envLoaded) {
$this->envContent = '';
$this->envLoaded = false;
}
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']),
'envKey' => $meta['env_key'],
'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'),
]);
}
}