CluPilotCloud/app/Http/Controllers/BootstrapArchiveController.php

134 lines
4.6 KiB
PHP

<?php
namespace App\Http\Controllers;
use App\Support\HostEnrolment;
use Illuminate\Http\Request;
use Symfony\Component\HttpFoundation\BinaryFileResponse;
use Symfony\Component\Process\Process;
/**
* Liefert das Bootstrap-Skript als Archiv aus.
*
* Das ist der einzige Weg, auf dem das Skript auf eine nackte Maschine kommt:
* ein Rettungssystem hat nichts als curl. Es ist eine STATISCHE Datei und kein
* Endpunkt, der Auskunft gibt — Spec §5 bleibt damit unangetastet. Es steht
* nichts Vertrauliches darin; alle Geheimnisse reist der Betreiber in der
* Befehlszeile mit, nicht im Archiv.
*
* Als Archiv und nicht als eine Datei, weil `curl … | sh` keine `lib/` haben
* kann und der Skript-Plan getrennte Dateien verlangt: `network.sh` und
* `template.sh` sollen einzeln nachlesbar und wiederholbar bleiben, wenn auf
* einer Maschine um drei Uhr morgens etwas klemmt.
*
* Gebaut wird beim ersten Abruf und danach nur, wenn sich unter `deploy/bootstrap`
* etwas geändert hat. Ein Archiv, das im Repo läge, wäre die Fassung von dem
* Tag, an dem jemand zuletzt daran gedacht hat, es neu zu bauen.
*/
class BootstrapArchiveController extends Controller
{
public function __invoke(Request $request): BinaryFileResponse
{
// Der Ausweis ist der Einmal-Code, der ohnehin schon in der kopierten
// Zeile steht. Kein zweites Geheimnis: ein eigener Download-Token liefe
// nie ab, stünde für immer in Shell-Historien, und er reiste in
// derselben Zeile wie der WireGuard-Schlüssel — er schützte also das
// Unwichtigste mit derselben Aussetzung wie das Wichtigste.
//
// `resolve()` und nicht `claim()`: der Code wird hier NICHT verbraucht.
// Er wird nach dem Herunterladen noch für jede Fortschrittsmeldung und
// zuletzt für die Registrierung gebraucht.
//
// 404 und nicht 403, dieselbe Regel wie bei der Konsole: ein Fremder
// soll nicht erfahren, dass es hier etwas gibt.
$code = (string) ($request->query('code') ?? $request->header('X-CluPilot-Code', ''));
if (HostEnrolment::resolve($code) === null) {
abort(404);
}
$archive = storage_path('app/bootstrap.tar.gz');
if ($this->isStale($archive)) {
$this->build($archive);
}
return response()
->file($archive, [
'Content-Type' => 'application/gzip',
'Content-Disposition' => 'attachment; filename="bootstrap.tar.gz"',
// Nicht in den Index. Die Adresse ist ohne Code wertlos, aber
// eine Suchmaschine, die sie kennt, verrät immerhin, dass es
// sie gibt.
'X-Robots-Tag' => 'noindex, nofollow',
]);
}
/**
* Neuer als alles, was drinsteckt?
*
* Über die Änderungszeit und nicht über einen Hash: das Verzeichnis ist
* klein, der Vergleich läuft bei jedem Abruf, und ein Hash über alle Dateien
* kostete bei jedem Abruf mehr als das Packen gelegentlich kostet.
*/
private function isStale(string $archive): bool
{
if (! is_file($archive)) {
return true;
}
$built = filemtime($archive);
foreach ($this->sourceFiles() as $file) {
if (filemtime($file) > $built) {
return true;
}
}
return false;
}
/** @return list<string> */
private function sourceFiles(): array
{
$root = base_path('deploy/bootstrap');
if (! is_dir($root)) {
return [];
}
$files = [];
$iterator = new \RecursiveIteratorIterator(
new \RecursiveDirectoryIterator($root, \FilesystemIterator::SKIP_DOTS)
);
foreach ($iterator as $file) {
if ($file->isFile()) {
$files[] = $file->getPathname();
}
}
return $files;
}
/**
* Mit GNU tar und nicht mit PharData: `phar.readonly` steht in diesem
* Container auf 1, und das Ausführbar-Bit soll erhalten bleiben.
*
* Der oberste Eintrag heißt `bootstrap/`, damit die kopierte Befehlszeile
* mit `tar xz -C /opt/clupilot` genau `/opt/clupilot/bootstrap` ergibt —
* der Ort, an dem das Skript sich selbst und seine Bibliothek erwartet.
*/
private function build(string $archive): void
{
@mkdir(dirname($archive), 0755, true);
$process = new Process(
['tar', 'czf', $archive, '-C', base_path('deploy'), 'bootstrap'],
timeout: 60,
);
$process->mustRun();
}
}