Give the downloads a hostname of their own, with two halves
A domain that exists only to serve one installer is not worth a certificate. One that also carries the AGB, the AV and the TOM is — those addresses go into contracts and onto invoices and have to still resolve in three years, which an address that moves with the next rebuild of the portal cannot promise. That reason is what changed the answer. files.… over cdn./storage./archiv.: a CDN is an edge network and this is not one, so the name would be a lie the day a real CDN goes in front of it. "storage" reads like object storage or customer data, and a customer seeing it will wonder whether their files live there. "archiv" says superseded, which the terms currently in force are not — and R13 keeps paths and names English anyway. Two halves on that host, with opposite rules, and that is the whole point of giving it its own name: Public — storage/app/files/public/, served to anyone, indexable, because somebody looking for the terms should find them. Versioned filenames: agb-2026-01.pdf, never agb.pdf, so a contract signed in January cannot come to point at conditions written in July. The rule is written where somebody will look for it rather than enforced, because a upload that rejects a filename helps nobody. Private — the installer, and it is not a file in that directory at all: it is built from deploy/bootstrap on demand. The gate is the one-time enrolment code that is ALREADY in the pasted line, resolved without being consumed, because the code is still needed for every progress report and for the registration at the end. No second secret: a dedicated download token would never expire, would sit in shell histories forever, and would travel in the same line as the WireGuard private key — protecting the least sensitive thing with the exposure of the most sensitive one. 404 rather than 403 on a bad code, and noindex on the response. Path traversal is answered before it starts: basename() only, no directory tree under public/ by design, and dotfiles refused. The test walks ../../.env three different ways. Empty FILES_HOST keeps the archive on the portal host exactly as before, so nothing breaks in the window between setting the variable and the DNS record existing. The hostname had to move into phpunit.xml rather than a config()->set(): routes are bound at boot, so a test that sets it afterwards is setting it too late. 2025 tests pass. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>feature/host-bootstrap
parent
1e22d66639
commit
156de12c8c
|
|
@ -214,6 +214,11 @@ CLUPILOT_TAX_PERCENT=20
|
||||||
# www.clupilot.com/dashboard ist dann ein 404, kein funktionierender Aufruf.
|
# www.clupilot.com/dashboard ist dann ein 404, kein funktionierender Aufruf.
|
||||||
# Leer = Portal antwortet ueberall (Vorgabe, und was jede dev-Maschine ueber
|
# Leer = Portal antwortet ueberall (Vorgabe, und was jede dev-Maschine ueber
|
||||||
# eine blanke IP braucht).
|
# eine blanke IP braucht).
|
||||||
|
# Hostname fuer Dateien zum Herunterladen: AGB, AV, TOM (oeffentlich) und das
|
||||||
|
# Bootstrap-Archiv (nur mit gueltigem Einmal-Code). Leer = das Archiv bleibt
|
||||||
|
# auf dem Portal-Hostnamen.
|
||||||
|
FILES_HOST=
|
||||||
|
|
||||||
APP_HOST=
|
APP_HOST=
|
||||||
|
|
||||||
# SITE_HOST: Hostname der oeffentlichen Website. Gesetzt, antwortet die
|
# SITE_HOST: Hostname der oeffentlichen Website. Gesetzt, antwortet die
|
||||||
|
|
|
||||||
|
|
@ -2,6 +2,7 @@
|
||||||
|
|
||||||
namespace App\Http\Controllers;
|
namespace App\Http\Controllers;
|
||||||
|
|
||||||
|
use App\Support\HostEnrolment;
|
||||||
use Illuminate\Http\Request;
|
use Illuminate\Http\Request;
|
||||||
use Symfony\Component\HttpFoundation\BinaryFileResponse;
|
use Symfony\Component\HttpFoundation\BinaryFileResponse;
|
||||||
use Symfony\Component\Process\Process;
|
use Symfony\Component\Process\Process;
|
||||||
|
|
@ -28,6 +29,24 @@ class BootstrapArchiveController extends Controller
|
||||||
{
|
{
|
||||||
public function __invoke(Request $request): BinaryFileResponse
|
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');
|
$archive = storage_path('app/bootstrap.tar.gz');
|
||||||
|
|
||||||
if ($this->isStale($archive)) {
|
if ($this->isStale($archive)) {
|
||||||
|
|
@ -38,6 +57,10 @@ class BootstrapArchiveController extends Controller
|
||||||
->file($archive, [
|
->file($archive, [
|
||||||
'Content-Type' => 'application/gzip',
|
'Content-Type' => 'application/gzip',
|
||||||
'Content-Disposition' => 'attachment; filename="bootstrap.tar.gz"',
|
'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',
|
||||||
]);
|
]);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,68 @@
|
||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Http\Controllers;
|
||||||
|
|
||||||
|
use Symfony\Component\HttpFoundation\BinaryFileResponse;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Dateien, die jeder herunterladen darf: AGB, Auftragsverarbeitungsvertrag,
|
||||||
|
* technische und organisatorische Maßnahmen, Preisblätter.
|
||||||
|
*
|
||||||
|
* Sie liegen in `storage/app/files/public/` und werden ausgeliefert, wie sie
|
||||||
|
* sind. Kein Verwaltungsbereich, keine Datenbanktabelle: eine Datei ablegen
|
||||||
|
* genügt, und was nicht dort liegt, gibt es nicht.
|
||||||
|
*
|
||||||
|
* ---------------------------------------------------------------------------
|
||||||
|
* Warum eine eigene Adresse
|
||||||
|
* ---------------------------------------------------------------------------
|
||||||
|
*
|
||||||
|
* Diese Dateien stehen in Verträgen und auf Rechnungen. Eine Adresse, die beim
|
||||||
|
* nächsten Umbau des Portals wandert, macht aus jedem dieser Verweise einen
|
||||||
|
* toten Link — und zwar rückwirkend, in Dokumenten, die längst verschickt sind.
|
||||||
|
* Ein eigener Hostname ist die einzige Zusage, die sich einhalten lässt.
|
||||||
|
*
|
||||||
|
* ---------------------------------------------------------------------------
|
||||||
|
* Versionierte Dateinamen
|
||||||
|
* ---------------------------------------------------------------------------
|
||||||
|
*
|
||||||
|
* `agb-2026-01.pdf`, nicht `agb.pdf`. Eine Fassung, auf die ein Vertrag
|
||||||
|
* verweist, darf sich unter derselben Adresse NIE ändern — sonst zeigt der
|
||||||
|
* Verweis in einem Vertrag vom Januar plötzlich auf Bedingungen vom Juli, und
|
||||||
|
* niemand kann mehr sagen, was vereinbart war. Neue Fassung heißt neue Datei.
|
||||||
|
*
|
||||||
|
* Erzwungen wird das hier nicht — eine Regel, die eine Datei mit dem falschen
|
||||||
|
* Namen abweist, hilft niemandem, der eine hochladen will. Sie steht hier, weil
|
||||||
|
* dies die Stelle ist, an der jemand nachsieht.
|
||||||
|
*/
|
||||||
|
class PublicFileController extends Controller
|
||||||
|
{
|
||||||
|
private const ROOT = 'app/files/public';
|
||||||
|
|
||||||
|
public function __invoke(string $file): BinaryFileResponse
|
||||||
|
{
|
||||||
|
// NUR der Dateiname, ohne Pfad. `basename()` macht aus
|
||||||
|
// `../../.env` ein `.env`, und das liegt in diesem Verzeichnis nicht —
|
||||||
|
// damit ist der Pfad-Ausbruch beantwortet, bevor er anfängt. Ein
|
||||||
|
// Verzeichnisbaum unter `public/` gibt es absichtlich nicht: er wäre
|
||||||
|
// die zweite Gelegenheit für denselben Fehler.
|
||||||
|
$name = basename($file);
|
||||||
|
|
||||||
|
// Ein Punktdatei-Name kommt hier nicht durch. Sie sind in diesem
|
||||||
|
// Verzeichnis nicht vorgesehen, und ihr Vorkommen wäre ein Versehen.
|
||||||
|
if ($name === '' || str_starts_with($name, '.')) {
|
||||||
|
abort(404);
|
||||||
|
}
|
||||||
|
|
||||||
|
$path = storage_path(self::ROOT.'/'.$name);
|
||||||
|
|
||||||
|
if (! is_file($path)) {
|
||||||
|
abort(404);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Inline, nicht als Anhang: eine AGB, die der Browser anzeigt, wird
|
||||||
|
// gelesen; eine, die er herunterlädt, wird weggeklickt.
|
||||||
|
return response()->file($path, [
|
||||||
|
'Content-Disposition' => 'inline; filename="'.$name.'"',
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -29,7 +29,10 @@ final class HostTakeoverCommand
|
||||||
{
|
{
|
||||||
return implode(' ', [
|
return implode(' ', [
|
||||||
'mkdir -p '.self::INSTALL_DIR,
|
'mkdir -p '.self::INSTALL_DIR,
|
||||||
'&& curl -fsSL '.self::archiveUrl(),
|
// Der Code steht ohnehin gleich noch einmal als `--code` in
|
||||||
|
// derselben Zeile. Ein zweites Geheimnis für den Download wäre eines
|
||||||
|
// mehr, das nie abläuft und für immer in Shell-Historien steht.
|
||||||
|
"&& curl -fsSL '".self::archiveUrlWithCode($enrolment['code'])."'",
|
||||||
'| tar xz -C '.self::INSTALL_DIR,
|
'| tar xz -C '.self::INSTALL_DIR,
|
||||||
'&& sh '.self::INSTALL_DIR.'/bootstrap/clupilot-bootstrap.sh',
|
'&& sh '.self::INSTALL_DIR.'/bootstrap/clupilot-bootstrap.sh',
|
||||||
'--code '.$enrolment['code'],
|
'--code '.$enrolment['code'],
|
||||||
|
|
@ -54,9 +57,23 @@ final class HostTakeoverCommand
|
||||||
* Konsolen-Hostnamen, und die Zeile liefe in eine 404 auf einem Server, an
|
* Konsolen-Hostnamen, und die Zeile liefe in eine 404 auf einem Server, an
|
||||||
* den man dann nur noch über die Anbieterkonsole kommt.
|
* den man dann nur noch über die Anbieterkonsole kommt.
|
||||||
*/
|
*/
|
||||||
|
/** Die Adresse mit Ausweis — das, was wirklich in der Zeile steht. */
|
||||||
|
public static function archiveUrlWithCode(string $code): string
|
||||||
|
{
|
||||||
|
return self::archiveUrl().'?code='.rawurlencode($code);
|
||||||
|
}
|
||||||
|
|
||||||
public static function archiveUrl(): string
|
public static function archiveUrl(): string
|
||||||
{
|
{
|
||||||
$host = (string) config('admin_access.app_host');
|
// Der eigene Datei-Hostname, wenn es ihn gibt. Er trägt zweierlei mit
|
||||||
|
// denselben Ansprüchen: die Rechtsdokumente, deren Adressen in
|
||||||
|
// Verträgen stehen und Jahre halten müssen, und dieses Archiv, das ein
|
||||||
|
// Server im Rettungssystem holt, bevor er sonst irgendetwas kann.
|
||||||
|
$host = (string) config('admin_access.files_host');
|
||||||
|
|
||||||
|
if ($host === '') {
|
||||||
|
$host = (string) config('admin_access.app_host');
|
||||||
|
}
|
||||||
|
|
||||||
// APP_HOST ist auf den meisten Installationen NICHT gesetzt — leer heißt
|
// APP_HOST ist auf den meisten Installationen NICHT gesetzt — leer heißt
|
||||||
// dort „das Portal antwortet auf jedem Hostnamen", und das ist die
|
// dort „das Portal antwortet auf jedem Hostnamen", und das ist die
|
||||||
|
|
|
||||||
|
|
@ -61,6 +61,31 @@ return [
|
||||||
*/
|
*/
|
||||||
'app_host' => strtolower(trim((string) env('APP_HOST', ''))),
|
'app_host' => strtolower(trim((string) env('APP_HOST', ''))),
|
||||||
|
|
||||||
|
/*
|
||||||
|
| Hostname für Dateien zum Herunterladen, z. B. files.clupilot.com.
|
||||||
|
|
|
||||||
|
| Eine eigene Adresse, weil hier zweierlei liegt, das dieselben Ansprüche
|
||||||
|
| hat: öffentlich, dauerhaft zitierbar, immer erreichbar. Rechtsdokumente
|
||||||
|
| (AGB, AV, TOM) stehen in Verträgen und Rechnungen und müssen Jahre halten
|
||||||
|
| — eine Adresse, die mit einem Umbau des Portals wandert, taugt dafür
|
||||||
|
| nicht. Und das Bootstrap-Archiv, das ein Server im Rettungssystem holt,
|
||||||
|
| bevor er irgendetwas anderes kann.
|
||||||
|
|
|
||||||
|
| Zwei Bereiche auf diesem Hostnamen, mit entgegengesetzten Regeln:
|
||||||
|
|
|
||||||
|
| - ÖFFENTLICH: `storage/app/files/public/`. Anonym abrufbar, indexierbar.
|
||||||
|
| Wer die AGB sucht, soll sie finden.
|
||||||
|
| - PRIVAT: `/bootstrap.tar.gz`, nur mit gültigem Einmal-Code, und ohne den
|
||||||
|
| ein 404 statt eines 403 — dieselbe Regel wie bei der Konsole: ein
|
||||||
|
| Fremder soll nicht erfahren, dass es das hier gibt. Das Archiv liegt in
|
||||||
|
| keinem Verzeichnis; es wird bei Bedarf aus `deploy/bootstrap` gebaut.
|
||||||
|
|
|
||||||
|
| Leer heißt: das Archiv bleibt auf dem Portal-Hostnamen erreichbar, wie
|
||||||
|
| bisher. So bricht nichts, solange die DNS-Einträge für den neuen Namen
|
||||||
|
| noch nicht stehen.
|
||||||
|
*/
|
||||||
|
'files_host' => strtolower(trim((string) env('FILES_HOST', ''))),
|
||||||
|
|
||||||
/*
|
/*
|
||||||
| Hostnames the public website lives on, e.g. www.clupilot.com,clupilot.com.
|
| Hostnames the public website lives on, e.g. www.clupilot.com,clupilot.com.
|
||||||
|
|
|
|
||||||
|
|
|
||||||
|
|
@ -56,6 +56,11 @@
|
||||||
nothing about the code. The value is the one the code itself
|
nothing about the code. The value is the one the code itself
|
||||||
defaults to. -->
|
defaults to. -->
|
||||||
<env name="CLUPILOT_DNS_ZONE" value="clupilot.cloud" force="true"/>
|
<env name="CLUPILOT_DNS_ZONE" value="clupilot.cloud" force="true"/>
|
||||||
|
<!-- Die Datei-Domain wird beim Registrieren der Routen gelesen, nicht
|
||||||
|
beim Ausführen eines Tests. Ein config()->set() im Test kommt zu
|
||||||
|
spät: die Route existiert dann bereits oder eben nicht. -->
|
||||||
|
<env name="FILES_HOST" value="files.clupilot.test" force="true"/>
|
||||||
|
<env name="CLUPILOT_PLATFORM_ZONE" value="clupilot.com" force="true"/>
|
||||||
<env name="APP_MAINTENANCE_DRIVER" value="file"/>
|
<env name="APP_MAINTENANCE_DRIVER" value="file"/>
|
||||||
<env name="BCRYPT_ROUNDS" value="4" force="true"/>
|
<env name="BCRYPT_ROUNDS" value="4" force="true"/>
|
||||||
<env name="BROADCAST_CONNECTION" value="null" force="true"/>
|
<env name="BROADCAST_CONNECTION" value="null" force="true"/>
|
||||||
|
|
|
||||||
|
|
@ -3,6 +3,7 @@
|
||||||
use App\Http\Controllers\BootstrapArchiveController;
|
use App\Http\Controllers\BootstrapArchiveController;
|
||||||
use App\Http\Controllers\ImpersonationController;
|
use App\Http\Controllers\ImpersonationController;
|
||||||
use App\Http\Controllers\LandingController;
|
use App\Http\Controllers\LandingController;
|
||||||
|
use App\Http\Controllers\PublicFileController;
|
||||||
use App\Http\Controllers\StatusController;
|
use App\Http\Controllers\StatusController;
|
||||||
use App\Http\Controllers\StripeWebhookController;
|
use App\Http\Controllers\StripeWebhookController;
|
||||||
use App\Livewire\Admin;
|
use App\Livewire\Admin;
|
||||||
|
|
@ -153,6 +154,39 @@ if ($statusHost !== '') {
|
||||||
Route::get('/status', StatusController::class)->name('status');
|
Route::get('/status', StatusController::class)->name('status');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/*
|
||||||
|
| Dateien zum Herunterladen, auf einem eigenen Hostnamen.
|
||||||
|
|
|
||||||
|
| Zwei Bereiche mit entgegengesetzten Regeln, und das ist der ganze Grund für
|
||||||
|
| die eigene Adresse:
|
||||||
|
|
|
||||||
|
| - ÖFFENTLICH: alles unter `storage/app/files/public/` — AGB,
|
||||||
|
| Auftragsverarbeitungsvertrag, TOM, Preisblätter. Anonym, indexierbar,
|
||||||
|
| dauerhaft zitierbar, weil diese Adressen in Verträgen und Rechnungen
|
||||||
|
| stehen und Jahre halten müssen.
|
||||||
|
| - PRIVAT: das Bootstrap-Archiv, nur mit gültigem Einmal-Code. Es liegt in
|
||||||
|
| keinem Verzeichnis, sondern wird aus `deploy/bootstrap` gebaut, und ohne
|
||||||
|
| Code antwortet es mit 404 statt 403 — dieselbe Regel wie bei der Konsole.
|
||||||
|
|
|
||||||
|
| Die private Route steht ZUERST, damit `bootstrap.tar.gz` nicht vom
|
||||||
|
| Platzhalter der öffentlichen Route eingesammelt wird und dort als „Datei
|
||||||
|
| nicht gefunden" endet.
|
||||||
|
|
|
||||||
|
| Ohne FILES_HOST bleibt das Archiv auf dem Portal-Hostnamen erreichbar. So
|
||||||
|
| bricht keine bereits ausgegebene Befehlszeile, solange der DNS-Eintrag für den
|
||||||
|
| neuen Namen noch nicht steht.
|
||||||
|
*/
|
||||||
|
$filesHost = (string) config('admin_access.files_host');
|
||||||
|
|
||||||
|
if ($filesHost !== '') {
|
||||||
|
Route::domain($filesHost)->group(function () {
|
||||||
|
Route::get('/bootstrap.tar.gz', BootstrapArchiveController::class)->name('bootstrap.archive');
|
||||||
|
Route::get('/{file}', PublicFileController::class)->name('files.public');
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
Route::get('/bootstrap.tar.gz', BootstrapArchiveController::class)->name('bootstrap.archive');
|
||||||
|
}
|
||||||
|
|
||||||
/*
|
/*
|
||||||
| The public website and the customer portal, each on its own hostname.
|
| The public website and the customer portal, each on its own hostname.
|
||||||
|
|
|
|
||||||
|
|
@ -191,13 +225,6 @@ $publicSite = function () {
|
||||||
// because it has already gone out in mail footers.
|
// because it has already gone out in mail footers.
|
||||||
Route::get('/sicherheit', fn () => redirect()->route('security', status: 301));
|
Route::get('/sicherheit', fn () => redirect()->route('security', status: 301));
|
||||||
|
|
||||||
// Das Bootstrap-Skript für die Host-Übernahme. Muss öffentlich sein: es
|
|
||||||
// wird aus dem Rettungssystem eines nackten Servers geholt, der noch keinen
|
|
||||||
// Tunnel hat und nichts kennt als curl. Eine statische Datei ohne
|
|
||||||
// Geheimnisse — die reisen in der Befehlszeile mit, die der Adminbereich
|
|
||||||
// zeigt.
|
|
||||||
Route::get('/bootstrap.tar.gz', BootstrapArchiveController::class)->name('bootstrap.archive');
|
|
||||||
|
|
||||||
Route::get('/robots.txt', function () {
|
Route::get('/robots.txt', function () {
|
||||||
$body = App\Support\Settings::bool('site.public', true)
|
$body = App\Support\Settings::bool('site.public', true)
|
||||||
? "User-agent: *\nAllow: /\n"
|
? "User-agent: *\nAllow: /\n"
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,14 @@
|
||||||
|
# Hier liegen die Dateien, die jeder herunterladen darf: AGB,
|
||||||
|
# Auftragsverarbeitungsvertrag, TOM, Preisblätter.
|
||||||
|
#
|
||||||
|
# Ausgeliefert unter https://<FILES_HOST>/<dateiname>.
|
||||||
|
#
|
||||||
|
# VERSIONIERTE NAMEN: `agb-2026-01.pdf`, nicht `agb.pdf`. Eine Fassung, auf die
|
||||||
|
# ein Vertrag verweist, darf sich unter derselben Adresse nie ändern — sonst
|
||||||
|
# zeigt ein Verweis vom Januar plötzlich auf Bedingungen vom Juli, und niemand
|
||||||
|
# kann mehr sagen, was vereinbart war.
|
||||||
|
#
|
||||||
|
# Der Inhalt gehört nicht ins Repository: es sind Dokumente, keine Quelltexte,
|
||||||
|
# und sie stehen auf jedem Server anders.
|
||||||
|
*
|
||||||
|
!.gitignore
|
||||||
|
|
@ -61,8 +61,8 @@ it('marks the two provider steps as things to do first', function () {
|
||||||
* und der Unterschied fiele auf einem Server auf, der schon bestellt ist.
|
* und der Unterschied fiele auf einem Server auf, der schon bestellt ist.
|
||||||
*/
|
*/
|
||||||
it('names the same archive address the command will use', function () {
|
it('names the same archive address the command will use', function () {
|
||||||
guidePage(HostCreate::class)->assertSee('app.clupilot.com/bootstrap.tar.gz');
|
guidePage(HostCreate::class)->assertSee('files.clupilot.test/bootstrap.tar.gz');
|
||||||
guidePage(Hosts::class)->assertSee('app.clupilot.com/bootstrap.tar.gz');
|
guidePage(Hosts::class)->assertSee('files.clupilot.test/bootstrap.tar.gz');
|
||||||
});
|
});
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|
|
||||||
|
|
@ -107,8 +107,12 @@ it('keeps host names out of the customer zone', function () {
|
||||||
* Anbieterkonsole kommt.
|
* Anbieterkonsole kommt.
|
||||||
*/
|
*/
|
||||||
it('fetches the installer over the public hostname', function () {
|
it('fetches the installer over the public hostname', function () {
|
||||||
|
// Vom Datei-Hostnamen, seit es einen gibt — und in keinem Fall vom
|
||||||
|
// Konsolen-Hostnamen: die Konsole ist per Client-IP abgeriegelt
|
||||||
|
// (RestrictConsoleNetwork), und ein frischer Server im Rettungssystem steht
|
||||||
|
// in keinem der freigegebenen Bereiche.
|
||||||
expect(createHostAs()->get('command'))
|
expect(createHostAs()->get('command'))
|
||||||
->toContain('https://app.clupilot.com/bootstrap.tar.gz')
|
->toContain('https://files.clupilot.test/bootstrap.tar.gz')
|
||||||
->not->toContain('admin.');
|
->not->toContain('admin.');
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -5,8 +5,18 @@
|
||||||
* kommt. Was hier schiefgeht, merkt man erst im Rettungssystem eines Servers,
|
* kommt. Was hier schiefgeht, merkt man erst im Rettungssystem eines Servers,
|
||||||
* der schon bestellt ist.
|
* der schon bestellt ist.
|
||||||
*/
|
*/
|
||||||
|
beforeEach(function () {
|
||||||
|
fakeServices();
|
||||||
|
$this->code = App\Support\HostEnrolment::issue(App\Models\Host::factory()->create());
|
||||||
|
});
|
||||||
|
|
||||||
|
function fetchArchive(): \Illuminate\Testing\TestResponse
|
||||||
|
{
|
||||||
|
return test()->get('http://files.clupilot.test/bootstrap.tar.gz?code='.test()->code);
|
||||||
|
}
|
||||||
|
|
||||||
it('serves the bootstrap script as an archive', function () {
|
it('serves the bootstrap script as an archive', function () {
|
||||||
$response = $this->get('/bootstrap.tar.gz');
|
$response = fetchArchive();
|
||||||
|
|
||||||
$response->assertOk();
|
$response->assertOk();
|
||||||
expect($response->headers->get('Content-Type'))->toBe('application/gzip');
|
expect($response->headers->get('Content-Type'))->toBe('application/gzip');
|
||||||
|
|
@ -20,7 +30,7 @@ it('serves the bootstrap script as an archive', function () {
|
||||||
* nur über die Anbieterkonsole kommt.
|
* nur über die Anbieterkonsole kommt.
|
||||||
*/
|
*/
|
||||||
it('unpacks to bootstrap/ with the script and its library', function () {
|
it('unpacks to bootstrap/ with the script and its library', function () {
|
||||||
$this->get('/bootstrap.tar.gz')->assertOk();
|
fetchArchive()->assertOk();
|
||||||
|
|
||||||
$listing = shell_exec('tar tzf '.escapeshellarg(storage_path('app/bootstrap.tar.gz')));
|
$listing = shell_exec('tar tzf '.escapeshellarg(storage_path('app/bootstrap.tar.gz')));
|
||||||
|
|
||||||
|
|
@ -40,7 +50,7 @@ it('unpacks to bootstrap/ with the script and its library', function () {
|
||||||
* Befehlszeile mit, die der Adminbereich genau einmal zeigt.
|
* Befehlszeile mit, die der Adminbereich genau einmal zeigt.
|
||||||
*/
|
*/
|
||||||
it('carries no secrets', function () {
|
it('carries no secrets', function () {
|
||||||
$this->get('/bootstrap.tar.gz')->assertOk();
|
fetchArchive()->assertOk();
|
||||||
|
|
||||||
$dir = sys_get_temp_dir().'/bootstrap-pruefung-'.bin2hex(random_bytes(4));
|
$dir = sys_get_temp_dir().'/bootstrap-pruefung-'.bin2hex(random_bytes(4));
|
||||||
mkdir($dir);
|
mkdir($dir);
|
||||||
|
|
@ -65,14 +75,14 @@ it('carries no secrets', function () {
|
||||||
* von dem Tag, an dem jemand zuletzt daran gedacht hat.
|
* von dem Tag, an dem jemand zuletzt daran gedacht hat.
|
||||||
*/
|
*/
|
||||||
it('rebuilds itself when the script changes', function () {
|
it('rebuilds itself when the script changes', function () {
|
||||||
$this->get('/bootstrap.tar.gz')->assertOk();
|
fetchArchive()->assertOk();
|
||||||
$first = filemtime(storage_path('app/bootstrap.tar.gz'));
|
$first = filemtime(storage_path('app/bootstrap.tar.gz'));
|
||||||
|
|
||||||
// Eine Sekunde zurück, damit die Änderung sichtbar später liegt.
|
// Eine Sekunde zurück, damit die Änderung sichtbar später liegt.
|
||||||
touch(storage_path('app/bootstrap.tar.gz'), $first - 10);
|
touch(storage_path('app/bootstrap.tar.gz'), $first - 10);
|
||||||
touch(base_path('deploy/bootstrap/clupilot-bootstrap.sh'));
|
touch(base_path('deploy/bootstrap/clupilot-bootstrap.sh'));
|
||||||
|
|
||||||
$this->get('/bootstrap.tar.gz')->assertOk();
|
fetchArchive()->assertOk();
|
||||||
|
|
||||||
expect(filemtime(storage_path('app/bootstrap.tar.gz')))->toBeGreaterThan($first - 10);
|
expect(filemtime(storage_path('app/bootstrap.tar.gz')))->toBeGreaterThan($first - 10);
|
||||||
});
|
});
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,120 @@
|
||||||
|
<?php
|
||||||
|
|
||||||
|
use App\Models\Host;
|
||||||
|
use App\Support\HostEnrolment;
|
||||||
|
use App\Support\HostTakeoverCommand;
|
||||||
|
|
||||||
|
beforeEach(function () {
|
||||||
|
fakeServices();
|
||||||
|
// Hostname kommt aus phpunit.xml — Routen werden beim Booten gebunden.
|
||||||
|
|
||||||
|
$this->publicDir = storage_path('app/files/public');
|
||||||
|
if (! is_dir($this->publicDir)) {
|
||||||
|
mkdir($this->publicDir, 0755, true);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
function issuedCode(): string
|
||||||
|
{
|
||||||
|
return HostEnrolment::issue(Host::factory()->create());
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Der private Teil: ohne Ausweis gibt es das Archiv nicht — und zwar als 404,
|
||||||
|
* nicht als 403. Dieselbe Regel wie bei der Konsole: ein Fremder soll nicht
|
||||||
|
* erfahren, dass es hier etwas gibt.
|
||||||
|
*/
|
||||||
|
it('refuses the installer without a code', function () {
|
||||||
|
$this->get('http://files.clupilot.test/bootstrap.tar.gz')->assertNotFound();
|
||||||
|
$this->get('http://files.clupilot.test/bootstrap.tar.gz?code=erfunden')->assertNotFound();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('serves the installer to a valid code', function () {
|
||||||
|
$response = $this->get('http://files.clupilot.test/bootstrap.tar.gz?code='.issuedCode());
|
||||||
|
|
||||||
|
$response->assertOk();
|
||||||
|
expect($response->headers->get('Content-Type'))->toBe('application/gzip')
|
||||||
|
->and($response->headers->get('X-Robots-Tag'))->toContain('noindex');
|
||||||
|
});
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Der Code wird beim Herunterladen NICHT verbraucht. Er wird danach für jede
|
||||||
|
* Fortschrittsmeldung und zuletzt für die Registrierung gebraucht — ein
|
||||||
|
* Download, der ihn aufbraucht, macht die Übernahme unmöglich, und zwar
|
||||||
|
* nachdem der Server schon läuft.
|
||||||
|
*/
|
||||||
|
it('does not spend the code on the download', function () {
|
||||||
|
$code = issuedCode();
|
||||||
|
|
||||||
|
$this->get('http://files.clupilot.test/bootstrap.tar.gz?code='.$code)->assertOk();
|
||||||
|
$this->get('http://files.clupilot.test/bootstrap.tar.gz?code='.$code)->assertOk();
|
||||||
|
|
||||||
|
expect(HostEnrolment::claim($code))->not->toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Der öffentliche Teil: AGB, AV, TOM. Anonym, ohne Ausweis, ohne Konto.
|
||||||
|
*/
|
||||||
|
it('serves public documents to anyone', function () {
|
||||||
|
file_put_contents($this->publicDir.'/agb-2026-01.pdf', '%PDF-1.4 Testfassung');
|
||||||
|
|
||||||
|
$response = $this->get('http://files.clupilot.test/agb-2026-01.pdf');
|
||||||
|
|
||||||
|
$response->assertOk();
|
||||||
|
|
||||||
|
// `response()->file()` streamt, der Rumpf steht also nicht im Ergebnis.
|
||||||
|
// Geprüft wird deshalb die ausgelieferte Datei selbst — und dass sie im
|
||||||
|
// Browser ANGEZEIGT wird: eine AGB, die heruntergeladen wird, wird
|
||||||
|
// weggeklickt.
|
||||||
|
expect($response->baseResponse->getFile()->getPathname())
|
||||||
|
->toBe($this->publicDir.'/agb-2026-01.pdf')
|
||||||
|
->and($response->headers->get('Content-Disposition'))->toStartWith('inline');
|
||||||
|
|
||||||
|
unlink($this->publicDir.'/agb-2026-01.pdf');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('does not invent documents that are not there', function () {
|
||||||
|
$this->get('http://files.clupilot.test/gibt-es-nicht.pdf')->assertNotFound();
|
||||||
|
});
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Der teuerste denkbare Fehlgriff an dieser Stelle: ein Pfad, der aus dem
|
||||||
|
* Verzeichnis herausführt. `.env` liegt zwei Ebenen darüber.
|
||||||
|
*/
|
||||||
|
it('cannot be walked out of its directory', function () {
|
||||||
|
foreach (['..%2F..%2F..%2F.env', '....//....//.env', '%2e%2e%2f.env'] as $attempt) {
|
||||||
|
$this->get('http://files.clupilot.test/'.$attempt)->assertNotFound();
|
||||||
|
}
|
||||||
|
|
||||||
|
// Und eine Punktdatei, die tatsächlich dort läge, ebenfalls nicht.
|
||||||
|
file_put_contents($this->publicDir.'/.geheim', 'nicht ausliefern');
|
||||||
|
$this->get('http://files.clupilot.test/.geheim')->assertNotFound();
|
||||||
|
unlink($this->publicDir.'/.geheim');
|
||||||
|
});
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Die kopierte Zeile holt von diesem Hostnamen und legt den Code bei.
|
||||||
|
*/
|
||||||
|
it('points the command line at the files host, with the code', function () {
|
||||||
|
$host = Host::factory()->create(['dns_name' => 'fsn-01']);
|
||||||
|
$enrolment = HostEnrolment::issueWithKeys($host);
|
||||||
|
|
||||||
|
$command = HostTakeoverCommand::for($host, $enrolment);
|
||||||
|
|
||||||
|
expect($command)
|
||||||
|
->toContain('https://files.clupilot.test/bootstrap.tar.gz?code='.$enrolment['code'])
|
||||||
|
->toContain('--code '.$enrolment['code']);
|
||||||
|
});
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Ohne FILES_HOST bleibt alles, wo es war. Sonst bräche eine schon ausgegebene
|
||||||
|
* Befehlszeile in dem Moment, in dem jemand die Einstellung setzt, aber der
|
||||||
|
* DNS-Eintrag noch nicht steht.
|
||||||
|
*/
|
||||||
|
it('falls back to the portal host until the files host exists', function () {
|
||||||
|
config()->set('admin_access.files_host', '');
|
||||||
|
config()->set('admin_access.app_host', 'app.clupilot.com');
|
||||||
|
|
||||||
|
expect(HostTakeoverCommand::archiveUrl())
|
||||||
|
->toBe('https://app.clupilot.com/bootstrap.tar.gz');
|
||||||
|
});
|
||||||
Loading…
Reference in New Issue