CluPilotCloud/app/Livewire/Admin/Integrations.php

515 lines
22 KiB
PHP

<?php
namespace App\Livewire\Admin;
use App\Livewire\Concerns\ConfirmsPassword;
use App\Services\Deployment\UpdateChannel;
use App\Services\Env\EnvFileEditor;
use App\Services\Env\InvalidEnvContentException;
use App\Services\Mail\InboundMailbox;
use App\Services\Mail\InboundMailStatus;
use App\Services\Secrets\SecretVault;
use App\Support\ProvisioningSettings;
use App\Support\Settings;
use Illuminate\Support\Facades\Artisan;
use Illuminate\Support\Facades\Auth;
use Illuminate\Support\Facades\Gate;
use Livewire\Attributes\Layout;
use Livewire\Attributes\On;
use Livewire\Attributes\Url;
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.
*
* TABBED, like Admin\Settings and for the same reason: this was one narrow
* column of six cards, and an operator connecting DNS scrolled past Stripe to
* get to it. The split is by what a section is FOR — outside services we buy
* from, our own machines, and the raw file underneath — which is the same axis
* the page was rebuilt around when it stopped being split by storage mechanism.
*
* The open tab is in the query string, so a reload or a bookmark lands where
* the operator was. The .env tab in particular is worth linking to directly,
* and the unlock survives the switch because the confirmation is a session
* fact, not a property of a tab.
*/
#[Layout('layouts.admin')]
class Integrations extends Component
{
/**
* The tabs, in order. The list IS the schema: it validates the query
* string, builds the bar and decides what renders.
*
* `services` is what we buy from outside (Stripe, Hetzner DNS, the
* monitoring bridge), `platform` is how we reach our own machines (the
* WireGuard hub, the SSH identity), `env` is the file underneath all of it.
*/
public const TABS = ['services', 'platform', 'env'];
/**
* Which tab is open, in the address bar.
*
* `history: true`, so each tab is a step the back button can take — and no
* `except`, so the parameter is there from the first render rather than
* appearing only once somebody leaves the default tab. Hiding it on the
* default read as "the tab is not in the URL", which is exactly how it was
* reported: a link to this page could not say which tab it meant unless the
* tab happened not to be the first one.
*/
#[Url(history: true)]
public string $tab = 'services';
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 = '';
/**
* The support mailbox this installation reads customer mail from.
*
* Host, user and folder only. The PASSWORD is a vault entry — it opens
* something, and this page's rule is that anything which opens something is
* write-only and behind a confirmed password.
*/
public string $inboundHost = '';
public string $inboundPort = '993';
public string $inboundUsername = '';
public string $inboundFolder = 'INBOX';
// .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;
/**
* True from the moment saveEnv() successfully asks the agent to restart
* the workers, until render() observes the request is no longer pending.
*
* Drives the small inline "wird neu gestartet" indicator on this page —
* not the full-screen maintenance overlay in layouts/admin.blade.php,
* which is for an actual deployment. A worker restart is four containers
* coming back in a few seconds, this page (and the console around it)
* never goes offline for it, and `app` — the container answering this
* very poll — is never among them. wire:poll on this page's own
* component is therefore enough to watch it finish; nothing here needs
* the client-side watcher an in-flight deployment does.
*/
public bool $envRestartWatching = false;
protected function confirmationGuard(): string
{
return 'operator';
}
public function mount(): void
{
abort_unless(Gate::any(['hosts.manage', 'secrets.manage']), 403);
// A tab name out of the URL is a string a stranger typed.
if (! in_array($this->tab, self::TABS, true)) {
$this->tab = self::TABS[0];
}
// The raw file is Owner-only. Landing on the first tab says that
// without a blank page; the editor itself checks again anyway.
if ($this->tab === 'env' && ! Gate::allows('secrets.manage')) {
$this->tab = self::TABS[0];
}
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();
$mailbox = ProvisioningSettings::inboundMailbox();
$this->inboundHost = $mailbox['host'];
$this->inboundPort = (string) $mailbox['port'];
$this->inboundUsername = $mailbox['username'];
$this->inboundFolder = $mailbox['folder'];
}
}
// ---- 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'],
'inboundHost' => ['nullable', 'string', 'max:255'],
'inboundPort' => ['required', 'integer', 'min:1', 'max:65535'],
'inboundUsername' => ['nullable', 'string', 'max:255'],
'inboundFolder' => ['required', 'string', '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']));
Settings::set('inbound_mail.host', trim((string) $data['inboundHost']));
Settings::set('inbound_mail.port', (int) $data['inboundPort']);
Settings::set('inbound_mail.username', trim((string) $data['inboundUsername']));
Settings::set('inbound_mail.folder', trim((string) $data['inboundFolder']));
$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 — then do the two things saving this file used
* to leave as homework: clear the config cache (this container can do
* that itself, no host privileges needed) and ask the host-side agent to
* restart queue, queue-provisioning, scheduler and reverb (UpdateChannel
* — it cannot be done from in here; see that class for why).
*
* EnvFileEditor does the write itself and the actual refusing; this only
* translates its one exception into a form error instead of a 500.
*/
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;
}
// Unconditional, and deliberately not gated on
// app()->configurationIsCached() first: Illuminate's own ConfigClear
// command is already exactly that guard — it deletes the cache file
// if one exists and does nothing if there is none, so adding a check
// here would only save a single is_file() call in the common case at
// the cost of a second source of truth to keep in sync with it. This
// dev machine has no cache right now (checked: no
// bootstrap/cache/config.php), but deploy/update.sh and
// deploy/install-agent.sh both run `config:cache` as standard
// practice, so a real installation usually does — and while one is
// active, Laravel skips loading .env on every request AT ALL
// (LoadEnvironmentVariables bootstraps straight from the cached
// values instead). Left uncleared there, the value just written would
// stay invisible — not merely to the four workers below, to this
// application too — until the cache was rebuilt by hand.
Artisan::call('config:clear');
$channel = app(UpdateChannel::class);
$agentAlive = $channel->state()['agent_seen'];
$by = Auth::guard('operator')->user()?->email ?? 'console';
// '' means there was no previous file to protect — EnvFileEditor's
// own docblock explains why that is not an error.
$backupName = $backup !== '' ? basename($backup) : __('integrations.env_no_previous_file');
if (! $agentAlive) {
// Honest, not silent: the values ARE saved, but nothing is going
// to pick them up on its own. Pretending otherwise here is worse
// than the manual-restart card this replaces — see
// admin_settings.update_no_agent for the same shape on updates.
$this->envRestartWatching = false;
$this->dispatch('notify', message: __('integrations.env_saved_no_agent', ['backup' => $backupName]));
return;
}
if (! $channel->requestRestart($by)) {
// The single request slot is already taken by something else
// (a check, an update, or another restart just asked for) — rare,
// but real, and not something to paper over with a message that
// says "restarting" when nothing was actually queued.
$this->envRestartWatching = false;
$this->dispatch('notify', message: __('integrations.env_saved_restart_busy', ['backup' => $backupName]));
return;
}
$this->envRestartWatching = true;
$this->dispatch('notify', message: __('integrations.env_saved_restarting', ['backup' => $backupName]));
}
/** 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);
}
/**
* Reach the support mailbox now and say what came back.
*
* The one thing an operator wants after typing a host, a user and a
* password: did any of it work. Waiting for the next scheduled run to find
* out is not an answer, and "der Posteingang ist leer" is not one either.
*
* Saved first, deliberately: testing what is on screen while the server
* still holds the previous values would report on a mailbox nobody
* configured. The unsaved form is the question being asked.
*/
public function testInbound(): void
{
$this->guardInfra();
$this->saveInfra();
$check = app(InboundMailbox::class)->check();
app(InboundMailStatus::class)->record($check['ok'], $check['message'], $check['unseen']);
$this->dispatch('notify', message: $check['ok']
? ($check['unseen'] === null
? __('integrations.inbound_ok')
: trans_choice('integrations.inbound_ok_waiting', $check['unseen'], ['count' => $check['unseen']]))
: __('integrations.inbound_failed_'.$check['message']));
}
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;
}
$restart = app(UpdateChannel::class)->state();
// The transition the small inline indicator exists to show: a restart
// THIS component asked for is no longer pending. Read off
// 'requested_at' rather than 'restarting': the latter is gated on
// agent_seen (see UpdateChannel::state()), so if the agent went
// quiet in the few seconds this was being waited on, 'restarting'
// would already read false EVEN THOUGH THE REQUEST FILE IS STILL
// SITTING THERE, unread — and this would announce "restarted"
// for a restart that never ran. 'requested_at' answers the only
// question that matters here — is a request still on disk — with no
// opinion about the agent either way. Fires once — the instant it
// does, envRestartWatching drops so the next poll (or the one after,
// on a slower host with no on-demand wake) does not dispatch it
// again. wire:poll on the .env section is what keeps calling
// render() while this is being waited on; see $envRestartWatching's
// own docblock for why that is enough and the full-page deployment
// overlay is not needed here.
if ($this->envRestartWatching && $restart['requested_at'] === null) {
$this->envRestartWatching = false;
$this->dispatch('notify', message: __('integrations.env_restart_done'));
}
return view('livewire.admin.integrations', [
// Only the tabs this operator can open. A tab that renders nothing
// is worse than one that is not there.
'tabs' => array_values(array_filter(
self::TABS,
fn (string $tab) => $tab !== 'env' || $canSecrets,
)),
// When the mailbox was last reached, and what came back. Null until
// somebody — or the scheduler — has asked once.
'inboundStatus' => app(InboundMailStatus::class)->last(),
'canSecrets' => $canSecrets,
'canInfra' => $canInfra,
'unlocked' => $unlocked,
'usable' => $vault->isUsable(),
'restart' => $restart,
'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'),
]);
}
}