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'); +});