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]; } // Öffentlich, also ohne die hosts.manage-Bedingung darunter: er wird // im Klartext angezeigt und schützt nichts. $this->stripePublishableKey = StripePublishableKey::current(); 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']; } } // ---- The operating mode — governs every vault entry below it. ---- /** * Den Betriebsmodus umlegen. * * Hinter derselben Sperre wie der Tresor — `secrets.manage` plus bestätigtes * Passwort — weil der Wechsel auf Live der Moment ist, ab dem echtes Geld * fließt. Das ist keine Umschaltfläche zum Danebenklicken. */ #[On('mode-switch-confirmed')] public function switchMode(string $mode): void { $this->guardSecrets(); // Ein String aus dem Browser. tryFrom, nicht from. $target = OperatingMode::tryFrom($mode); if ($target === null) { return; } OperatingMode::set($target); // Der Platz unter dem Feld hat gewechselt. Ohne diese Zeile zeigt es // weiter den Schlüssel des anderen Kontos — und speichert ihn beim // nächsten Klick in den falschen. $this->stripePublishableKey = StripePublishableKey::current(); $this->dispatch('notify', message: __('integrations.mode_switched', [ 'mode' => __('readiness.mode.'.$target->value), ])); } // ---- 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->checkedKey = 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); } /** * Der dritte Stripe-Wert, und der einzige, der kein Geheimnis ist. * * Eigene Aktion statt eines Tresoreintrags: er ist öffentlich, wird im * Klartext angezeigt und nicht maskiert — siehe StripePublishableKey. * Hinter derselben Sperre wie der Rest der Zahlungskarte, weil er auf ihr * steht und nicht, weil er schützenswert wäre. */ public function savePublishableKey(): void { $this->guardSecrets(); $this->validate(['stripePublishableKey' => ['nullable', 'string', 'max:255']]); StripePublishableKey::set($this->stripePublishableKey); $this->dispatch('notify', message: __('secrets.publishable_saved')); } public function forget(string $key): void { $this->guardSecrets(); app(SecretVault::class)->forget($key); $this->check = null; $this->checkedKey = 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); } /** * Mint a fresh SSH identity here, rather than in a shell somewhere. * * `ssh.private_key` was the one vault entry with no way to fill it from the * console: the readiness page flagged it, and the only cure was * `ssh-keygen` in a terminal, a copy-paste of a private key through a * clipboard, and remembering to shred the temp file. A page that reports a * gap it cannot close has moved the work, not done it. * * Both halves are written in one go, deliberately. They are two records of * ONE fact, and this is the only moment both are known — the private half * is write-only from here on, so a public half saved separately (or not at * all) could never be checked against it again. Order matters too: the * private half goes in first, because a published public key whose private * half was never stored would have EstablishSshTrust install a key nothing * can log in with, and scrub the root password on the way past. * * The private half is never returned, rendered or put in the snapshot; the * public half is, because it has to be copied onto a machine by hand. */ public function generateSshKey(): void { $this->guardSecrets(); $pair = Keypair::generate(); app(SecretVault::class)->put('ssh.private_key', $pair->privateKey, Auth::guard('operator')->user()); Settings::set('provisioning.ssh_public_key', $pair->publicKey); // The form field on this same page — otherwise the next saveInfra() // would write the OLD public key back over the one just published. $this->sshPublicKey = $pair->publicKey; $this->entered[self::field('ssh.private_key')] = ''; $this->check = null; $this->dispatch('notify', message: __('secrets.ssh_generated')); } /** ConfirmGenerateSshKey dispatches this back (R23) — see that class. */ #[On('ssh-key-generate-confirmed')] public function onGenerateSshKeyConfirmed(): void { $this->generateSshKey(); } /** * Push the plan catalogue into Stripe from here, rather than from a shell. * * Deliberately NOT synchronous. The command talks to Stripe and creates * objects; a first run on a full catalogue is seconds to minutes, and a * button that held the request open for it would answer with a gateway * timeout while the run carried on behind it. So it goes on the queue and * the result is shown on this page when it lands — see RunCatalogueSync. * * The dry run is the FIRST button and asks nothing: it changes nothing and * prints what would be created. The live run is the second and confirms * (R23). Anything else would be a button that spends money on one click. * * `plans.manage` rather than the vault lock: this is the plan catalogue * being mirrored, the same decision as publishing a version, and it reveals * no credential. Owner and Admin, which is the same pair that can reach * this page for the DNS zone. */ public function syncCatalogue(bool $dryRun = true): void { $this->authorize('plans.manage'); RunCatalogueSync::dispatch( dryRun: $dryRun, by: Auth::guard('operator')->user()?->email ?? 'console', ); $this->dispatch('notify', message: __($dryRun ? 'integrations.catalogue_dry_run_queued' : 'integrations.catalogue_sync_queued')); } /** ConfirmSyncCatalogue dispatches this back (R23) — see that class. */ #[On('catalogue-sync-confirmed')] public function onCatalogueSyncConfirmed(): void { $this->syncCatalogue(false); } 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); $this->checkedKey = $key; // Als Modal, nicht nur an der Karte. Zweimal gemeldet: erst „der Knopf // tut nichts" (die Antwort stand am Seitenende), dann // „runterscrollen tut niemand, wenn man es nicht weiss". Eine Antwort, // die man suchen muss, ist für den Betreiber keine. $this->dispatch('openModal', component: 'admin.check-result', arguments: [ 'entryKey' => $key, 'result' => $this->check, ]); } /** 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; $this->checkedKey = 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(), // What the last catalogue run printed. Null until somebody has // pressed one of the two buttons once. 'catalogueSync' => app(CatalogueSyncStatus::class)->last(), 'canCatalogue' => Gate::allows('plans.manage'), 'canSecrets' => $canSecrets, 'canInfra' => $canInfra, 'unlocked' => $unlocked, 'usable' => $vault->isUsable(), 'mode' => OperatingMode::current(), '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', // …and the only entry this console can mint for itself. // Everything else here is issued by somebody else (Stripe, // Hetzner, the mail host) and can only be pasted in. 'generatable' => $key === 'ssh.private_key', // Das Ergebnis GEHÖRT zu diesem Eintrag, sonst null. So // steht die Antwort unter dem Knopf, der sie ausgelöst hat. 'check' => $this->checkedKey === $key ? $this->check : null, ]) ->keyBy('key'), ]); } }