221 lines
8.0 KiB
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";
|
|
}
|
|
}
|