Merge the host-takeover branch

No conflicts: the branch had already taken main up to v1.3.65, and the only
commit main carried on its own was that release itself.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
feature/host-bootstrap
nexxo 2026-07-30 21:52:29 +02:00
commit cfc4985f89
10 changed files with 405 additions and 220 deletions

View File

@ -57,6 +57,10 @@ class PublicSiteGate
return $next($request);
}
if ($this->isDownload($request)) {
return $next($request);
}
if ($this->fromManagementNetwork($request) || $this->isActiveOperator()) {
return $next($request);
}
@ -68,6 +72,30 @@ class PublicSiteGate
]);
}
/**
* Die Datei-Domain bleibt erreichbar, auch solange die Website verborgen ist.
*
* Sonst bricht jede Host-Übernahme, und zwar unsichtbar: ein Server im
* Rettungssystem holt das Archiv mit `curl … | tar xz`, bekäme hier die
* 503-Platzhalterseite und schöbe sie in `tar`. Der Betreiber sähe einen
* Entpackfehler auf einer Maschine, an die er nur noch über die
* Anbieterkonsole kommt und nichts davon deutete auf einen Schalter in
* der Konsole hin. Er ist per Definition weder in einem verwalteten Netz
* noch angemeldet: das ist ja gerade der Zustand, den er verlassen will.
*
* Über den ROUTENNAMEN, nicht über den Pfad: die Route existiert nur, wenn
* die Domain gebunden hat. Der Docblock oben warnt zu Recht davor, per
* Hostname auszunehmen dort ginge es aber um das gesamte Portal, und ein
* gefälschter Host-Header hätte es aufgedeckt. Hier stehen zwei Routen
* dahinter: die Rechtsdokumente, die ohnehin öffentlich sind, und das
* Archiv, das ohne gültigen Einmal-Code mit 404 antwortet. Wer den Header
* fälscht, gewinnt damit nichts, was er nicht auch nach dem Start hätte.
*/
private function isDownload(Request $request): bool
{
return in_array($request->route()?->getName(), ['bootstrap.archive', 'files.public'], true);
}
private function fromManagementNetwork(Request $request): bool
{
$ranges = (array) config('admin_access.trusted_ranges', []);

View File

@ -135,45 +135,64 @@ return [
'complete_host_onboarding' => 'Onboarding abschließen',
],
'takeover' => [
'eyebrow' => 'Host-Übernahme',
'how_title' => 'So läuft eine Host-Übernahme ab',
'how_sub' => 'Sechs Schritte, etwa 20 bis 40 Minuten. Die ersten beiden passieren beim Anbieter — bevor hier etwas angelegt wird.',
'how_sub' => 'Sechs Schritte, etwa 20 bis 40 Minuten. Die ersten beiden passieren beim Anbieter.',
'rail_title' => 'Ablauf',
'badge_before' => 'vorher',
'badge_now' => 'jetzt',
'badge_later' => 'danach',
// Kurz — sie stehen in der Schiene nebeneinander, nicht als Absatz.
's1_label' => 'Server bestellen',
's2_label' => 'Rettungssystem',
's3_label' => 'Host anlegen',
's4_label' => 'Zeile kopieren',
's5_label' => 'Einfügen',
's6_label' => 'Zusehen',
's1_title' => 'Server bestellen',
's1_body' => 'Eine dedizierte Maschine. Keine Cloud-Instanz: ohne Hardware-Virtualisierung (/dev/kvm) startet später kein einziger Gast, und das Skript weist die Maschine gleich zu Beginn ab. Bei Hetzner CPX/CX ist das immer so, bei netcup je nach Produkt.',
's1_body' => 'Dediziert, nicht Cloud. Ohne /dev/kvm startet kein Gast, und das Skript weist die Maschine ab.',
's2_title' => 'Rettungssystem starten',
's2_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.',
's2_body' => 'Einschalten UND neu starten. Läuft der Server noch im alten System, weigert sich das Skript — es überschreibt Platten.',
's3_title' => 'Host hier anlegen',
's3_body' => 'Name, Rechenzentrum, öffentliche IP und root-Kennwort eintragen und speichern. Erst danach entsteht der Einmal-Code, und ab dann läuft seine Frist von 24 Stunden.',
's3_title' => 'Host anlegen',
's3_body' => 'Die Angaben unten. Danach entsteht der Einmal-Code, und seine Frist von 24 Stunden beginnt.',
's4_title' => 'Die Befehlszeile kopieren',
's4_body' => 'Sie erscheint direkt nach dem Speichern und wird genau einmal gezeigt.',
's4_title' => 'Befehlszeile kopieren',
's4_body' => 'Sie erscheint nach dem Speichern und wird genau einmal gezeigt.',
's5_title' => 'Im Rettungssystem einfügen',
's5_body' => 'Per SSH als root auf den Server, Zeile einfügen, Eingabetaste. Es wird nichts abgetippt und nichts ausgefüllt: die Zeile trägt alles, was der Server vor dem Tunnel braucht.',
's5_hint' => 'Die Zeile holt das Installationsskript von :url, packt es nach /opt/clupilot aus und startet es. Mehr lädt der Server nicht nach.',
's5_body' => 'Per SSH als root, Zeile einfügen, Eingabetaste. Nichts abtippen, nichts ausfüllen.',
's5_hint' => 'Holt das Skript von :url und startet es. Mehr lädt der Server nicht nach.',
's6_title' => 'Zusehen',
's6_body' => 'Der Fortschritt läuft auf der Host-Seite 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.',
's6_body' => 'Der Fortschritt läuft auf der Host-Seite mit. Bis der Tunnel steht, meldet der Server nichts — danach kommt alles auf einmal nach.',
'requirements_title' => 'Beim Anbieter, vorher',
'requirements_sub' => 'Beides muss erledigt sein, bevor unten gespeichert wird — der Code läuft ab dem Speichern.',
'form_title' => 'Der Host',
'form_sub' => 'Vier Angaben. Den Rest holt sich der Server selbst.',
'command_title' => 'Die Befehlszeile',
'command_sub' => 'Einfügen im Rettungssystem, als root. Sie trägt alles, was der Server vor dem Tunnel braucht.',
'after_title' => 'Danach',
'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.',
'missing_body' => 'Es fehlt: :settings. Ohne diese Werte entsteht eine Zeile, die läuft und in einem Tunnel endet, der nie einen Handshake hat — und das fällt erst auf der Maschine auf.',
'title' => ':name ist angelegt',
'subtitle' => 'Noch drei Schritte, dann 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.',
'once_body' => 'In der Datenbank steht nur der Hash des Codes, der WireGuard-Schlüssel gar nicht. Neu laden bringt sie nicht zurück, sondern legt einen neuen Code an — der alte ist dann wertlos.',
'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.',
'footnote' => 'Der Code gilt 24 Stunden und lässt sich einmal einlösen. Bleibt die Übernahme stehen, wird die Maschine neu aufgesetzt statt nachgebessert — siehe docs/runbooks/host-bootstrap.md.',
],
];

View File

@ -135,45 +135,63 @@ return [
'complete_host_onboarding' => 'Complete onboarding',
],
'takeover' => [
'eyebrow' => 'Host takeover',
'how_title' => 'How a host takeover works',
'how_sub' => 'Six steps, roughly 20 to 40 minutes. The first two happen at the provider — before anything is created here.',
'how_sub' => 'Six steps, roughly 20 to 40 minutes. The first two happen at the provider.',
'rail_title' => 'Procedure',
'badge_before' => 'first',
'badge_now' => 'now',
'badge_later' => 'then',
's1_label' => 'Order server',
's2_label' => 'Rescue system',
's3_label' => 'Add host',
's4_label' => 'Copy line',
's5_label' => 'Paste',
's6_label' => 'Watch',
's1_title' => 'Order the server',
's1_body' => 'A dedicated machine. Not a cloud instance: without hardware virtualisation (/dev/kvm) no guest will ever start, and the script turns the machine away at the very beginning. Hetzner CPX/CX never has it; netcup depends on the product.',
's1_body' => 'Dedicated, not cloud. Without /dev/kvm no guest will start, and the script turns the machine away.',
's2_title' => 'Boot the rescue system',
's2_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.',
's2_body' => 'Enable it AND restart. If the server is still in the old system the script refuses — it overwrites disks.',
's3_title' => 'Add the host here',
's3_body' => 'Name, datacenter, public IP and root password, then save. The one-time code is created at that point, and its 24-hour window starts running.',
's3_title' => 'Add the host',
's3_body' => 'The fields below. The one-time code is created after that, and its 24-hour window starts.',
's4_title' => 'Copy the command line',
's4_body' => 'It appears right after saving and is shown exactly once.',
's4_body' => 'It appears after saving and is shown exactly once.',
's5_title' => 'Paste it into the rescue system',
's5_body' => 'SSH in as root, paste the line, press enter. Nothing is typed out and nothing is filled in: the line carries everything the server needs before the tunnel exists.',
's5_hint' => 'The line fetches the installer from :url, unpacks it to /opt/clupilot and starts it. The server downloads nothing else.',
's5_body' => 'SSH in as root, paste, press enter. Nothing typed out, nothing filled in.',
's5_hint' => 'Fetches the installer from :url and starts it. The server downloads nothing else.',
's6_title' => 'Watch',
's6_body' => 'Progress appears on the host page, 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.',
's6_body' => 'Progress appears on the host page. Until the tunnel is up the server reports nothing — then everything arrives at once.',
'requirements_title' => 'At the provider, first',
'requirements_sub' => 'Both have to be done before you save below — the code starts expiring the moment you do.',
'form_title' => 'The host',
'form_sub' => 'Four fields. The server works out the rest itself.',
'command_title' => 'The command line',
'command_sub' => 'Paste into the rescue system as root. It carries everything the server needs before the tunnel.',
'after_title' => 'Then',
'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.',
'missing_body' => 'Missing: :settings. Without these the line runs and ends in a tunnel that never handshakes — and that only shows up on the machine.',
'title' => ':name is created',
'subtitle' => 'Three steps left, 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.',
'once_body' => 'The database holds only the hash of the code, and not the WireGuard key at all. Reloading does not bring it back — it mints a new code, and the old one becomes worthless.',
'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.',
'footnote' => 'The code is valid for 24 hours and can be redeemed once. If the takeover stops, the machine is reinstalled rather than repaired — see docs/runbooks/host-bootstrap.md.',
],
];

View File

@ -1,109 +0,0 @@
@props([
// Die fertige Befehlszeile, sobald der Host angelegt ist — sonst null.
'command' => null,
'archiveUrl' => '',
'hostUuid' => null,
])
{{-- Die Anleitung für eine Host-Übernahme, an EINER Stelle geschrieben.
Sie steht bewusst dort, wo jemand sie braucht, bevor er anfängt: die
Schritte 1 und 2 passieren beim Anbieter, und wer sie erst nach dem Anlegen
liest, hat den Einmal-Code schon in der Zwischenablage und dessen Frist
schon laufen. Die erste Fassung dieser Seite zeigte die Anleitung erst
hinterher das war genau dieser Fehler.
Deshalb sind IMMER alle sechs Schritte zu sehen, auch die erledigten und
die kommenden. Eine Anleitung, die nur den aktuellen Schritt zeigt,
beantwortet die Frage „wie lange dauert das noch" nicht. --}}
@php
$done = $command !== null;
// Schritt 3 ist das Formular auf dieser Seite; ist die Zeile da, sind 13
// vorbei und 4 ist dran.
$current = $done ? 4 : 3;
$steps = [
1 => ['title' => __('hosts.takeover.s1_title'), 'body' => __('hosts.takeover.s1_body')],
2 => ['title' => __('hosts.takeover.s2_title'), 'body' => __('hosts.takeover.s2_body')],
3 => ['title' => __('hosts.takeover.s3_title'), 'body' => __('hosts.takeover.s3_body')],
4 => ['title' => __('hosts.takeover.s4_title'), 'body' => __('hosts.takeover.s4_body')],
5 => ['title' => __('hosts.takeover.s5_title'), 'body' => __('hosts.takeover.s5_body')],
6 => ['title' => __('hosts.takeover.s6_title'), 'body' => __('hosts.takeover.s6_body')],
];
@endphp
<div {{ $attributes->merge(['class' => 'rounded-lg border border-line bg-surface shadow-xs']) }}>
<div class="border-b border-line px-6 py-4">
<h2 class="text-sm font-semibold text-ink">{{ __('hosts.takeover.how_title') }}</h2>
<p class="mt-1 text-sm text-muted">{{ __('hosts.takeover.how_sub') }}</p>
</div>
<ol class="divide-y divide-line">
@foreach ($steps as $number => $step)
@php
$isCurrent = $number === $current;
$isPast = $number < $current;
@endphp
<li class="flex gap-3 px-6 py-4 {{ $isCurrent ? 'bg-info-bg' : '' }}">
{{-- Die Nummer trägt den Zustand, nicht ein Icon daneben: eine
Ziffer, die man mit dem Blick abzählen kann, ist an dieser
Stelle mehr wert als ein Häkchen. --}}
<span class="mt-0.5 flex size-6 shrink-0 items-center justify-center rounded-full border text-xs font-bold
{{ $isPast ? 'border-success-border bg-success-bg text-success' : ($isCurrent ? 'border-info-border bg-surface text-info' : 'border-line text-muted') }}">
{{ $number }}
</span>
<div class="min-w-0 flex-1">
<div class="flex flex-wrap items-center gap-2">
<h3 class="text-sm font-semibold {{ $isCurrent ? 'text-ink' : 'text-body' }}">{{ $step['title'] }}</h3>
@if ($number <= 2 && ! $done)
<x-ui.badge status="warning">{{ __('hosts.takeover.badge_before') }}</x-ui.badge>
@elseif ($isCurrent)
<x-ui.badge status="info">{{ __('hosts.takeover.badge_now') }}</x-ui.badge>
@endif
</div>
<p class="mt-1 text-sm text-muted">{{ $step['body'] }}</p>
@if ($number === 4 && $done)
<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>
</div>
@endif
@if ($number === 5)
<p class="mt-2 text-xs text-muted">{{ __('hosts.takeover.s5_hint', ['url' => $archiveUrl]) }}</p>
@endif
@if ($number === 6 && $hostUuid)
<div class="mt-3">
<a href="{{ route('admin.hosts.show', ['host' => $hostUuid]) }}" wire:navigate>
<x-ui.button variant="primary" size="sm">
<x-slot:icon><x-ui.icon name="activity" class="size-4" /></x-slot:icon>
{{ __('hosts.takeover.watch') }}
</x-ui.button>
</a>
</div>
@endif
</div>
</li>
@endforeach
</ol>
<div class="border-t border-line px-6 py-3">
<p class="text-xs text-muted">{{ __('hosts.takeover.footnote') }}</p>
</div>
</div>

View File

@ -0,0 +1,67 @@
@props([
// Der Schritt, an dem der Betreiber gerade steht: 3 solange das Formular
// offen ist, 4 sobald die Befehlszeile da ist.
'current' => 3,
])
{{-- Der Ablauf als schmale Schiene, nicht als Textwand.
Die erste Fassung war sechs Absätze übereinander, oberhalb des Formulars.
Sie beantwortete alles und zeigte nichts: der eigentliche Auftrag dieser
Seite vier Felder ausfüllen verschwand unter der Anleitung dazu.
Sechs Wortpaare untereinander beantworten die Frage, die man hier wirklich
hat („wo stehe ich, wie viel kommt noch"), auf einen Blick. Was ein Schritt
inhaltlich bedeutet, steht in der Zeile, in der er dran ist nicht sechsmal
im Voraus.
Dieselbe Form wie die Seitennavigation der Einstellungsseite: 232 px, ab
`lg` klebend, darunter waagerecht scrollbar. Ein zweites Muster für
dasselbe Problem wäre eine zweite Sprache. --}}
@php
$steps = [
1 => __('hosts.takeover.s1_label'),
2 => __('hosts.takeover.s2_label'),
3 => __('hosts.takeover.s3_label'),
4 => __('hosts.takeover.s4_label'),
5 => __('hosts.takeover.s5_label'),
6 => __('hosts.takeover.s6_label'),
];
@endphp
<nav {{ $attributes->merge(['class' => '-mx-4 shrink-0 overflow-x-auto px-4 lg:mx-0 lg:w-[232px] lg:overflow-visible lg:px-0 lg:sticky lg:top-8']) }}
aria-label="{{ __('hosts.takeover.rail_title') }}">
<p class="lbl hidden lg:block">{{ __('hosts.takeover.rail_title') }}</p>
<ol class="mt-2 flex gap-1.5 lg:flex-col lg:gap-0.5">
@foreach ($steps as $number => $label)
@php
$isCurrent = $number === $current;
$isPast = $number < $current;
@endphp
<li class="shrink-0">
<div @class([
'flex items-center gap-2.5 rounded px-2.5 py-2 text-sm whitespace-nowrap lg:whitespace-normal',
'bg-surface-2 font-semibold text-ink' => $isCurrent,
'text-muted' => ! $isCurrent,
])>
{{-- Die Ziffer trägt den Zustand. Ein Häkchen daneben wäre
ein zweites Zeichen für dieselbe Aussage, und die
Nummer kann man abzählen. --}}
<span @class([
'flex size-5 shrink-0 items-center justify-center rounded-full border text-[11px] font-bold tabular-nums',
'border-transparent bg-success text-on-accent' => $isPast,
'border-accent text-accent-text' => $isCurrent,
'border-line text-muted' => ! $isPast && ! $isCurrent,
])>{{ $number }}</span>
<span class="min-w-0">{{ $label }}</span>
</div>
</li>
@endforeach
</ol>
<p class="mt-4 hidden max-w-[232px] text-xs leading-relaxed text-muted lg:block">
{{ __('hosts.takeover.how_sub') }}
</p>
</nav>

View File

@ -0,0 +1,41 @@
@props(['archiveUrl' => ''])
{{-- Dieselben sechs Schritte, für die Stelle, an der noch nichts angelegt ist:
die Host-Liste. Dort ist es eine Auskunft und kein Ablauf also Zeilen mit
je einem Satz, nicht die Schiene mit Zustand.
Eine Zeile je Schritt, nach dem Muster von `x-ui.row`: links wozu, rechts
was. Die zwei Schritte beim Anbieter tragen eine Plakette, sonst liest sich
die Liste, als könne man oben anfangen und sich durcharbeiten und Schritt
2 ist eine halbe Stunde Wartezeit. --}}
@php
$steps = [
1 => [__('hosts.takeover.s1_title'), __('hosts.takeover.s1_body'), true],
2 => [__('hosts.takeover.s2_title'), __('hosts.takeover.s2_body'), true],
3 => [__('hosts.takeover.s3_title'), __('hosts.takeover.s3_body'), false],
4 => [__('hosts.takeover.s4_title'), __('hosts.takeover.s4_body'), false],
5 => [__('hosts.takeover.s5_title'), __('hosts.takeover.s5_body'), false],
6 => [__('hosts.takeover.s6_title'), __('hosts.takeover.s6_body'), false],
];
@endphp
<ol {{ $attributes->merge(['class' => 'divide-y divide-line']) }}>
@foreach ($steps as $number => [$title, $body, $atProvider])
<li class="grid gap-x-6 gap-y-1 px-6 py-3.5 sm:grid-cols-[minmax(0,220px)_minmax(0,1fr)] sm:items-baseline">
<div class="flex items-center gap-2.5">
<span class="flex size-5 shrink-0 items-center justify-center rounded-full border border-line text-[11px] font-bold tabular-nums text-muted">{{ $number }}</span>
<span class="text-sm font-medium text-ink">{{ $title }}</span>
@if ($atProvider)
<x-ui.badge status="warning">{{ __('hosts.takeover.badge_before') }}</x-ui.badge>
@endif
</div>
<p class="text-sm leading-relaxed text-muted">
{{ $body }}
@if ($number === 5 && $archiveUrl)
<span class="block pt-0.5 text-xs">{{ __('hosts.takeover.s5_hint', ['url' => $archiveUrl]) }}</span>
@endif
</p>
</li>
@endforeach
</ol>

View File

@ -1,50 +1,88 @@
<div class="mx-auto max-w-2xl space-y-5">
<div class="space-y-6">
{{-- Kopf wie auf der Einstellungsseite: Augenbraue, Titel, ein Satz
darunter. Die Seite hatte vorher einen Zurück-Link an dieser Stelle und
darunter eine sechsteilige Anleitung der Auftrag der Seite, vier
Felder auszufüllen, ging darin unter. --}}
<header class="animate-rise">
<p class="lbl">{{ __('hosts.takeover.eyebrow') }}</p>
<h1 class="mt-[7px] text-[23px] font-bold leading-[1.12] tracking-[-0.03em] text-ink min-[901px]:text-[30px]">
@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>
<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>
{{ __('hosts.create_title') }}
@else
{{ __('hosts.takeover.title', ['name' => $createdName]) }}
@endif
</h1>
<p class="mt-2 max-w-[76ch] text-sm leading-relaxed text-muted">
{{ $command === null ? __('hosts.create_sub') : __('hosts.takeover.subtitle') }}
</p>
</header>
<div class="flex flex-col gap-6 lg:flex-row lg:items-start lg:gap-8">
{{-- Der Ablauf als Schiene, nicht als Wand. Sie beantwortet „wo stehe
ich" auf einen Blick und überlässt dem Hauptteil die Arbeit. --}}
<x-admin.takeover-rail :current="$command === null ? 3 : 4"
class="animate-rise [animation-delay:40ms]" />
<div class="min-w-0 flex-1 space-y-8 lg:max-w-[820px]">
@if ($command === null)
@if ($missingSettings)
<x-ui.alert variant="warning" class="animate-rise">
<x-ui.alert variant="warning" class="animate-rise [animation-delay:60ms]">
<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
{{-- Die Anleitung steht VOR dem Formular, weil ihre ersten beiden
Schritte beim Anbieter passieren und vor dem Anlegen erledigt sein
müssen. Wer sie erst danach liest, hat den Einmal-Code schon und
dessen Frist läuft. --}}
<x-admin.takeover-guide :archive-url="$archiveUrl" class="animate-rise" />
{{-- Die zwei Schritte beim Anbieter. Sie stehen VOR dem
Formular, weil sie vorher erledigt sein müssen: der
Einmal-Code beginnt mit dem Speichern abzulaufen. --}}
<section class="animate-rise [animation-delay:60ms]">
<h2 class="text-md font-bold tracking-[-0.01em] text-ink">{{ __('hosts.takeover.requirements_title') }}</h2>
<p class="mt-1 max-w-[70ch] text-sm leading-relaxed text-muted">{{ __('hosts.takeover.requirements_sub') }}</p>
<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 />
<x-ui.panel class="mt-4">
<x-ui.row :label="__('hosts.takeover.s1_title')">
<p class="text-sm leading-relaxed text-muted">{{ __('hosts.takeover.s1_body') }}</p>
</x-ui.row>
<x-ui.row :label="__('hosts.takeover.s2_title')">
<p class="text-sm leading-relaxed text-muted">{{ __('hosts.takeover.s2_body') }}</p>
</x-ui.row>
</x-ui.panel>
</section>
<div class="space-y-1.5">
<label for="datacenter" class="block text-sm font-medium text-body">{{ __('hosts.field.datacenter') }}</label>
<form wire:submit="save" class="animate-rise [animation-delay:80ms]">
<h2 class="text-md font-bold tracking-[-0.01em] text-ink">{{ __('hosts.takeover.form_title') }}</h2>
<p class="mt-1 max-w-[70ch] text-sm leading-relaxed text-muted">{{ __('hosts.takeover.form_sub') }}</p>
<x-ui.panel class="mt-4">
<x-ui.row :label="__('hosts.field.name')" :hint="__('hosts.field.name_hint')" for="name">
<x-ui.input name="name" wire:model="name" autofocus />
</x-ui.row>
<x-ui.row :label="__('hosts.field.datacenter')" for="datacenter">
<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. --}}
{{-- 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>
@error('datacenter') <p class="mt-1 text-xs text-danger">{{ $message }}</p> @enderror
</x-ui.row>
<x-ui.input name="public_ip" wire:model="public_ip" :label="__('hosts.field.public_ip')" inputmode="numeric" placeholder="203.0.113.10" />
<x-ui.row :label="__('hosts.field.public_ip')" for="public_ip">
<x-ui.input name="public_ip" wire:model="public_ip" inputmode="numeric" placeholder="203.0.113.10" />
</x-ui.row>
<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" />
<x-ui.row :label="__('hosts.field.root_password')" :hint="__('hosts.field.root_password_hint')" for="root_password">
<x-ui.input name="root_password" wire:model="root_password" type="password" autocomplete="off" />
</x-ui.row>
</x-ui.panel>
<div class="flex items-center justify-end gap-3 pt-1">
<div class="mt-4 flex items-center justify-end gap-3">
<a href="{{ route('admin.hosts') }}" wire:navigate>
<x-ui.button variant="secondary" type="button">{{ __('hosts.cancel') }}</x-ui.button>
</a>
@ -55,18 +93,61 @@
</div>
</form>
@else
{{-- Der Host steht. Dieselbe Anleitung, jetzt mit der Befehlszeile an
Schritt 4 und ohne Formular, weil es nichts mehr auszufüllen gibt. --}}
<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>
{{-- Ab hier ist die Befehlszeile die Hauptsache. Sie bekommt die
volle Breite und steht ganz oben; alles andere ordnet sich
unter. --}}
<section class="animate-rise [animation-delay:60ms]">
<h2 class="text-md font-bold tracking-[-0.01em] text-ink">{{ __('hosts.takeover.command_title') }}</h2>
<p class="mt-1 max-w-[70ch] text-sm leading-relaxed text-muted">{{ __('hosts.takeover.command_sub') }}</p>
<div x-data="{ copied: false }" class="mt-4 overflow-hidden rounded-lg border border-accent-border bg-surface shadow-xs">
<div class="flex items-center justify-between gap-3 border-b border-line bg-accent-subtle px-4 py-2.5">
<p class="min-w-0 text-xs font-medium text-accent-text">{{ __('hosts.takeover.once_title') }}</p>
<button type="button"
class="inline-flex shrink-0 items-center gap-1.5 rounded border border-line bg-surface px-2.5 py-1 text-xs font-medium text-body transition hover:text-ink"
x-on:click="navigator.clipboard.writeText($refs.command.textContent.trim()); 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>
<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>
{{-- 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-64 overflow-y-auto px-4 py-3.5 font-mono text-xs leading-relaxed text-ink break-all whitespace-pre-wrap"
x-ref="command">{{ $command }}</pre>
</div>
<x-admin.takeover-guide :command="$command" :archive-url="$archiveUrl" :host-uuid="$createdUuid" class="animate-rise" />
<p class="mt-2 text-xs leading-relaxed text-muted">{{ __('hosts.takeover.once_body') }}</p>
</section>
<section class="animate-rise [animation-delay:80ms]">
<h2 class="text-md font-bold tracking-[-0.01em] text-ink">{{ __('hosts.takeover.after_title') }}</h2>
<x-ui.panel class="mt-4">
<x-ui.row :label="__('hosts.takeover.s5_title')">
<p class="text-sm leading-relaxed text-muted">{{ __('hosts.takeover.s5_body') }}</p>
<p class="mt-1 text-xs leading-relaxed text-muted">{{ __('hosts.takeover.s5_hint', ['url' => $archiveUrl]) }}</p>
</x-ui.row>
<x-ui.row :label="__('hosts.takeover.s6_title')">
<p class="text-sm leading-relaxed text-muted">{{ __('hosts.takeover.s6_body') }}</p>
<div class="mt-3">
<a href="{{ route('admin.hosts.show', ['host' => $createdUuid]) }}" wire:navigate>
<x-ui.button variant="primary" size="sm">
<x-slot:icon><x-ui.icon name="activity" class="size-4" /></x-slot:icon>
{{ __('hosts.takeover.watch') }}
</x-ui.button>
</a>
</div>
</x-ui.row>
</x-ui.panel>
</section>
@endif
<p class="max-w-[76ch] text-xs leading-relaxed text-muted animate-rise [animation-delay:100ms]">
{{ __('hosts.takeover.footnote') }}
</p>
</div>
</div>
</div>

View File

@ -14,14 +14,16 @@
hinzufügen" klickt, hat den Server womöglich noch gar nicht bestellt.
Zusammengeklappt, damit sie die Liste nicht verdrängt aber auffindbar,
ohne dass man vorher etwas anlegen muss. --}}
<details class="group rounded-lg border border-line bg-surface shadow-xs animate-rise [animation-delay:20ms]">
<details class="group overflow-hidden rounded-lg border border-line bg-surface shadow-xs animate-rise [animation-delay:20ms]">
<summary class="flex cursor-pointer list-none items-center justify-between gap-3 px-6 py-3">
<span class="flex items-center gap-2 text-sm font-medium text-body">
<x-ui.icon name="life-buoy" class="size-4" />{{ __('hosts.takeover.how_title') }}
<span class="flex items-center gap-2.5 text-sm font-medium text-body">
<x-ui.icon name="life-buoy" class="size-4" />
{{ __('hosts.takeover.how_title') }}
<span class="hidden text-xs font-normal text-muted sm:inline">· {{ __('hosts.takeover.how_sub') }}</span>
</span>
<x-ui.icon name="chevron-down" class="size-4 shrink-0 text-muted transition group-open:rotate-180" />
</summary>
<x-admin.takeover-guide :archive-url="$archiveUrl" class="rounded-none border-0 border-t border-line shadow-none" />
<x-admin.takeover-steps :archive-url="$archiveUrl" class="border-t border-line" />
</details>
{{-- Filter bar scales to many hosts (search + datacenter + status). --}}

View File

@ -25,13 +25,19 @@ function guidePage(string $component): \Livewire\Features\SupportTesting\Testabl
* Zwischenablage und dessen 24-Stunden-Frist läuft bereits.
*/
it('explains the whole procedure before anything is created', function () {
guidePage(HostCreate::class)
->assertSee(__('hosts.takeover.s1_title'))
->assertSee(__('hosts.takeover.s2_title'))
->assertSee(__('hosts.takeover.s3_title'))
->assertSee(__('hosts.takeover.s4_title'))
->assertSee(__('hosts.takeover.s5_title'))
->assertSee(__('hosts.takeover.s6_title'));
$page = guidePage(HostCreate::class);
// Alle sechs, als Schiene — kurze Beschriftungen, damit die Seite eine
// Seite bleibt und keine Textwand wird. Das war der zweite Fund: sechs
// Absätze übereinander beantworteten alles und zeigten nichts.
foreach (range(1, 6) as $step) {
$page->assertSee(__("hosts.takeover.s{$step}_label"));
}
// Die zwei beim Anbieter ausführlich, weil sie VOR dem Speichern erledigt
// sein müssen und nur hier erklärt werden.
$page->assertSee(__('hosts.takeover.s1_body'))
->assertSee(__('hosts.takeover.s2_body'));
});
/**
@ -61,7 +67,10 @@ 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('files.clupilot.test/bootstrap.tar.gz');
// Auf der Liste, wo die Anleitung Auskunft gibt. Auf der Anlegen-Seite
// steht die Adresse erst NACH dem Speichern: dort ist sie Teil der
// Befehlszeile und keine Angabe für sich, und vorher wäre sie eine
// Einzelheit mehr auf einer Seite, die vier Felder ausgefüllt haben will.
guidePage(Hosts::class)->assertSee('files.clupilot.test/bootstrap.tar.gz');
});

View File

@ -118,3 +118,32 @@ it('falls back to the portal host until the files host exists', function () {
expect(HostTakeoverCommand::archiveUrl())
->toBe('https://app.clupilot.com/bootstrap.tar.gz');
});
/**
* Der Fund, der diesen Test nötig gemacht hat.
*
* `PublicSiteGate` hängt an der ganzen `web`-Gruppe. Solange die Website
* verborgen ist, bekäme ein Server im Rettungssystem die 503-Platzhalterseite
* und schöbe sie in `tar xz` der Betreiber sähe einen Entpackfehler auf einer
* Maschine, an die er nur noch über die Anbieterkonsole kommt.
*/
it('serves downloads even while the site is hidden', function () {
App\Support\Settings::set('site.public', false);
// Von einer fremden Adresse, denn genau die hat ein Server im
// Rettungssystem: 127.0.0.1 steht in TRUSTED_RANGES und käme ohnehin durch,
// der Test bewiese dann nichts.
$vonAussen = ['REMOTE_ADDR' => '203.0.113.77'];
$this->withServerVariables($vonAussen)
->get('http://files.clupilot.test/bootstrap.tar.gz?code='.issuedCode())->assertOk();
file_put_contents($this->publicDir.'/tom-2026-01.pdf', '%PDF-1.4');
$this->withServerVariables($vonAussen)
->get('http://files.clupilot.test/tom-2026-01.pdf')->assertOk();
unlink($this->publicDir.'/tom-2026-01.pdf');
// Und die Website selbst bleibt für dieselbe Adresse verborgen — sonst
// hätte die Ausnahme mehr aufgemacht als sie sollte.
$this->withServerVariables($vonAussen)->get('http://localhost/')->assertStatus(503);
});