diff --git a/app/Console/Commands/BindHosts.php b/app/Console/Commands/BindHosts.php new file mode 100644 index 0000000..f918660 --- /dev/null +++ b/app/Console/Commands/BindHosts.php @@ -0,0 +1,146 @@ +read(); + + if (trim($content) === '') { + $this->error("Keine .env unter {$env->path()}."); + + return self::FAILURE; + } + + $wanted = [ + 'APP_HOST' => (string) ($this->option('app') ?: $this->hostOf($content)), + 'SITE_HOST' => (string) $this->option('site'), + 'STATUS_HOST' => (string) $this->option('status'), + 'FILES_HOST' => (string) $this->option('files'), + ]; + + $missing = []; + + foreach ($wanted as $key => $value) { + if ($value === '' || $this->valueOf($content, $key) !== '') { + continue; + } + + $missing[$key] = $value; + } + + if ($missing === []) { + $this->info('Nichts nachzutragen — jeder angegebene Hostname steht bereits in der Datei.'); + + return self::SUCCESS; + } + + $this->line('In '.$env->path().':'); + + foreach ($missing as $key => $value) { + $this->line(" {$key}={$value}"); + } + + // Der Satz, der einen Ausfall verhindert. Ein Hostname wird durch das + // Binden zur EINZIGEN Adresse, unter der diese Routen noch antworten — + // steht dafür kein DNS-Eintrag und kein Block im Reverse Proxy, ist der + // Bereich danach schlicht nicht mehr erreichbar. + $this->newLine(); + $this->warn('Jeder dieser Namen braucht einen DNS-Eintrag und einen Block im Reverse Proxy.'); + $this->warn('Ohne den ist der jeweilige Bereich nach dem Neuladen der Konfiguration nicht mehr erreichbar.'); + $this->newLine(); + + if ($this->option('dry-run')) { + $this->info('--dry-run: nichts geschrieben.'); + + return self::SUCCESS; + } + + if (! $this->option('force') && ! $this->confirm('Diese Namen jetzt binden?')) { + $this->line('Nichts geschrieben.'); + + return self::FAILURE; + } + + $backup = $env->write($this->apply($content, $missing)); + + $this->info('Geschrieben. Die vorherige Fassung liegt unter '.$backup); + $this->line('Danach: php artisan config:cache && php artisan route:cache'); + + return self::SUCCESS; + } + + /** + * Der Wert eines Schlüssels, oder '' wenn er fehlt ODER leer ist. + * + * Beides zusammen, absichtlich: siehe Klassenkommentar. + */ + private function valueOf(string $content, string $key): string + { + return preg_match('/^'.preg_quote($key, '/').'=(.*)$/m', $content, $matches) === 1 + ? trim($matches[1]) + : ''; + } + + /** Der Hostname aus APP_URL — die Antwort für APP_HOST steht schon in der Datei. */ + private function hostOf(string $content): string + { + return (string) parse_url($this->valueOf($content, 'APP_URL'), PHP_URL_HOST); + } + + /** + * Vorhandene Zeile ersetzen, sonst anhängen. + * + * Ersetzen und nicht nur anhängen, weil ein leerer Schlüssel als fehlend + * gilt: `APP_HOST=` steht dann schon da, und ein zweites `APP_HOST=…` + * darunter wäre eine Datei mit zwei Antworten auf dieselbe Frage. + * + * @param array $values + */ + private function apply(string $content, array $values): string + { + foreach ($values as $key => $value) { + $line = $key.'='.$value; + $pattern = '/^'.preg_quote($key, '/').'=.*$/m'; + + $content = preg_match($pattern, $content) === 1 + ? preg_replace($pattern, $line, $content, 1) + : rtrim($content, "\n")."\n".$line."\n"; + } + + return $content; + } +} diff --git a/tests/Feature/BindHostsCommandTest.php b/tests/Feature/BindHostsCommandTest.php new file mode 100644 index 0000000..95c2d6e --- /dev/null +++ b/tests/Feature/BindHostsCommandTest.php @@ -0,0 +1,112 @@ +envPath = sys_get_temp_dir().'/clupilot-env-'.bin2hex(random_bytes(6)); + $this->app->instance(EnvFileEditor::class, new EnvFileEditor($this->envPath)); +}); + +afterEach(function () { + foreach (glob($this->envPath.'*') ?: [] as $file) { + @unlink($file); + } +}); + +it('trägt die fehlenden Hostnamen nach', function () { + file_put_contents($this->envPath, "APP_URL=https://app.example.test\nADMIN_HOSTS=admin.example.test\n"); + + $this->artisan('clupilot:bind-hosts', [ + '--site' => 'www.example.test,example.test', + '--status' => 'status.example.test', + '--files' => 'files.example.test', + '--force' => true, + ])->assertSuccessful(); + + $written = file_get_contents($this->envPath); + + // APP_HOST kommt aus dem Host von APP_URL — danach muss niemand gefragt + // werden, die Antwort steht schon in der Datei. + expect($written)->toContain('APP_HOST=app.example.test') + ->and($written)->toContain('SITE_HOST=www.example.test,example.test') + ->and($written)->toContain('STATUS_HOST=status.example.test') + ->and($written)->toContain('FILES_HOST=files.example.test') + // Und nichts Vorhandenes geht verloren. + ->and($written)->toContain('APP_URL=https://app.example.test') + ->and($written)->toContain('ADMIN_HOSTS=admin.example.test'); +}); + +it('überschreibt niemals einen Wert, den jemand von Hand gesetzt hat', function () { + file_put_contents( + $this->envPath, + "APP_URL=https://app.example.test\nAPP_HOST=eigener.example.test\nSITE_HOST=\n", + ); + + $this->artisan('clupilot:bind-hosts', [ + '--site' => 'www.example.test', + '--force' => true, + ])->assertSuccessful(); + + $written = file_get_contents($this->envPath); + + // Gesetzt bleibt gesetzt … + expect($written)->toContain('APP_HOST=eigener.example.test') + ->and($written)->not->toContain('APP_HOST=app.example.test') + // … und leer gilt als fehlend, denn genau so liefert .env.example aus. + ->and($written)->toContain('SITE_HOST=www.example.test'); +}); + +it('sichert die alte Datei, bevor es schreibt', function () { + file_put_contents($this->envPath, "APP_URL=https://app.example.test\n"); + + $this->artisan('clupilot:bind-hosts', ['--force' => true])->assertSuccessful(); + + // EnvFileEditor legt eine Kopie mit Zeitstempel daneben. Diese Datei hält + // jedes Geheimnis der Installation; ein Schreibfehler darf sie nicht + // ersatzlos ersetzen. + expect(glob($this->envPath.'.bak-*'))->not->toBeEmpty(); +}); + +it('tut ohne Bestätigung nichts', function () { + file_put_contents($this->envPath, "APP_URL=https://app.example.test\n"); + + $this->artisan('clupilot:bind-hosts', ['--site' => 'www.example.test']) + ->expectsConfirmation( + 'Diese Namen jetzt binden?', + 'no', + ) + ->assertFailed(); + + expect(file_get_contents($this->envPath))->not->toContain('SITE_HOST'); +}); + +it('schreibt bei --dry-run nichts, sagt aber was geschähe', function () { + file_put_contents($this->envPath, "APP_URL=https://app.example.test\n"); + + $this->artisan('clupilot:bind-hosts', ['--site' => 'www.example.test', '--dry-run' => true]) + ->expectsOutputToContain('SITE_HOST=www.example.test') + ->assertSuccessful(); + + expect(file_get_contents($this->envPath))->not->toContain('SITE_HOST') + ->and(glob($this->envPath.'.bak-*'))->toBeEmpty(); +}); + +it('sagt es, wenn schon alles steht, statt eine Sicherung anzulegen', function () { + file_put_contents($this->envPath, implode("\n", [ + 'APP_URL=https://app.example.test', + 'APP_HOST=app.example.test', + 'SITE_HOST=www.example.test', + 'STATUS_HOST=status.example.test', + 'FILES_HOST=files.example.test', + '', + ])); + + $this->artisan('clupilot:bind-hosts', ['--force' => true]) + ->expectsOutputToContain('Nichts nachzutragen') + ->assertSuccessful(); + + expect(glob($this->envPath.'.bak-*'))->toBeEmpty(); +});