CluPilotCloud/app/Console/Commands/BindHosts.php

221 lines
8.0 KiB
PHP

<?php
namespace App\Console\Commands;
use App\Services\Env\EnvFileEditor;
use App\Services\Env\InvalidEnvContentException;
use App\Support\HostnamePattern;
use Illuminate\Console\Command;
/**
* Trägt die Hostnamen in eine bestehende .env nach.
*
* `install.sh` schreibt die .env nur bei einer NEUEN Installation („Keeping the
* existing .env"). Jede Maschine, die es schon gibt, bekommt die vier Schlüssel
* also nie — und ohne sie laufen die Route::domain()-Gruppen host-unabhängig
* und antworten überall. Das ist kein Schönheitsfehler: der Betreiber erreicht
* dann die Website unter app. und die Anmeldung unter www.
*
* Über EnvFileEditor und nicht mit einem eigenen `sed`: die Datei hält jedes
* Geheimnis dieser Installation, und der Editor prüft den neuen Inhalt Zeile
* für Zeile und legt vorher eine Kopie mit Zeitstempel daneben.
*
* Was gesetzt ist, bleibt. Was leer ist, gilt als fehlend — .env.example
* liefert die vier Schlüssel leer aus, und ein Befehl, der leere Zeilen als
* „steht ja schon da" behandelt, hilft genau auf den Installationen nicht, für
* die es ihn gibt.
*/
class BindHosts extends Command
{
protected $signature = 'clupilot:bind-hosts
{--app= : Hostname des Kundenportals, z. B. app.clupilot.com}
{--site= : Hostnamen der Website, kommagetrennt, der erste ist kanonisch}
{--status= : Hostname der Statusseite}
{--files= : Hostname für Downloads}
{--dry-run : Nur zeigen, was geschähe}
{--force : Ohne Rückfrage schreiben}';
protected $description = 'Trägt APP_HOST, SITE_HOST, STATUS_HOST und FILES_HOST in eine bestehende .env nach';
public function handle(EnvFileEditor $env): int
{
$content = $env->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'),
];
foreach ($wanted as $key => $value) {
if ($value !== '' && ! $this->isHostList($value)) {
$this->error("{$key}: „{$value}“ ist kein Hostname.");
$this->line('Erwartet wird ein Name wie app.example.com, mehrere kommagetrennt.');
return self::FAILURE;
}
}
$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;
}
try {
$backup = $env->write($this->apply($content, $missing));
} catch (InvalidEnvContentException $e) {
// Abgelehnt heißt hier: NICHTS geschrieben, und die Sicherung ist
// erst gar nicht angelegt worden — der Editor prüft vor beidem.
// Der Betreiber soll das als Satz erfahren, nicht als Stapelabzug.
$this->error('Die Datei wurde nicht geschrieben: '.$e->getMessage());
return self::FAILURE;
}
$this->info('Geschrieben. Die vorherige Fassung liegt unter '.$backup);
$this->line('Danach: php artisan config:cache && php artisan route:cache');
return self::SUCCESS;
}
/** Eine Komma-Liste, in der JEDER Eintrag ein Hostname ist. */
private function isHostList(string $value): bool
{
// `explode(',', $value)` liefert nie ein leeres Array — selbst für
// `''` steht `['']` da, und das faengt der `in_array('', …)` gleich
// darunter ab.
$names = array_map('trim', explode(',', $value));
if (in_array('', $names, true)) {
return false;
}
foreach ($names as $name) {
if (! $this->isHost($name)) {
return false;
}
}
return true;
}
/**
* Dasselbe Muster, das der root-eigene Helfer in `apply-proxy-hosts`
* benutzt, bevor er einen Namen in die Proxy-Konfiguration schreibt
* (deploy/install-agent.sh) — jetzt in HostnamePattern, denn
* PublishTunnelNames braucht dieselbe Prüfung fürs Lesen, ohne diese
* Klasse dafür kennen zu müssen.
*
* Der Anlass ist konkret: `[www.example.com](https://www.example.com)` ist
* als Hostname offensichtlich Unsinn und wurde trotzdem geschrieben, weil
* die Zeile die Form KEY=value hatte. Das Repo kennt diese Falle schon —
* RestrictConsoleNetwork::isNetwork() gibt es, weil ein Eintrag, der nichts
* trifft, sonst „stored happily and reports success".
*/
private function isHost(string $name): bool
{
return HostnamePattern::matches($name);
}
/**
* 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.
*
* Zeilenweise und ohne `preg_replace`: der Wert kommt von der Befehlszeile,
* und `preg_replace` deutet `$1`, `\1` und `\\` im ERSATZ als Rückverweise.
* Aus `--site 'www.example.test$1'` würde still `SITE_HOST=www.example.test`
* — und diese Datei hält jedes Geheimnis dieser Installation. Ein leise
* verstümmelter Wert darin ist schlimmer als eine Fehlermeldung.
*
* 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<string, string> $values
*/
private function apply(string $content, array $values): string
{
$lines = preg_split('/\R/', rtrim($content, "\r\n"));
foreach ($values as $key => $value) {
$replaced = false;
foreach ($lines as $index => $line) {
if (str_starts_with($line, $key.'=')) {
$lines[$index] = $key.'='.$value;
$replaced = true;
break;
}
}
if (! $replaced) {
$lines[] = $key.'='.$value;
}
}
return implode("\n", $lines)."\n";
}
}