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'), ]); } }