222 lines
7.9 KiB
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'),
|
|
]);
|
|
}
|
|
}
|