diff --git a/.gitignore b/.gitignore index 4c90594..d96e697 100644 --- a/.gitignore +++ b/.gitignore @@ -3,6 +3,10 @@ .env .env.backup .env.production +# EnvFileEditor's own backups (App\Services\Env\EnvFileEditor::backup()) — a +# timestamped copy beside .env before every write from the console's raw +# editor. Never pruned automatically; see the warning on that page. +.env.bak-* .phpactor.json .phpunit.result.cache /.codex diff --git a/app/Livewire/Admin/ConfirmSaveEnv.php b/app/Livewire/Admin/ConfirmSaveEnv.php new file mode 100644 index 0000000..144275b --- /dev/null +++ b/app/Livewire/Admin/ConfirmSaveEnv.php @@ -0,0 +1,35 @@ +authorize('secrets.manage'); + } + + public function confirm(): void + { + $this->authorize('secrets.manage'); + $this->dispatch('env-save-confirmed'); + $this->closeModal(); + } + + public function render() + { + return view('livewire.admin.confirm-save-env'); + } +} diff --git a/app/Livewire/Admin/Integrations.php b/app/Livewire/Admin/Integrations.php index 2c8bc94..3e6b199 100644 --- a/app/Livewire/Admin/Integrations.php +++ b/app/Livewire/Admin/Integrations.php @@ -3,6 +3,8 @@ 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; @@ -41,11 +43,18 @@ use Throwable; * 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; + use ConfirmsPassword { + forgetPasswordConfirmation as private lockAgain; + } // Vault entries — same shape ConfirmSaveSecret/ConfirmForgetSecret expect // and dispatch back into (secret-save-confirmed / secret-forget-confirmed). @@ -66,6 +75,16 @@ class Integrations extends Component 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'; @@ -178,12 +197,62 @@ class Integrations extends Component 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 action. */ + /** Capability AND a recently confirmed password, on every vault/.env action. */ private function guardSecrets(): void { $this->authorize('secrets.manage'); @@ -197,6 +266,27 @@ class Integrations extends Component $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, @@ -207,6 +297,7 @@ class Integrations extends Component '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, diff --git a/app/Services/Env/EnvFileEditor.php b/app/Services/Env/EnvFileEditor.php new file mode 100644 index 0000000..af8f85b --- /dev/null +++ b/app/Services/Env/EnvFileEditor.php @@ -0,0 +1,146 @@ +path !== '' ? $this->path : base_path('.env'); + } + + public function read(): string + { + return File::exists($this->path()) ? File::get($this->path()) : ''; + } + + /** + * The first line that is neither blank, nor a comment, nor KEY=value — + * or null when every line is one of those three things. 1-indexed, so it + * can be shown to an operator directly ("Zeile 42"). + */ + public function firstInvalidLine(string $content): ?int + { + foreach (preg_split('/\r\n|\r|\n/', $content) as $number => $line) { + if (! self::isValidLine($line)) { + return $number + 1; + } + } + + return null; + } + + public function isValid(string $content): bool + { + return $this->firstInvalidLine($content) === null; + } + + /** + * Validate, back up, then write — in that order, and never out of it. + * Content that fails validation is never backed up (nothing is about to + * change) and never written. + * + * @return string the backup path, so the caller can tell the operator + * exactly where the previous version landed + * + * @throws InvalidEnvContentException the content is empty, or does not parse + */ + public function write(string $content): string + { + if (trim($content) === '') { + throw InvalidEnvContentException::empty(); + } + + $line = $this->firstInvalidLine($content); + if ($line !== null) { + throw InvalidEnvContentException::atLine($line); + } + + $backup = $this->backup(); + File::put($this->path(), $content); + + return $backup; + } + + /** + * A timestamped copy of the CURRENT file, beside it — before whatever + * write() is about to do to the original. Microseconds in the name so two + * saves a second apart (a real thing to hit while testing this class, not + * only a hypothetical operator habit) do not collide and silently + * overwrite one another. + * + * Returns '' — never a path — when there is nothing to back up, i.e. the + * file does not exist yet. read() already treats a missing file as '' + * rather than an error (Codex review, P2); write() has to agree, or the + * very first save on a fresh installation would fail trying to copy a + * file that was never there instead of simply creating one. + */ + public function backup(): string + { + if (! File::exists($this->path())) { + return ''; + } + + $target = $this->path().'.bak-'.now()->format('Ymd-His-u'); + + if (! File::copy($this->path(), $target)) { + throw new \RuntimeException("Could not back up [{$this->path()}] before writing it."); + } + + return $target; + } + + private static function isValidLine(string $line): bool + { + $line = ltrim(rtrim($line, "\r\n")); + + if ($line === '' || str_starts_with($line, '#')) { + return true; + } + + return (bool) preg_match('/^[A-Za-z_][A-Za-z0-9_]*=.*$/', $line); + } +} diff --git a/app/Services/Env/InvalidEnvContentException.php b/app/Services/Env/InvalidEnvContentException.php new file mode 100644 index 0000000..e223ebc --- /dev/null +++ b/app/Services/Env/InvalidEnvContentException.php @@ -0,0 +1,35 @@ + 'services.stripe.secret', 'label' => 'secrets.item.stripe_secret', 'check' => StripeCheck::class, + 'env_key' => 'STRIPE_SECRET', ], 'dns.token' => [ 'config' => 'provisioning.dns.token', 'label' => 'secrets.item.dns_token', + 'env_key' => 'HETZNER_DNS_TOKEN', ], 'monitoring.token' => [ 'config' => 'services.monitoring.token', 'label' => 'secrets.item.monitoring_token', + 'env_key' => 'MONITORING_API_TOKEN', ], 'ssh.private_key' => [ 'config' => 'provisioning.ssh.private_key', 'label' => 'secrets.item.ssh_private_key', + // File-or-inline (see config/provisioning.php's $secretFrom) — the + // editor names both, since either can be the one actually in force. + 'env_key' => 'CLUPILOT_SSH_PRIVATE_KEY / CLUPILOT_SSH_PRIVATE_KEY_PATH', ], ]; diff --git a/lang/de/integrations.php b/lang/de/integrations.php index c32561c..48a90b4 100644 --- a/lang/de/integrations.php +++ b/lang/de/integrations.php @@ -33,4 +33,37 @@ return [ 'save_settings' => 'Einstellungen speichern', 'settings_saved' => 'Gespeichert. Der neue Wert gilt ab sofort.', + + // .env-Editor (Teil B). + 'env_title' => 'Erweitert: Serverdatei (.env)', + 'env_subtitle' => 'Alles, was oben kein eigenes Feld hat — roh, wie auf dem Server. Ohne diesen Editor bräuchte es dafür eine Shell.', + 'env_locked' => 'Gesperrt. Oben entsperren, um die Serverdatei zu sehen.', + + 'env_danger_title' => 'Diese Zeilen können die Konsole selbst aussperren', + 'env_danger_body' => 'Ein falscher Wert in einer dieser Zeilen macht die Konsole für Sie selbst unerreichbar: APP_KEY, DB_* (Datenbankverbindung), REDIS_* (Sitzungen, Cache, Warteschlange), SESSION_*. Nicht gesperrt — volle Kontrolle war die Entscheidung —, aber diese Zeilen verdienen einen zweiten Blick.', + + 'env_vault_title' => 'Von der Zugangsdaten-Verwaltung verwaltet', + 'env_vault_body' => 'SecretVault::get() bevorzugt einen hier hinterlegten Wert und weicht nur auf die Serverdatei aus, wenn keiner hinterlegt ist. Solange oben ein Wert hinterlegt ist, ändert die passende Zeile unten nichts — sonst widerspräche sich die Konsole selbst.', + 'env_vault_active' => 'hinterlegt — diese Zeile wirkt nicht', + 'env_vault_inactive' => 'nicht hinterlegt — diese Zeile gilt', + + 'env_label' => 'Inhalt von .env', + 'env_save' => 'Speichern', + + 'env_backup_title' => 'Sicherung', + 'env_backup_body' => 'Vor jedem Speichern wird eine Kopie neben .env abgelegt (Dateiname .env.bak-Zeitstempel). Sie werden nicht automatisch gelöscht.', + + 'env_restart_title' => 'Was Speichern nicht tut', + 'env_restart_body' => 'queue, queue-provisioning, scheduler und reverb lesen .env nur beim eigenen Start — ein hier gespeicherter Wert wirkt dort erst nach einem Neustart. Ohne ihn sieht es aus, als hätte der Editor nichts getan.', + 'env_config_clear' => 'Ist der Konfigurations-Cache aktiv, zusätzlich: docker compose exec -T app php artisan config:clear.', + + 'env_save_title' => '.env wirklich überschreiben?', + 'env_save_body' => 'Sie enthält jedes Zugangsdatum im System. Eine Sicherung wird zuvor angelegt — betroffene Dienste übernehmen die Änderung trotzdem erst nach einem Neustart.', + 'env_save_confirm' => 'Überschreiben', + 'env_saved' => 'Gespeichert. Sicherung: :backup. Betroffene Dienste übernehmen die Änderung erst nach einem Neustart.', + // Nur wenn keine .env vorher existierte — dann gibt es nichts zu sichern. + 'env_no_previous_file' => 'keine, da zuvor keine Datei existierte', + + 'env_invalid' => 'Zeile :line ist weder leer, noch ein Kommentar, noch SCHLÜSSEL=Wert. Nichts wurde gespeichert.', + 'env_empty' => 'Eine leere Datei würde jede Einstellung löschen. Nichts wurde gespeichert.', ]; diff --git a/lang/en/integrations.php b/lang/en/integrations.php index 86a7ec2..91643cd 100644 --- a/lang/en/integrations.php +++ b/lang/en/integrations.php @@ -33,4 +33,37 @@ return [ 'save_settings' => 'Save settings', 'settings_saved' => 'Saved. The new value applies immediately.', + + // .env editor (Part B). + 'env_title' => 'Advanced: server file (.env)', + 'env_subtitle' => 'Everything above with no field of its own — raw, as it sits on the server. Without this editor, that still needs a shell.', + 'env_locked' => 'Locked. Unlock above to see the server file.', + + 'env_danger_title' => 'These lines can lock you out of the console itself', + 'env_danger_body' => 'A wrong value on one of these lines makes the console unreachable for you too: APP_KEY, DB_* (the database connection), REDIS_* (sessions, cache, queue), SESSION_*. Not blocked — full control was the decision — but these lines are worth a second look.', + + 'env_vault_title' => 'Managed by credential storage', + 'env_vault_body' => 'SecretVault::get() prefers a value stored there and only falls back to the server file when none is stored. While a value is stored above, the matching line below changes nothing — otherwise the console would contradict itself.', + 'env_vault_active' => 'stored — this line has no effect', + 'env_vault_inactive' => 'not stored — this line applies', + + 'env_label' => 'Contents of .env', + 'env_save' => 'Save', + + 'env_backup_title' => 'Backup', + 'env_backup_body' => 'A copy is placed beside .env before every save (filename .env.bak-timestamp). Nothing deletes them automatically.', + + 'env_restart_title' => 'What saving does not do', + 'env_restart_body' => 'queue, queue-provisioning, scheduler and reverb only read .env at their own startup — a value saved here does not take effect there until they restart. Without a restart, it looks like the editor did nothing.', + 'env_config_clear' => 'With the config cache active, also run: docker compose exec -T app php artisan config:clear.', + + 'env_save_title' => 'Really overwrite .env?', + 'env_save_body' => 'It holds every credential in the system. A backup is made first — affected services still only pick up the change after a restart.', + 'env_save_confirm' => 'Overwrite', + 'env_saved' => 'Saved. Backup: :backup. Affected services pick up the change only after a restart.', + // Only when no .env existed before — then there is nothing to back up. + 'env_no_previous_file' => 'none, no file existed before this', + + 'env_invalid' => 'Line :line is neither blank, a comment, nor KEY=value. Nothing was saved.', + 'env_empty' => 'An empty file would erase every setting. Nothing was saved.', ]; diff --git a/resources/views/livewire/admin/confirm-save-env.blade.php b/resources/views/livewire/admin/confirm-save-env.blade.php new file mode 100644 index 0000000..fcc6657 --- /dev/null +++ b/resources/views/livewire/admin/confirm-save-env.blade.php @@ -0,0 +1,17 @@ +
{{ __('integrations.env_save_body') }}
+