diff --git a/.env.example b/.env.example
index b7dd576..e7d6dab 100644
--- a/.env.example
+++ b/.env.example
@@ -214,6 +214,11 @@ CLUPILOT_TAX_PERCENT=20
# www.clupilot.com/dashboard ist dann ein 404, kein funktionierender Aufruf.
# Leer = Portal antwortet ueberall (Vorgabe, und was jede dev-Maschine ueber
# 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=
# SITE_HOST: Hostname der oeffentlichen Website. Gesetzt, antwortet die
diff --git a/app/Http/Controllers/BootstrapArchiveController.php b/app/Http/Controllers/BootstrapArchiveController.php
index df0686f..89dfe6a 100644
--- a/app/Http/Controllers/BootstrapArchiveController.php
+++ b/app/Http/Controllers/BootstrapArchiveController.php
@@ -2,6 +2,7 @@
namespace App\Http\Controllers;
+use App\Support\HostEnrolment;
use Illuminate\Http\Request;
use Symfony\Component\HttpFoundation\BinaryFileResponse;
use Symfony\Component\Process\Process;
@@ -28,6 +29,24 @@ 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)) {
@@ -38,6 +57,10 @@ class BootstrapArchiveController extends Controller
->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',
]);
}
diff --git a/app/Http/Controllers/PublicFileController.php b/app/Http/Controllers/PublicFileController.php
new file mode 100644
index 0000000..773b1b8
--- /dev/null
+++ b/app/Http/Controllers/PublicFileController.php
@@ -0,0 +1,68 @@
+file($path, [
+ 'Content-Disposition' => 'inline; filename="'.$name.'"',
+ ]);
+ }
+}
diff --git a/app/Support/HostTakeoverCommand.php b/app/Support/HostTakeoverCommand.php
index 153661b..5ffb6d1 100644
--- a/app/Support/HostTakeoverCommand.php
+++ b/app/Support/HostTakeoverCommand.php
@@ -29,7 +29,10 @@ final class HostTakeoverCommand
{
return implode(' ', [
'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,
'&& sh '.self::INSTALL_DIR.'/bootstrap/clupilot-bootstrap.sh',
'--code '.$enrolment['code'],
@@ -54,9 +57,23 @@ final class HostTakeoverCommand
* Konsolen-Hostnamen, und die Zeile liefe in eine 404 auf einem Server, an
* 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
{
- $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
// dort „das Portal antwortet auf jedem Hostnamen", und das ist die
diff --git a/config/admin_access.php b/config/admin_access.php
index 15b8d52..e40caa5 100644
--- a/config/admin_access.php
+++ b/config/admin_access.php
@@ -61,6 +61,31 @@ return [
*/
'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.
|
diff --git a/phpunit.xml b/phpunit.xml
index 813c785..21f30aa 100644
--- a/phpunit.xml
+++ b/phpunit.xml
@@ -56,6 +56,11 @@
nothing about the code. The value is the one the code itself
defaults to. -->
+
+
+
diff --git a/routes/web.php b/routes/web.php
index 21c24b6..0055745 100644
--- a/routes/web.php
+++ b/routes/web.php
@@ -3,6 +3,7 @@
use App\Http\Controllers\BootstrapArchiveController;
use App\Http\Controllers\ImpersonationController;
use App\Http\Controllers\LandingController;
+use App\Http\Controllers\PublicFileController;
use App\Http\Controllers\StatusController;
use App\Http\Controllers\StripeWebhookController;
use App\Livewire\Admin;
@@ -153,6 +154,39 @@ if ($statusHost !== '') {
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.
|
@@ -191,13 +225,6 @@ $publicSite = function () {
// because it has already gone out in mail footers.
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 () {
$body = App\Support\Settings::bool('site.public', true)
? "User-agent: *\nAllow: /\n"
diff --git a/storage/app/files/public/.gitignore b/storage/app/files/public/.gitignore
new file mode 100644
index 0000000..c6cf8a0
--- /dev/null
+++ b/storage/app/files/public/.gitignore
@@ -0,0 +1,14 @@
+# Hier liegen die Dateien, die jeder herunterladen darf: AGB,
+# Auftragsverarbeitungsvertrag, TOM, Preisblätter.
+#
+# Ausgeliefert unter https:///.
+#
+# 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
diff --git a/tests/Feature/Admin/HostTakeoverGuideTest.php b/tests/Feature/Admin/HostTakeoverGuideTest.php
index 3524cca..9f42ca8 100644
--- a/tests/Feature/Admin/HostTakeoverGuideTest.php
+++ b/tests/Feature/Admin/HostTakeoverGuideTest.php
@@ -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.
*/
it('names the same archive address the command will use', function () {
- guidePage(HostCreate::class)->assertSee('app.clupilot.com/bootstrap.tar.gz');
- guidePage(Hosts::class)->assertSee('app.clupilot.com/bootstrap.tar.gz');
+ guidePage(HostCreate::class)->assertSee('files.clupilot.test/bootstrap.tar.gz');
+ guidePage(Hosts::class)->assertSee('files.clupilot.test/bootstrap.tar.gz');
});
/**
diff --git a/tests/Feature/Admin/HostTakeoverPageTest.php b/tests/Feature/Admin/HostTakeoverPageTest.php
index 74ab282..add71cc 100644
--- a/tests/Feature/Admin/HostTakeoverPageTest.php
+++ b/tests/Feature/Admin/HostTakeoverPageTest.php
@@ -107,8 +107,12 @@ it('keeps host names out of the customer zone', function () {
* Anbieterkonsole kommt.
*/
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'))
- ->toContain('https://app.clupilot.com/bootstrap.tar.gz')
+ ->toContain('https://files.clupilot.test/bootstrap.tar.gz')
->not->toContain('admin.');
});
diff --git a/tests/Feature/Host/BootstrapArchiveTest.php b/tests/Feature/Host/BootstrapArchiveTest.php
index 5312245..35e9e91 100644
--- a/tests/Feature/Host/BootstrapArchiveTest.php
+++ b/tests/Feature/Host/BootstrapArchiveTest.php
@@ -5,8 +5,18 @@
* kommt. Was hier schiefgeht, merkt man erst im Rettungssystem eines Servers,
* 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 () {
- $response = $this->get('/bootstrap.tar.gz');
+ $response = fetchArchive();
$response->assertOk();
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.
*/
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')));
@@ -40,7 +50,7 @@ it('unpacks to bootstrap/ with the script and its library', function () {
* Befehlszeile mit, die der Adminbereich genau einmal zeigt.
*/
it('carries no secrets', function () {
- $this->get('/bootstrap.tar.gz')->assertOk();
+ fetchArchive()->assertOk();
$dir = sys_get_temp_dir().'/bootstrap-pruefung-'.bin2hex(random_bytes(4));
mkdir($dir);
@@ -65,14 +75,14 @@ it('carries no secrets', function () {
* von dem Tag, an dem jemand zuletzt daran gedacht hat.
*/
it('rebuilds itself when the script changes', function () {
- $this->get('/bootstrap.tar.gz')->assertOk();
+ fetchArchive()->assertOk();
$first = filemtime(storage_path('app/bootstrap.tar.gz'));
// Eine Sekunde zurück, damit die Änderung sichtbar später liegt.
touch(storage_path('app/bootstrap.tar.gz'), $first - 10);
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);
});
diff --git a/tests/Feature/Host/FilesHostTest.php b/tests/Feature/Host/FilesHostTest.php
new file mode 100644
index 0000000..818dbd8
--- /dev/null
+++ b/tests/Feature/Host/FilesHostTest.php
@@ -0,0 +1,120 @@
+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');
+});