Give the operator one line to copy and three steps around it
The console could describe a takeover it had no way to start. This is the vertical slice that closes that: a one-time code, the archive the rescue system fetches, and the page that says what to do with both. The command carries EVERYTHING the script needs before the tunnel exists, because there is nothing to fetch — that is the whole point of spec §5. Which means CluPilot generates the WireGuard keypair and admits the peer at the hub before the machine has ever booted, and hands the private half over in the line. It is worthless within minutes: task 9 of the script replaces it with one generated on the machine. Shown exactly once. The database holds only the code's hash and never the private key, so leaving the page does not bring it back — it mints a new code, which invalidates the old one. That is deliberate: a glance at somebody's screen should be worth nothing an hour later. Which is also why save() no longer redirects. Sending the operator to the host detail page sends them away from the only value they need, and an existing test asserted that redirect — it now asserts the opposite, with the reason written next to it. SHA-256 rather than bcrypt for the code, and the reason is not speed. Both endpoints have to FIND the host by the code; with bcrypt that means trying every row. The code is 32 characters of CSPRNG output, so it has the entropy that stretching exists to manufacture. resolve() and claim() are separate because progress reports arrive BEFORE registration. If reporting consumed the code, a host could never register after its first message. The archive URL is always the public hostname. The console runs under admin.…, but this line executes on a machine that must not reach the admin area — it is locked down for exactly that reason — so route() from the console would emit a hostname that 404s on a server only reachable through the provider's console. The page warns about missing tunnel settings BEFORE the host is created, not after. An empty hub key produces a line that looks clean, copies fine, runs, and ends in a tunnel that never handshakes — discovered on the machine, after somebody has already paid for it. The three steps lead with the rescue system, because that is the one nobody knows by heart, and it says enabling is not the same as booting into it — the script refuses a running production machine, which is what a half-done switch looks like from the inside. 1986 tests pass. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>feature/host-bootstrap
parent
668ed67de4
commit
903ebdd2b2
|
|
@ -0,0 +1,110 @@
|
|||
<?php
|
||||
|
||||
namespace App\Http\Controllers;
|
||||
|
||||
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
|
||||
{
|
||||
$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"',
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* 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();
|
||||
}
|
||||
}
|
||||
|
|
@ -3,6 +3,8 @@
|
|||
namespace App\Livewire\Admin;
|
||||
|
||||
use App\Actions\StartHostOnboarding;
|
||||
use App\Support\HostEnrolment;
|
||||
use App\Support\HostTakeoverCommand;
|
||||
use Livewire\Attributes\Layout;
|
||||
use Livewire\Attributes\Validate;
|
||||
use Livewire\Component;
|
||||
|
|
@ -10,6 +12,21 @@ use Livewire\Component;
|
|||
#[Layout('layouts.admin')]
|
||||
class HostCreate extends Component
|
||||
{
|
||||
/**
|
||||
* Die fertige Befehlszeile — gesetzt, sobald der Host angelegt ist.
|
||||
*
|
||||
* Sie wird GENAU EINMAL gezeigt. In der Datenbank steht nur der Hash des
|
||||
* Codes, und der private Schlüssel steht dort überhaupt nicht; wer die Seite
|
||||
* neu lädt, bekommt sie nicht wieder, sondern legt einen neuen Code an. Das
|
||||
* ist kein Versehen, sondern der Grund, warum ein abgefangener Blick auf
|
||||
* einen Bildschirm später nichts mehr wert ist.
|
||||
*/
|
||||
public ?string $command = null;
|
||||
|
||||
public ?string $createdUuid = null;
|
||||
|
||||
public ?string $createdName = null;
|
||||
|
||||
#[Validate('required|string|max:255')]
|
||||
public string $name = '';
|
||||
|
||||
|
|
@ -34,13 +51,20 @@ class HostCreate extends Component
|
|||
|
||||
$host = $action->run($data);
|
||||
|
||||
return $this->redirectRoute('admin.hosts.show', ['host' => $host->uuid], navigate: true);
|
||||
// Kein Weiterleiten mehr. Die Befehlszeile gibt es nur hier und nur
|
||||
// jetzt — wer den Betreiber auf eine andere Seite schickt, schickt ihn
|
||||
// von dem einzigen Wert weg, den er braucht.
|
||||
$this->command = HostTakeoverCommand::for($host, HostEnrolment::issueWithKeys($host));
|
||||
$this->createdUuid = $host->uuid;
|
||||
$this->createdName = $host->name;
|
||||
}
|
||||
|
||||
public function render()
|
||||
{
|
||||
return view('livewire.admin.host-create', [
|
||||
'datacenters' => \App\Models\Datacenter::query()->active()->orderBy('name')->get(),
|
||||
'archiveUrl' => HostTakeoverCommand::archiveUrl(),
|
||||
'missingSettings' => HostTakeoverCommand::missingSettings(),
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -0,0 +1,139 @@
|
|||
<?php
|
||||
|
||||
namespace App\Support;
|
||||
|
||||
use App\Models\Host;
|
||||
use App\Services\Wireguard\Keypair;
|
||||
use App\Services\Wireguard\WireguardHub;
|
||||
use Illuminate\Support\Str;
|
||||
|
||||
/**
|
||||
* Der Einmal-Code und das Schlüsselpaar, mit denen ein Host sich übernehmen
|
||||
* lässt.
|
||||
*
|
||||
* Das Henne-Ei-Problem und seine Auflösung stehen in §5 der Spec: damit der Hub
|
||||
* den Host hereinlässt, muss er dessen öffentlichen Schlüssel kennen — erzeugte
|
||||
* der Host ihn selbst, müsste er ihn melden, BEVOR der Tunnel steht, und genau
|
||||
* dafür wäre ein öffentlicher Endpunkt nötig. Also erzeugt CluPilot das Paar
|
||||
* hier, nimmt den Peer sofort auf und gibt den privaten Teil in der kopierten
|
||||
* Befehlszeile mit.
|
||||
*
|
||||
* Der private Schlüssel wird NICHT gespeichert. Er steht in der Zwischenablage
|
||||
* des Betreibers und sonst nirgends, und Task 9 des Bootstrap-Skripts tauscht
|
||||
* ihn ohnehin gegen einen frisch auf der Maschine erzeugten — danach ist er
|
||||
* wertlos. Ihn aufzubewahren hieße, ein Geheimnis mit einer Lebensdauer von
|
||||
* Minuten dauerhaft zu lagern.
|
||||
*/
|
||||
final class HostEnrolment
|
||||
{
|
||||
/**
|
||||
* Großzügig, weil der Code nichts nach außen öffnet: er ist nur aus dem
|
||||
* WireGuard-Subnetz überhaupt verwendbar (Spec §5). Er begrenzt, wie lange
|
||||
* ein Betreiber zwischen „Host anlegen" und „Rettungssystem starten" Zeit
|
||||
* hat, und einen Server zu bestellen dauert manchmal einen Nachmittag.
|
||||
*/
|
||||
private const LIFETIME_HOURS = 24;
|
||||
|
||||
/**
|
||||
* SHA-256 statt bcrypt, mit Absicht.
|
||||
*
|
||||
* Der Code sind 32 Zeichen aus einem kryptografischen Zufallsgenerator —
|
||||
* er hat die Entropie, die ein Passwort erst durch Streckung bekommt, und
|
||||
* ist gegen Erraten nicht zu verstärken. Entscheidend ist die andere Seite:
|
||||
* beide Endpunkte müssen den Host ÜBER den Code finden. Mit bcrypt ginge das
|
||||
* nur, indem man jede Zeile der Tabelle durchprobiert; mit einem Hash ist es
|
||||
* ein indizierter Zugriff.
|
||||
*/
|
||||
private static function hash(string $code): string
|
||||
{
|
||||
return hash('sha256', $code);
|
||||
}
|
||||
|
||||
/** Legt Code, Schlüsselpaar und Peer an und gibt den Klartext-Code zurück. */
|
||||
public static function issue(Host $host): string
|
||||
{
|
||||
return self::issueWithKeys($host)['code'];
|
||||
}
|
||||
|
||||
/**
|
||||
* Wie `issue()`, gibt aber auch den privaten Schlüssel zurück — den braucht
|
||||
* die Befehlszeile, und er ist danach nirgends mehr zu holen.
|
||||
*
|
||||
* @return array{code: string, private_key: string, public_key: string, wg_ip: string}
|
||||
*/
|
||||
public static function issueWithKeys(Host $host): array
|
||||
{
|
||||
$hub = app(WireguardHub::class);
|
||||
|
||||
$keypair = Keypair::generate();
|
||||
|
||||
// Die Tunneladresse bleibt, wenn es schon eine gibt. Ein zweiter
|
||||
// Übernahmeversuch soll denselben Host an derselben Adresse ergeben —
|
||||
// die Spalte ist eindeutig indiziert, und eine neue Adresse hieße, die
|
||||
// alte für immer belegt zu lassen.
|
||||
$ip = $host->wg_ip ?: $hub->allocateIp();
|
||||
|
||||
// Der Reihenfolge nach: erst den neuen Peer aufnehmen, dann den alten
|
||||
// entfernen. Dieselbe Regel wie beim Schlüsseltausch in §6 — dazwischen
|
||||
// darf es keinen Moment geben, in dem gar kein Peer eingetragen ist.
|
||||
$previous = $host->wg_pubkey;
|
||||
$hub->addPeer($keypair->publicKey, $ip);
|
||||
if (filled($previous) && $previous !== $keypair->publicKey) {
|
||||
$hub->removePeer($previous);
|
||||
}
|
||||
|
||||
$code = Str::random(32);
|
||||
|
||||
$host->forceFill([
|
||||
'wg_ip' => $ip,
|
||||
'wg_pubkey' => $keypair->publicKey,
|
||||
'enrolment_code_hash' => self::hash($code),
|
||||
'enrolment_expires_at' => now()->addHours(self::LIFETIME_HOURS),
|
||||
// Ein neuer Code macht den alten wertlos. Sonst hätte eine Maschine,
|
||||
// die im Rettungssystem hängengeblieben ist, weiter einen gültigen
|
||||
// Ausweis für einen Host, den der Betreiber gerade neu übernimmt.
|
||||
'enrolment_used_at' => null,
|
||||
])->save();
|
||||
|
||||
return [
|
||||
'code' => $code,
|
||||
'private_key' => $keypair->privateKey,
|
||||
'public_key' => $keypair->publicKey,
|
||||
'wg_ip' => $ip,
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* Löst einen gültigen, unverbrauchten Code auf, OHNE ihn zu verbrauchen.
|
||||
*
|
||||
* Das ist der Weg für `/host/progress`: die Fortschrittsmeldungen kommen vor
|
||||
* der Registrierung, und würden sie den Code verbrauchen, könnte der Host
|
||||
* sich nach seiner ersten Meldung nie mehr registrieren.
|
||||
*/
|
||||
public static function resolve(string $code): ?Host
|
||||
{
|
||||
if ($code === '') {
|
||||
return null;
|
||||
}
|
||||
|
||||
return Host::query()
|
||||
->where('enrolment_code_hash', self::hash($code))
|
||||
->whereNull('enrolment_used_at')
|
||||
->where('enrolment_expires_at', '>', now())
|
||||
->first();
|
||||
}
|
||||
|
||||
/** Löst einen Code auf und verbraucht ihn. Der Weg für `/host/register`. */
|
||||
public static function claim(string $code): ?Host
|
||||
{
|
||||
$host = self::resolve($code);
|
||||
|
||||
if ($host === null) {
|
||||
return null;
|
||||
}
|
||||
|
||||
$host->forceFill(['enrolment_used_at' => now()])->save();
|
||||
|
||||
return $host;
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,103 @@
|
|||
<?php
|
||||
|
||||
namespace App\Support;
|
||||
|
||||
use App\Models\Host;
|
||||
|
||||
/**
|
||||
* Die eine Zeile, die der Betreiber im Rettungssystem einfügt.
|
||||
*
|
||||
* An einer Stelle gebaut, weil sie an zwei Stellen gezeigt wird (beim Anlegen
|
||||
* und beim Neuausstellen eines Codes) und weil jeder ihrer sieben Werte aus
|
||||
* einer anderen Ecke kommt. Zwei Fassungen davon würden auseinanderlaufen, und
|
||||
* der Unterschied fiele erst auf einem Server auf, der schon bestellt ist.
|
||||
*
|
||||
* Sie trägt ALLES, was das Skript vor dem Tunnel braucht (Spec §5): es holt
|
||||
* nichts ab, es gibt nichts abzurufen. Deshalb steht der private
|
||||
* WireGuard-Schlüssel darin — mit einer Lebensdauer von Minuten, denn Task 9
|
||||
* des Skripts tauscht ihn gegen einen auf der Maschine erzeugten.
|
||||
*/
|
||||
final class HostTakeoverCommand
|
||||
{
|
||||
/** Wohin das Archiv ausgepackt wird. Das Skript sucht sich dort selbst. */
|
||||
public const INSTALL_DIR = '/opt/clupilot';
|
||||
|
||||
/**
|
||||
* @param array{code: string, private_key: string, public_key: string, wg_ip: string} $enrolment
|
||||
*/
|
||||
public static function for(Host $host, array $enrolment): string
|
||||
{
|
||||
return implode(' ', [
|
||||
'mkdir -p '.self::INSTALL_DIR,
|
||||
'&& curl -fsSL '.self::archiveUrl(),
|
||||
'| tar xz -C '.self::INSTALL_DIR,
|
||||
'&& sh '.self::INSTALL_DIR.'/bootstrap/clupilot-bootstrap.sh',
|
||||
'--code '.$enrolment['code'],
|
||||
// Einfache Anführungszeichen: WireGuard-Schlüssel sind base64 und
|
||||
// enthalten +, / und =. Ohne sie zerlegt die Shell die Zeile an
|
||||
// einer Stelle, die der Betreiber nicht sieht.
|
||||
"--wg-private '".$enrolment['private_key']."'",
|
||||
'--wg-ip '.$enrolment['wg_ip'].'/'.self::subnetPrefix(),
|
||||
"--hub-pubkey '".config('provisioning.wireguard.hub_public_key')."'",
|
||||
'--hub-endpoint '.config('provisioning.wireguard.endpoint'),
|
||||
'--api http://'.config('provisioning.wireguard.hub_address'),
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Immer über den ÖFFENTLICHEN Hostnamen.
|
||||
*
|
||||
* Die Konsole läuft unter `admin.…`, aber diese Zeile wird auf einer
|
||||
* Maschine ausgeführt, die den Adminbereich nicht erreichen darf und soll —
|
||||
* er ist genau dafür abgeriegelt. `route()` von der Konsole aus lieferte den
|
||||
* Konsolen-Hostnamen, und die Zeile liefe in eine 404 auf einem Server, an
|
||||
* den man dann nur noch über die Anbieterkonsole kommt.
|
||||
*/
|
||||
public static function archiveUrl(): string
|
||||
{
|
||||
$appHost = (string) config('admin_access.app_host');
|
||||
|
||||
if ($appHost === '') {
|
||||
return url('/bootstrap.tar.gz');
|
||||
}
|
||||
|
||||
return 'https://'.$appHost.'/bootstrap.tar.gz';
|
||||
}
|
||||
|
||||
/** Die Präfixlänge des Tunnel-Subnetzes, damit `--wg-ip` vollständig ist. */
|
||||
private static function subnetPrefix(): string
|
||||
{
|
||||
$subnet = (string) config('provisioning.wireguard.subnet', '10.66.0.0/24');
|
||||
|
||||
return str_contains($subnet, '/') ? explode('/', $subnet)[1] : '24';
|
||||
}
|
||||
|
||||
/**
|
||||
* Was in der Zeile fehlt, damit sie überhaupt funktionieren kann.
|
||||
*
|
||||
* Ein leerer Hub-Schlüssel oder ein leerer Endpunkt ergibt eine Zeile, die
|
||||
* sauber aussieht, kopiert wird, läuft — und in einem Tunnel endet, der nie
|
||||
* einen Handshake hat. Das fiele erst auf der Maschine auf, nach der
|
||||
* Installation. Lieber hier sagen, was fehlt.
|
||||
*
|
||||
* @return list<string>
|
||||
*/
|
||||
public static function missingSettings(): array
|
||||
{
|
||||
$missing = [];
|
||||
|
||||
if (blank(config('provisioning.wireguard.hub_public_key'))) {
|
||||
$missing[] = 'CLUPILOT_WG_HUB_PUBKEY';
|
||||
}
|
||||
|
||||
if (blank(config('provisioning.wireguard.endpoint'))) {
|
||||
$missing[] = 'CLUPILOT_WG_ENDPOINT';
|
||||
}
|
||||
|
||||
if (blank(config('provisioning.wireguard.hub_address'))) {
|
||||
$missing[] = 'CLUPILOT_WG_HUB_ADDRESS';
|
||||
}
|
||||
|
||||
return $missing;
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,36 @@
|
|||
<?php
|
||||
|
||||
use Illuminate\Database\Migrations\Migration;
|
||||
use Illuminate\Database\Schema\Blueprint;
|
||||
use Illuminate\Support\Facades\Schema;
|
||||
|
||||
/**
|
||||
* Der Einmal-Code, mit dem ein Host sich zurückmeldet.
|
||||
*
|
||||
* Nur der Hash liegt hier. Der Klartext wird beim Anlegen genau einmal gezeigt,
|
||||
* steht danach in der Zwischenablage des Betreibers und in keiner Datenbank —
|
||||
* er ist ein Ausweis für den Rückweg, kein Schlüssel zu einer Auskunft (Spec §5).
|
||||
*
|
||||
* Der Hash ist indiziert, weil beide Endpunkte (`/host/progress` und
|
||||
* `/host/register`) den Host darüber auflösen und das der einzige Weg dorthin
|
||||
* ist.
|
||||
*/
|
||||
return new class extends Migration
|
||||
{
|
||||
public function up(): void
|
||||
{
|
||||
Schema::table('hosts', function (Blueprint $table) {
|
||||
$table->string('enrolment_code_hash', 64)->nullable()->index();
|
||||
$table->timestamp('enrolment_expires_at')->nullable();
|
||||
$table->timestamp('enrolment_used_at')->nullable();
|
||||
});
|
||||
}
|
||||
|
||||
public function down(): void
|
||||
{
|
||||
Schema::table('hosts', function (Blueprint $table) {
|
||||
$table->dropIndex(['enrolment_code_hash']);
|
||||
$table->dropColumn(['enrolment_code_hash', 'enrolment_expires_at', 'enrolment_used_at']);
|
||||
});
|
||||
}
|
||||
};
|
||||
|
|
@ -134,4 +134,33 @@ return [
|
|||
'secure_host_firewall' => 'Host-Firewall absichern',
|
||||
'complete_host_onboarding' => 'Onboarding abschließen',
|
||||
],
|
||||
'takeover' => [
|
||||
'before_title' => 'Vorher: Rettungssystem starten',
|
||||
'before_body' => 'Bestelle den Server beim Anbieter und starte sein Rettungssystem, bevor du hier anlegst. Direkt nach dem Anlegen zeigt diese Seite eine Befehlszeile, die im Rettungssystem eingefügt wird — sie wird genau einmal gezeigt.',
|
||||
|
||||
'missing_title' => 'Der Tunnel ist noch nicht eingerichtet',
|
||||
'missing_body' => 'Es fehlt: :settings. Ohne diese Werte entsteht eine Befehlszeile, die sauber aussieht, läuft — und in einem Tunnel endet, der nie einen Handshake hat. Das fällt erst auf der Maschine auf. Trage sie unter Einstellungen ein, bevor du einen Host anlegst.',
|
||||
|
||||
'title' => ':name ist angelegt',
|
||||
'subtitle' => 'Drei Schritte, danach macht der Server den Rest allein.',
|
||||
|
||||
'once_title' => 'Diese Zeile gibt es nur jetzt',
|
||||
'once_body' => 'In der Datenbank steht nur der Hash des Codes, der WireGuard-Schlüssel steht dort gar nicht. Wer die Seite verlässt oder neu lädt, bekommt sie nicht wieder, sondern legt einen neuen Code an — der alte ist damit wertlos.',
|
||||
|
||||
'step1_title' => 'Rettungssystem starten',
|
||||
'step1_body' => 'Im Kundenbereich des Anbieters das Rettungssystem einschalten und den Server neu starten. Einschalten allein genügt nicht: er muss wirklich darin hochgefahren sein, sonst weigert sich das Skript — es überschreibt Platten und prüft deshalb zuerst, ob es das darf.',
|
||||
|
||||
'step2_title' => 'Diese Zeile im Rettungssystem einfügen',
|
||||
'step2_body' => 'Per SSH als root auf den Server, Zeile einfügen, Eingabetaste. Es wird nichts abgetippt und nichts ausgefüllt: sie trägt alles, was der Server vor dem Tunnel braucht.',
|
||||
'step2_hint' => 'Die Zeile holt das Installationsskript von :url, packt es nach /opt/clupilot aus und startet es. Mehr lädt der Server nicht nach.',
|
||||
|
||||
'step3_title' => 'Zusehen',
|
||||
'step3_body' => 'Der Fortschritt läuft in der Konsole mit, Abschnitt für Abschnitt. Bis der Tunnel steht, meldet der Server nichts — das ist so gewollt und dauert über den ersten Neustart hinweg. Danach kommt alles Vorherige auf einmal nach, mit den Zeiten von damals.',
|
||||
|
||||
'watch' => 'Fortschritt ansehen',
|
||||
'copy' => 'Kopieren',
|
||||
'copied' => 'Kopiert',
|
||||
|
||||
'footnote' => 'Der Code gilt 24 Stunden und lässt sich genau einmal einlösen. Bleibt die Übernahme stehen, wird die Maschine neu aufgesetzt und nicht nachgebessert — was wo nachzusehen ist, steht im Runbook unter docs/runbooks/host-bootstrap.md.',
|
||||
],
|
||||
];
|
||||
|
|
|
|||
|
|
@ -134,4 +134,33 @@ return [
|
|||
'secure_host_firewall' => 'Secure host firewall',
|
||||
'complete_host_onboarding' => 'Complete onboarding',
|
||||
],
|
||||
'takeover' => [
|
||||
'before_title' => 'First: boot the rescue system',
|
||||
'before_body' => 'Order the server at the provider and boot its rescue system before you add it here. Right after you add it, this page shows a command line to paste into the rescue system — shown exactly once.',
|
||||
|
||||
'missing_title' => 'The tunnel is not set up yet',
|
||||
'missing_body' => 'Missing: :settings. Without these, the command line looks clean, runs, and ends in a tunnel that never handshakes — which only shows up on the machine. Fill them in under Settings before adding a host.',
|
||||
|
||||
'title' => ':name is created',
|
||||
'subtitle' => 'Three steps, then the server does the rest on its own.',
|
||||
|
||||
'once_title' => 'This line exists only now',
|
||||
'once_body' => 'The database holds only the hash of the code, and not the WireGuard key at all. Leaving or reloading this page does not bring it back — it mints a new code, and the old one becomes worthless.',
|
||||
|
||||
'step1_title' => 'Boot the rescue system',
|
||||
'step1_body' => 'Enable the rescue system in the provider\'s panel and restart the server. Enabling alone is not enough: it has to actually be running in it, or the script refuses — it overwrites disks, so it checks first whether it may.',
|
||||
|
||||
'step2_title' => 'Paste this line into the rescue system',
|
||||
'step2_body' => 'SSH in as root, paste the line, press enter. Nothing is typed out and nothing is filled in: it carries everything the server needs before the tunnel exists.',
|
||||
'step2_hint' => 'The line fetches the installer from :url, unpacks it to /opt/clupilot and starts it. The server downloads nothing else.',
|
||||
|
||||
'step3_title' => 'Watch',
|
||||
'step3_body' => 'Progress appears in the console, section by section. Until the tunnel is up the server reports nothing — that is intended, and it lasts across the first reboot. Everything before it then arrives at once, carrying the times it actually happened.',
|
||||
|
||||
'watch' => 'Watch progress',
|
||||
'copy' => 'Copy',
|
||||
'copied' => 'Copied',
|
||||
|
||||
'footnote' => 'The code is valid for 24 hours and can be redeemed exactly once. If the takeover stops, the machine is reinstalled rather than repaired — where to look is in the runbook at docs/runbooks/host-bootstrap.md.',
|
||||
],
|
||||
];
|
||||
|
|
|
|||
|
|
@ -1,43 +1,128 @@
|
|||
<div class="mx-auto max-w-xl space-y-5">
|
||||
<div class="animate-rise">
|
||||
<a href="{{ route('admin.hosts') }}" wire:navigate class="inline-flex items-center gap-1.5 text-xs font-medium text-muted hover:text-body">
|
||||
<x-ui.icon name="arrow-left" class="size-4" />{{ __('hosts.back') }}
|
||||
</a>
|
||||
<h1 class="mt-2 text-2xl font-bold tracking-tight text-ink">{{ __('hosts.create_title') }}</h1>
|
||||
<p class="mt-1 text-sm text-muted">{{ __('hosts.create_sub') }}</p>
|
||||
</div>
|
||||
|
||||
<form wire:submit="save" class="space-y-5 rounded-lg border border-line bg-surface p-6 shadow-xs animate-rise">
|
||||
<x-ui.input name="name" wire:model="name" :label="__('hosts.field.name')" :hint="__('hosts.field.name_hint')" autofocus />
|
||||
|
||||
<div class="space-y-1.5">
|
||||
<label for="datacenter" class="block text-sm font-medium text-body">{{ __('hosts.field.datacenter') }}</label>
|
||||
<select id="datacenter" wire:model="datacenter"
|
||||
class="block w-full rounded border border-line bg-surface px-3 py-2 text-sm text-ink transition">
|
||||
{{-- The building too, where it is known: two entries both
|
||||
reading "Falkenstein (fsn)" are not a choice anybody can
|
||||
make, and placing a second machine for redundancy is
|
||||
exactly when it matters which hall it lands in. --}}
|
||||
@foreach ($datacenters as $dc)
|
||||
<option value="{{ $dc->code }}">{{ $dc->name }} ({{ $dc->code }})@if ($dc->facility) · {{ $dc->facility }}@endif</option>
|
||||
@endforeach
|
||||
</select>
|
||||
@error('datacenter') <p class="text-xs text-danger">{{ $message }}</p> @enderror
|
||||
</div>
|
||||
|
||||
<x-ui.input name="public_ip" wire:model="public_ip" :label="__('hosts.field.public_ip')" inputmode="numeric" placeholder="203.0.113.10" />
|
||||
|
||||
<x-ui.input name="root_password" wire:model="root_password" type="password"
|
||||
:label="__('hosts.field.root_password')" :hint="__('hosts.field.root_password_hint')" autocomplete="off" />
|
||||
|
||||
<div class="flex items-center justify-end gap-3 pt-1">
|
||||
<a href="{{ route('admin.hosts') }}" wire:navigate>
|
||||
<x-ui.button variant="secondary" type="button">{{ __('hosts.cancel') }}</x-ui.button>
|
||||
<div class="mx-auto max-w-2xl space-y-5">
|
||||
@if ($command === null)
|
||||
<div class="animate-rise">
|
||||
<a href="{{ route('admin.hosts') }}" wire:navigate class="inline-flex items-center gap-1.5 text-xs font-medium text-muted hover:text-body">
|
||||
<x-ui.icon name="arrow-left" class="size-4" />{{ __('hosts.back') }}
|
||||
</a>
|
||||
<x-ui.button variant="primary" type="submit" wire:loading.attr="disabled">
|
||||
<span wire:loading.remove wire:target="save">{{ __('hosts.save') }}</span>
|
||||
<span wire:loading wire:target="save">{{ __('hosts.save') }}…</span>
|
||||
</x-ui.button>
|
||||
<h1 class="mt-2 text-2xl font-bold tracking-tight text-ink">{{ __('hosts.create_title') }}</h1>
|
||||
<p class="mt-1 text-sm text-muted">{{ __('hosts.create_sub') }}</p>
|
||||
</div>
|
||||
</form>
|
||||
|
||||
{{-- Was der Betreiber VORHER wissen muss, steht vor dem Formular und
|
||||
nicht danach: das Rettungssystem zu starten dauert beim Anbieter ein
|
||||
paar Minuten. Wer das erst hinterher liest, hat den Code schon in
|
||||
der Zwischenablage und wartet. --}}
|
||||
<x-ui.alert variant="info" class="animate-rise">
|
||||
<p class="font-medium">{{ __('hosts.takeover.before_title') }}</p>
|
||||
<p class="mt-1">{{ __('hosts.takeover.before_body') }}</p>
|
||||
</x-ui.alert>
|
||||
|
||||
@if ($missingSettings)
|
||||
<x-ui.alert variant="warning" class="animate-rise">
|
||||
<p class="font-medium">{{ __('hosts.takeover.missing_title') }}</p>
|
||||
<p class="mt-1">{{ __('hosts.takeover.missing_body', ['settings' => implode(', ', $missingSettings)]) }}</p>
|
||||
</x-ui.alert>
|
||||
@endif
|
||||
|
||||
<form wire:submit="save" class="space-y-5 rounded-lg border border-line bg-surface p-6 shadow-xs animate-rise">
|
||||
<x-ui.input name="name" wire:model="name" :label="__('hosts.field.name')" :hint="__('hosts.field.name_hint')" autofocus />
|
||||
|
||||
<div class="space-y-1.5">
|
||||
<label for="datacenter" class="block text-sm font-medium text-body">{{ __('hosts.field.datacenter') }}</label>
|
||||
<select id="datacenter" wire:model="datacenter"
|
||||
class="block w-full rounded border border-line bg-surface px-3 py-2 text-sm text-ink transition">
|
||||
{{-- The building too, where it is known: two entries both
|
||||
reading "Falkenstein (fsn)" are not a choice anybody can
|
||||
make, and placing a second machine for redundancy is
|
||||
exactly when it matters which hall it lands in. --}}
|
||||
@foreach ($datacenters as $dc)
|
||||
<option value="{{ $dc->code }}">{{ $dc->name }} ({{ $dc->code }})@if ($dc->facility) · {{ $dc->facility }}@endif</option>
|
||||
@endforeach
|
||||
</select>
|
||||
@error('datacenter') <p class="text-xs text-danger">{{ $message }}</p> @enderror
|
||||
</div>
|
||||
|
||||
<x-ui.input name="public_ip" wire:model="public_ip" :label="__('hosts.field.public_ip')" inputmode="numeric" placeholder="203.0.113.10" />
|
||||
|
||||
<x-ui.input name="root_password" wire:model="root_password" type="password"
|
||||
:label="__('hosts.field.root_password')" :hint="__('hosts.field.root_password_hint')" autocomplete="off" />
|
||||
|
||||
<div class="flex items-center justify-end gap-3 pt-1">
|
||||
<a href="{{ route('admin.hosts') }}" wire:navigate>
|
||||
<x-ui.button variant="secondary" type="button">{{ __('hosts.cancel') }}</x-ui.button>
|
||||
</a>
|
||||
<x-ui.button variant="primary" type="submit" wire:loading.attr="disabled">
|
||||
<span wire:loading.remove wire:target="save">{{ __('hosts.save') }}</span>
|
||||
<span wire:loading wire:target="save">{{ __('hosts.save') }}…</span>
|
||||
</x-ui.button>
|
||||
</div>
|
||||
</form>
|
||||
@else
|
||||
{{-- Der Host steht. Ab hier ist diese Seite eine Anleitung und kein
|
||||
Formular mehr — und sie ist die EINZIGE Stelle, an der die
|
||||
Befehlszeile je zu sehen ist. --}}
|
||||
<div class="animate-rise">
|
||||
<h1 class="text-2xl font-bold tracking-tight text-ink">{{ __('hosts.takeover.title', ['name' => $createdName]) }}</h1>
|
||||
<p class="mt-1 text-sm text-muted">{{ __('hosts.takeover.subtitle') }}</p>
|
||||
</div>
|
||||
|
||||
<x-ui.alert variant="warning" class="animate-rise">
|
||||
<p class="font-medium">{{ __('hosts.takeover.once_title') }}</p>
|
||||
<p class="mt-1">{{ __('hosts.takeover.once_body') }}</p>
|
||||
</x-ui.alert>
|
||||
|
||||
<ol class="space-y-4 animate-rise">
|
||||
<li class="rounded-lg border border-line bg-surface p-5 shadow-xs">
|
||||
<div class="flex items-baseline gap-2">
|
||||
<span class="text-xs font-bold text-muted">1</span>
|
||||
<h2 class="text-sm font-semibold text-ink">{{ __('hosts.takeover.step1_title') }}</h2>
|
||||
</div>
|
||||
<p class="mt-2 text-sm text-muted">{{ __('hosts.takeover.step1_body') }}</p>
|
||||
</li>
|
||||
|
||||
<li class="rounded-lg border border-line bg-surface p-5 shadow-xs">
|
||||
<div class="flex items-baseline gap-2">
|
||||
<span class="text-xs font-bold text-muted">2</span>
|
||||
<h2 class="text-sm font-semibold text-ink">{{ __('hosts.takeover.step2_title') }}</h2>
|
||||
</div>
|
||||
<p class="mt-2 text-sm text-muted">{{ __('hosts.takeover.step2_body') }}</p>
|
||||
|
||||
<div x-data="{ copied: false }" class="mt-3">
|
||||
<div class="relative">
|
||||
{{-- Umbrechen statt waagerecht rollen: aus einem Kasten
|
||||
mit Rollbalken markiert jemand die Hälfte und merkt
|
||||
es erst auf der Maschine. --}}
|
||||
<pre class="max-h-56 overflow-y-auto rounded border border-line bg-canvas p-3 pr-24 font-mono text-xs leading-relaxed text-ink break-all whitespace-pre-wrap"
|
||||
x-ref="command">{{ $command }}</pre>
|
||||
<button type="button"
|
||||
class="absolute right-2 top-2 inline-flex items-center gap-1 rounded border border-line bg-surface px-2 py-1 text-xs font-medium text-body hover:text-ink"
|
||||
x-on:click="navigator.clipboard.writeText($refs.command.textContent); copied = true; setTimeout(() => copied = false, 2000)">
|
||||
<x-ui.icon name="copy" class="size-4" x-show="!copied" />
|
||||
<x-ui.icon name="check" class="size-4" x-show="copied" x-cloak />
|
||||
<span x-text="copied ? @js(__('hosts.takeover.copied')) : @js(__('hosts.takeover.copy'))">{{ __('hosts.takeover.copy') }}</span>
|
||||
</button>
|
||||
</div>
|
||||
<p class="mt-2 text-xs text-muted">{{ __('hosts.takeover.step2_hint', ['url' => $archiveUrl]) }}</p>
|
||||
</div>
|
||||
</li>
|
||||
|
||||
<li class="rounded-lg border border-line bg-surface p-5 shadow-xs">
|
||||
<div class="flex items-baseline gap-2">
|
||||
<span class="text-xs font-bold text-muted">3</span>
|
||||
<h2 class="text-sm font-semibold text-ink">{{ __('hosts.takeover.step3_title') }}</h2>
|
||||
</div>
|
||||
<p class="mt-2 text-sm text-muted">{{ __('hosts.takeover.step3_body') }}</p>
|
||||
<div class="mt-3">
|
||||
<a href="{{ route('admin.hosts.show', ['host' => $createdUuid]) }}" wire:navigate>
|
||||
<x-ui.button variant="primary">
|
||||
<x-slot:icon><x-ui.icon name="activity" class="size-4" /></x-slot:icon>
|
||||
{{ __('hosts.takeover.watch') }}
|
||||
</x-ui.button>
|
||||
</a>
|
||||
</div>
|
||||
</li>
|
||||
</ol>
|
||||
|
||||
<p class="text-xs text-muted animate-rise">{{ __('hosts.takeover.footnote') }}</p>
|
||||
@endif
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -1,5 +1,6 @@
|
|||
<?php
|
||||
|
||||
use App\Http\Controllers\BootstrapArchiveController;
|
||||
use App\Http\Controllers\ImpersonationController;
|
||||
use App\Http\Controllers\LandingController;
|
||||
use App\Http\Controllers\StatusController;
|
||||
|
|
@ -190,6 +191,13 @@ $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"
|
||||
|
|
|
|||
|
|
@ -35,6 +35,11 @@ it('lists real hosts for admins', function () {
|
|||
|
||||
it('creates a host and starts onboarding with an encrypted password', function () {
|
||||
Queue::fake();
|
||||
// Das Anlegen erzeugt jetzt auch das WireGuard-Schlüsselpaar und nimmt den
|
||||
// Peer beim Hub auf, damit der Host schon im Tunnel bekannt ist, bevor er
|
||||
// das erste Mal bootet (Spec §5). Ohne den gefälschten Hub griffe das nach
|
||||
// `wg` auf einer Maschine, die keine wg0 hat.
|
||||
fakeServices();
|
||||
\App\Models\Datacenter::query()->firstOrCreate(['code' => 'fsn'], ['name' => 'Falkenstein']);
|
||||
|
||||
Livewire::actingAs(admin(), 'operator')
|
||||
|
|
@ -44,7 +49,11 @@ it('creates a host and starts onboarding with an encrypted password', function (
|
|||
->set('public_ip', '203.0.113.7')
|
||||
->set('root_password', 'supersecret')
|
||||
->call('save')
|
||||
->assertRedirect();
|
||||
// KEINE Weiterleitung mehr, und das ist der Punkt: die Befehlszeile für
|
||||
// das Rettungssystem gibt es nur hier und nur jetzt. Wer den Betreiber
|
||||
// weiterschickt, schickt ihn von dem einzigen Wert weg, den er braucht.
|
||||
->assertNoRedirect()
|
||||
->assertSee('clupilot-bootstrap.sh');
|
||||
|
||||
$host = Host::query()->where('name', 'pve-fsn-7')->first();
|
||||
expect($host)->not->toBeNull()->and($host->status)->toBe('pending');
|
||||
|
|
|
|||
|
|
@ -0,0 +1,146 @@
|
|||
<?php
|
||||
|
||||
use App\Livewire\Admin\HostCreate;
|
||||
use App\Models\Datacenter;
|
||||
use App\Models\Host;
|
||||
use App\Models\Operator;
|
||||
use App\Support\HostEnrolment;
|
||||
use Livewire\Livewire;
|
||||
|
||||
beforeEach(function () {
|
||||
fakeServices();
|
||||
|
||||
config()->set('provisioning.wireguard.hub_public_key', 'HUBKEYHUBKEYHUBKEYHUBKEYHUBKEYHUBKEYHUBKEY0=');
|
||||
config()->set('provisioning.wireguard.endpoint', 'hub.clupilot.cloud:51820');
|
||||
config()->set('provisioning.wireguard.hub_address', '10.66.0.1');
|
||||
config()->set('provisioning.wireguard.subnet', '10.66.0.0/24');
|
||||
config()->set('admin_access.app_host', 'clupilot.cloud');
|
||||
|
||||
// firstOrCreate: die Migrationen legen Rechenzentren schon an, und ein
|
||||
// zweites `fsn` verletzt den eindeutigen Index.
|
||||
Datacenter::query()->firstOrCreate(
|
||||
['code' => 'fsn'],
|
||||
['name' => 'Falkenstein', 'location' => 'DE', 'active' => true],
|
||||
);
|
||||
});
|
||||
|
||||
function createHostAs(): \Livewire\Features\SupportTesting\Testable
|
||||
{
|
||||
return Livewire::actingAs(Operator::factory()->role('Owner')->create(), 'operator')
|
||||
->test(HostCreate::class)
|
||||
->set('name', 'pve-fsn-2')
|
||||
->set('datacenter', 'fsn')
|
||||
->set('public_ip', '198.51.45.9')
|
||||
->set('root_password', 'ein-langes-kennwort')
|
||||
->call('save');
|
||||
}
|
||||
|
||||
/**
|
||||
* Die eine Frage, die diese Seite beantworten muss: was gebe ich auf dem Server
|
||||
* ein? Steht die Zeile nicht da, ist die ganze Übernahme nicht benutzbar.
|
||||
*/
|
||||
it('shows the command exactly once, right after creating the host', function () {
|
||||
$page = createHostAs();
|
||||
|
||||
$page->assertSee('curl')
|
||||
->assertSee('clupilot-bootstrap.sh')
|
||||
->assertSee('--code');
|
||||
|
||||
// Ein frisch geladenes Bauteil — also jemand, der die Seite neu lädt — hat
|
||||
// nichts mehr. Der Code steht nur als Hash in der Datenbank.
|
||||
Livewire::actingAs(Operator::factory()->role('Owner')->create(), 'operator')
|
||||
->test(HostCreate::class)
|
||||
->assertDontSee('curl');
|
||||
});
|
||||
|
||||
/**
|
||||
* Die Zeile muss ALLES tragen, was das Skript vor dem Tunnel braucht (Spec §5).
|
||||
* Fehlt ein Wert, läuft sie an und endet in einem Tunnel ohne Handshake — und
|
||||
* das merkt man erst auf einer Maschine, die schon bestellt ist.
|
||||
*/
|
||||
it('carries every value the script needs before the tunnel', function () {
|
||||
$command = createHostAs()->get('command');
|
||||
|
||||
foreach (['--code', '--wg-private', '--wg-ip', '--hub-pubkey', '--hub-endpoint', '--api'] as $argument) {
|
||||
expect($command)->toContain($argument);
|
||||
}
|
||||
|
||||
expect($command)
|
||||
->toContain('hub.clupilot.cloud:51820')
|
||||
->toContain('--api http://10.66.0.1')
|
||||
->toContain('/opt/clupilot');
|
||||
});
|
||||
|
||||
/**
|
||||
* Über den ÖFFENTLICHEN Hostnamen. Die Konsole läuft unter admin.…, aber die
|
||||
* Zeile wird auf einer Maschine ausgeführt, die den Adminbereich nicht erreichen
|
||||
* darf — sie ist genau dafür abgeriegelt. Ein Link auf den Konsolen-Hostnamen
|
||||
* liefe in eine 404 auf einem Server, an den man dann nur noch über die
|
||||
* Anbieterkonsole kommt.
|
||||
*/
|
||||
it('fetches the installer over the public hostname', function () {
|
||||
expect(createHostAs()->get('command'))
|
||||
->toContain('https://clupilot.cloud/bootstrap.tar.gz')
|
||||
->not->toContain('admin.');
|
||||
});
|
||||
|
||||
/**
|
||||
* Der Schlüssel ist base64 und enthält +, / und =. Ohne Anführungszeichen
|
||||
* zerlegt die Shell die Zeile an einer Stelle, die niemand sieht.
|
||||
*/
|
||||
it('quotes the keys so a shell cannot split them', function () {
|
||||
$command = createHostAs()->get('command');
|
||||
|
||||
expect($command)->toMatch("/--wg-private '[^']+'/")
|
||||
->and($command)->toMatch("/--hub-pubkey '[^']+'/");
|
||||
});
|
||||
|
||||
/**
|
||||
* Der Code aus der Zeile muss der sein, mit dem der Host sich meldet. Sonst ist
|
||||
* die Anleitung richtig und der Ablauf trotzdem kaputt.
|
||||
*/
|
||||
it('shows a code that actually resolves to the new host', function () {
|
||||
$command = createHostAs()->get('command');
|
||||
|
||||
preg_match('/--code ([A-Za-z0-9]+)/', $command, $matches);
|
||||
|
||||
expect(HostEnrolment::claim($matches[1])?->name)->toBe('pve-fsn-2');
|
||||
});
|
||||
|
||||
/**
|
||||
* Ein leerer Hub-Schlüssel ergibt eine Zeile, die läuft und nie handshaked.
|
||||
* Das gehört gesagt, BEVOR jemand einen Server bestellt.
|
||||
*/
|
||||
it('warns before creating a host when the tunnel settings are missing', function () {
|
||||
config()->set('provisioning.wireguard.hub_public_key', '');
|
||||
config()->set('provisioning.wireguard.endpoint', '');
|
||||
|
||||
Livewire::actingAs(Operator::factory()->role('Owner')->create(), 'operator')
|
||||
->test(HostCreate::class)
|
||||
->assertSee('CLUPILOT_WG_HUB_PUBKEY')
|
||||
->assertSee('CLUPILOT_WG_ENDPOINT');
|
||||
});
|
||||
|
||||
/**
|
||||
* Die drei Schritte sind die Anleitung. Fehlt einer, fehlt genau der, den
|
||||
* jemand nicht von selbst weiß — meistens das Rettungssystem.
|
||||
*/
|
||||
it('spells out the three steps, rescue system first', function () {
|
||||
createHostAs()
|
||||
->assertSee(__('hosts.takeover.step1_title'))
|
||||
->assertSee(__('hosts.takeover.step2_title'))
|
||||
->assertSee(__('hosts.takeover.step3_title'));
|
||||
});
|
||||
|
||||
it('refuses to create a host without the permission', function () {
|
||||
Livewire::actingAs(Operator::factory()->create(), 'operator')
|
||||
->test(HostCreate::class)
|
||||
->set('name', 'pve-fsn-3')
|
||||
->set('datacenter', 'fsn')
|
||||
->set('public_ip', '198.51.45.10')
|
||||
->set('root_password', 'ein-langes-kennwort')
|
||||
->call('save')
|
||||
->assertForbidden();
|
||||
|
||||
expect(Host::query()->where('name', 'pve-fsn-3')->exists())->toBeFalse();
|
||||
});
|
||||
|
|
@ -0,0 +1,78 @@
|
|||
<?php
|
||||
|
||||
/**
|
||||
* Das Archiv ist der einzige Weg, auf dem das Skript auf eine nackte Maschine
|
||||
* kommt. Was hier schiefgeht, merkt man erst im Rettungssystem eines Servers,
|
||||
* der schon bestellt ist.
|
||||
*/
|
||||
it('serves the bootstrap script as an archive', function () {
|
||||
$response = $this->get('/bootstrap.tar.gz');
|
||||
|
||||
$response->assertOk();
|
||||
expect($response->headers->get('Content-Type'))->toBe('application/gzip');
|
||||
});
|
||||
|
||||
/**
|
||||
* Der oberste Eintrag MUSS `bootstrap/` heißen. Die kopierte Zeile packt mit
|
||||
* `tar xz -C /opt/clupilot` aus, und das Skript sucht sich selbst unter
|
||||
* `/opt/clupilot/bootstrap` — ein anderer Name landet daneben, und der Fehler
|
||||
* zeigt sich als „lib/report.sh nicht gefunden" auf einer Maschine, an die man
|
||||
* nur über die Anbieterkonsole kommt.
|
||||
*/
|
||||
it('unpacks to bootstrap/ with the script and its library', function () {
|
||||
$this->get('/bootstrap.tar.gz')->assertOk();
|
||||
|
||||
$listing = shell_exec('tar tzf '.escapeshellarg(storage_path('app/bootstrap.tar.gz')));
|
||||
|
||||
expect($listing)
|
||||
->toContain('bootstrap/clupilot-bootstrap.sh')
|
||||
->toContain('bootstrap/lib/report.sh')
|
||||
->toContain('bootstrap/lib/proxmox.sh')
|
||||
->toContain('bootstrap/lib/network.sh')
|
||||
->toContain('bootstrap/lib/traefik.sh')
|
||||
->toContain('bootstrap/lib/template.sh')
|
||||
->toContain('bootstrap/lib/register.sh')
|
||||
->toContain('bootstrap/assets/docker-compose.yml');
|
||||
});
|
||||
|
||||
/**
|
||||
* Es darf nichts Vertrauliches darin stehen. Alle Geheimnisse reisen in der
|
||||
* Befehlszeile mit, die der Adminbereich genau einmal zeigt.
|
||||
*/
|
||||
it('carries no secrets', function () {
|
||||
$this->get('/bootstrap.tar.gz')->assertOk();
|
||||
|
||||
$dir = sys_get_temp_dir().'/bootstrap-pruefung-'.bin2hex(random_bytes(4));
|
||||
mkdir($dir);
|
||||
shell_exec('tar xzf '.escapeshellarg(storage_path('app/bootstrap.tar.gz')).' -C '.escapeshellarg($dir));
|
||||
|
||||
$content = '';
|
||||
foreach (glob($dir.'/bootstrap/{,lib/,assets/}*', GLOB_BRACE) as $file) {
|
||||
if (is_file($file)) {
|
||||
$content .= file_get_contents($file);
|
||||
}
|
||||
}
|
||||
|
||||
expect($content)->not->toBeEmpty()
|
||||
->and($content)->not->toContain(config('app.key'))
|
||||
->and($content)->not->toContain((string) env('SECRETS_KEY'));
|
||||
|
||||
shell_exec('rm -rf '.escapeshellarg($dir));
|
||||
});
|
||||
|
||||
/**
|
||||
* Ein Archiv, das einmal gebaut wurde und danach liegen bleibt, ist die Fassung
|
||||
* von dem Tag, an dem jemand zuletzt daran gedacht hat.
|
||||
*/
|
||||
it('rebuilds itself when the script changes', function () {
|
||||
$this->get('/bootstrap.tar.gz')->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();
|
||||
|
||||
expect(filemtime(storage_path('app/bootstrap.tar.gz')))->toBeGreaterThan($first - 10);
|
||||
});
|
||||
|
|
@ -0,0 +1,119 @@
|
|||
<?php
|
||||
|
||||
use App\Models\Host;
|
||||
use App\Services\Wireguard\WireguardHub;
|
||||
use App\Support\HostEnrolment;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
|
||||
beforeEach(function () {
|
||||
fakeServices();
|
||||
});
|
||||
|
||||
/**
|
||||
* Der Code ist ein Ausweis für den Rückweg, kein Schlüssel zu einer Auskunft:
|
||||
* er öffnet nichts nach außen (§5 der Spec), sondern verhindert nur, dass ein
|
||||
* Gerät im Tunnel für einen fremden Host spricht.
|
||||
*/
|
||||
it('hands out the code exactly once and stores only its hash', function () {
|
||||
$host = Host::factory()->create();
|
||||
|
||||
$code = HostEnrolment::issue($host);
|
||||
|
||||
expect($code)->toHaveLength(32);
|
||||
|
||||
$stored = DB::table('hosts')->where('id', $host->id)->value('enrolment_code_hash');
|
||||
expect($stored)->not->toBe($code)
|
||||
->and($stored)->not->toContain($code);
|
||||
});
|
||||
|
||||
it('resolves a valid code to its host and consumes it', function () {
|
||||
$host = Host::factory()->create();
|
||||
$code = HostEnrolment::issue($host);
|
||||
|
||||
expect(HostEnrolment::claim($code)?->id)->toBe($host->id);
|
||||
|
||||
// Verbraucht. Ein zweiter Lauf braucht einen neuen Code aus der Konsole —
|
||||
// eine halb installierte Maschine wird neu aufgesetzt, nicht nachgebessert.
|
||||
expect(HostEnrolment::claim($code))->toBeNull();
|
||||
});
|
||||
|
||||
/**
|
||||
* Der Fortschritt kommt VOR der Registrierung und darf den Code deshalb nicht
|
||||
* verbrauchen — sonst könnte der Host sich nach seiner ersten Meldung nie mehr
|
||||
* registrieren.
|
||||
*/
|
||||
it('resolves a code without consuming it, for the progress reports', function () {
|
||||
$host = Host::factory()->create();
|
||||
$code = HostEnrolment::issue($host);
|
||||
|
||||
expect(HostEnrolment::resolve($code)?->id)->toBe($host->id)
|
||||
->and(HostEnrolment::resolve($code)?->id)->toBe($host->id);
|
||||
|
||||
// Und danach ist er immer noch einlösbar.
|
||||
expect(HostEnrolment::claim($code)?->id)->toBe($host->id);
|
||||
});
|
||||
|
||||
it('refuses a code past its expiry', function () {
|
||||
$host = Host::factory()->create();
|
||||
$code = HostEnrolment::issue($host);
|
||||
|
||||
$this->travel(25)->hours();
|
||||
|
||||
expect(HostEnrolment::claim($code))->toBeNull();
|
||||
});
|
||||
|
||||
it('refuses a code that was never issued', function () {
|
||||
expect(HostEnrolment::claim(str_repeat('a', 32)))->toBeNull();
|
||||
});
|
||||
|
||||
/**
|
||||
* Der Hub muss den Peer kennen, BEVOR der Host im Tunnel ist — sonst käme er
|
||||
* nie hinein und müsste seinen Schlüssel über einen öffentlichen Weg melden.
|
||||
* Genau das vermeidet dieser Entwurf.
|
||||
*/
|
||||
it('allocates a tunnel address and admits the peer up front', function () {
|
||||
$host = Host::factory()->create(['wg_ip' => null, 'wg_pubkey' => null]);
|
||||
|
||||
HostEnrolment::issue($host);
|
||||
|
||||
$host->refresh();
|
||||
expect($host->wg_ip)->not->toBeNull()
|
||||
->and($host->wg_pubkey)->not->toBeNull();
|
||||
|
||||
// `peers()` ist nach dem öffentlichen Schlüssel indiziert, die Werte sind
|
||||
// Momentaufnahmen — geprüft wird deshalb gegen die Schlüssel.
|
||||
expect(array_keys(app(WireguardHub::class)->peers()))->toContain($host->wg_pubkey);
|
||||
});
|
||||
|
||||
/**
|
||||
* Der private Schlüssel steht in der kopierten Befehlszeile und sonst nirgends.
|
||||
* Ihn zu speichern hieße, ein Geheimnis aufzubewahren, das nach Task 9 des
|
||||
* Skripts ohnehin wertlos ist — und bis dahin eine Stelle mehr, an der es liegt.
|
||||
*/
|
||||
it('returns the private key with the code and never stores it', function () {
|
||||
$host = Host::factory()->create();
|
||||
|
||||
$enrolment = HostEnrolment::issueWithKeys($host);
|
||||
|
||||
expect($enrolment['private_key'])->not->toBeEmpty();
|
||||
|
||||
$row = (array) DB::table('hosts')->where('id', $host->id)->first();
|
||||
foreach ($row as $value) {
|
||||
expect((string) $value)->not->toContain($enrolment['private_key']);
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* Ein zweiter Code macht den ersten wertlos. Sonst hätte eine Maschine, die im
|
||||
* Rettungssystem hängengeblieben ist, weiter einen gültigen Ausweis für einen
|
||||
* Host, den der Betreiber gerade neu übernimmt.
|
||||
*/
|
||||
it('invalidates the previous code when a new one is issued', function () {
|
||||
$host = Host::factory()->create();
|
||||
|
||||
$first = HostEnrolment::issue($host);
|
||||
$second = HostEnrolment::issue($host);
|
||||
|
||||
expect(HostEnrolment::claim($first))->toBeNull()
|
||||
->and(HostEnrolment::claim($second)?->id)->toBe($host->id);
|
||||
});
|
||||
Loading…
Reference in New Issue