Compare commits
51 Commits
ecfced9257
...
1e22d66639
| Author | SHA1 | Date |
|---|---|---|
|
|
1e22d66639 | |
|
|
447d6fe0f3 | |
|
|
ba4996f316 | |
|
|
2194ab8079 | |
|
|
903ebdd2b2 | |
|
|
668ed67de4 | |
|
|
f45f290f00 | |
|
|
5d25018cc6 | |
|
|
a25e9c2fe6 | |
|
|
c345f21e7d | |
|
|
9e485efe87 | |
|
|
e9b2862331 | |
|
|
f214713113 | |
|
|
0f563545fd | |
|
|
0b26e45147 | |
|
|
8f943f3d1b | |
|
|
25bcab345d | |
|
|
af284d7d26 | |
|
|
61c2501136 | |
|
|
fe07fa2e77 | |
|
|
32016d9b80 | |
|
|
c0cd367a0e | |
|
|
0f1f397823 | |
|
|
880b5f1998 | |
|
|
2d915be003 | |
|
|
786318b6d4 | |
|
|
26cf1a72a0 | |
|
|
823eeaf413 | |
|
|
5094f70c19 | |
|
|
507c976035 | |
|
|
49d528dbea | |
|
|
e83b17ec37 | |
|
|
fe1110de71 | |
|
|
96f171b3b3 | |
|
|
494a3a817e | |
|
|
6ca3e6ef4a | |
|
|
8321e825eb | |
|
|
2eff3a5a3c | |
|
|
0f4226c488 | |
|
|
325cee50ca | |
|
|
b1e5bf8023 | |
|
|
d9c0ad0e4a | |
|
|
a1e3484fba | |
|
|
32a8bd9a88 | |
|
|
ac44717d1b | |
|
|
727f2e41db | |
|
|
ecf948f30f | |
|
|
59e8689630 | |
|
|
bc6eee4dc3 | |
|
|
3cf17e5cb0 | |
|
|
88d6dc620b |
|
|
@ -94,7 +94,11 @@ MAIL_FROM_NAME=CluPilot
|
|||
# ── Stripe (payments) ────────────────────────────────────────────────────
|
||||
# WEBHOOK_SECRET is READ BY CODE today (signature check on /webhooks/stripe).
|
||||
# Outside local/testing a missing secret makes the webhook fail closed.
|
||||
# Stripe hands out a SEPARATE signing secret per operating mode (test/live) —
|
||||
# fill in the one matching each. Which one applies follows platform.mode
|
||||
# (App\Support\StripeWebhookSecret), not this file.
|
||||
STRIPE_WEBHOOK_SECRET=
|
||||
STRIPE_WEBHOOK_SECRET_TEST=
|
||||
# Not read yet — needed once the Checkout flow is built (currently the portal
|
||||
# only records a purchase intent):
|
||||
STRIPE_KEY=
|
||||
|
|
|
|||
|
|
@ -13,6 +13,8 @@ use App\Services\Billing\AdoptStripeProduct;
|
|||
use App\Services\Billing\PlanPrices;
|
||||
use App\Services\Billing\TaxTreatment;
|
||||
use App\Services\Stripe\StripeClient;
|
||||
use App\Support\OperatingMode;
|
||||
use App\Support\StripeCatalogueMode;
|
||||
use Illuminate\Console\Command;
|
||||
|
||||
/**
|
||||
|
|
@ -61,6 +63,14 @@ use Illuminate\Console\Command;
|
|||
* Product for it would be a price list entry for something that may never
|
||||
* exist.
|
||||
*
|
||||
* **A run belongs to ONE Stripe account, and it says which.** Products and
|
||||
* Prices are account-bound, and the operating mode picks the key — so the mode
|
||||
* a run worked in is recorded (App\Support\StripeCatalogueMode) and a run into
|
||||
* a catalogue that belongs to the other account is refused rather than allowed
|
||||
* to skip its way to "already in step". Without that, switching to live left
|
||||
* every stored id pointing at test objects while the readiness page went on
|
||||
* reporting a synced catalogue, and the first real order met "No such price".
|
||||
*
|
||||
* **Modules were missing from this entirely**, which is why a booked module was
|
||||
* charged in the month it was booked and never again: nothing in Stripe existed
|
||||
* to put on the subscription. They are pushed at today's catalogue price on both
|
||||
|
|
@ -85,6 +95,39 @@ class SyncStripeCatalogue extends Command
|
|||
return self::FAILURE;
|
||||
}
|
||||
|
||||
if (StripeCatalogueMode::hasStoredObjects() && StripeCatalogueMode::belongsToAnotherMode()) {
|
||||
$this->error($this->staleCatalogueMessage());
|
||||
|
||||
// Auch der Trockenlauf endet hier, und mit FAILURE: er zählt, was
|
||||
// FEHLT, und in diesem Zustand fehlt nichts — jede Zeile trägt
|
||||
// schon eine ID, nur eine aus dem falschen Konto. „0 object(s)
|
||||
// would be created" wäre die beruhigende Antwort auf die
|
||||
// gefährliche Lage.
|
||||
return self::FAILURE;
|
||||
}
|
||||
|
||||
// VOR der Anlegeschleife, nicht danach. Hier ist bereits bewiesen, dass
|
||||
// entweder nichts liegt oder das Vorhandene dem aktiven Konto gehört —
|
||||
// der Zeitpunkt trägt die Aussage also genauso gut. Am Ende von
|
||||
// handle() trug er sie NICHT: createProduct() und ensure() werfen
|
||||
// ungefangen, und ein Lauf, der nach dem ersten angelegten Objekt
|
||||
// stirbt, hinterließ IDs ohne Herkunft. Der nächste Lauf hielt das für
|
||||
// ein fremdes Konto und trug dem Betreiber auf, einen Katalog zu leeren,
|
||||
// an dem laufende Verträge abgerechnet werden — statt schlicht
|
||||
// wiederaufzusetzen, wofür die Idempotenzschlüssel unten gebaut sind.
|
||||
if (! $dryRun) {
|
||||
if (StripeCatalogueMode::recorded() === null && StripeCatalogueMode::hasStoredObjects()) {
|
||||
$this->line(' note taking up a catalogue whose account was never recorded; nothing is replaced.');
|
||||
}
|
||||
|
||||
StripeCatalogueMode::record();
|
||||
}
|
||||
|
||||
// Beide Seiten setzen hier an, und beide werden gebraucht.
|
||||
// Die Modus-Sperre oben steht zuerst, weil sie VERWEIGERT: sie darf
|
||||
// nicht hinter etwas laufen, das schon Zustand aufgebaut hat. Die
|
||||
// Zähler darunter müssen nur vor der Anlegeschleife stehen, und das
|
||||
// tun sie hier auch.
|
||||
// Resolved once, as singletons: each counts across the WHOLE run, and a
|
||||
// count taken before ensure()/the adoption call runs can never itself
|
||||
// distinguish "created" from "adopted" — see AdoptStripePrice.
|
||||
|
|
@ -207,6 +250,40 @@ class SyncStripeCatalogue extends Command
|
|||
return self::SUCCESS;
|
||||
}
|
||||
|
||||
/**
|
||||
* Why a run is refused outright rather than left to skip its way through.
|
||||
*
|
||||
* A Stripe Product and a Stripe Price belong to the ACCOUNT that issued
|
||||
* them, and a test account and a live account are two accounts. This
|
||||
* command skips every row that already carries an id (PlanPrices::inStep()
|
||||
* asks the register, not Stripe), so a run after the switch would report
|
||||
* "already in step" and change nothing — while recording that the
|
||||
* catalogue now belongs to the account it never touched.
|
||||
*
|
||||
* The registers have to go too, not only the two columns: inStep() takes
|
||||
* a registered row as proof on its own for the reverse-charge half, so a
|
||||
* cleared pointer with the register left behind would leave exactly that
|
||||
* half pointing at the old account, and nothing would say so.
|
||||
*
|
||||
* Only reached where the OTHER account is established fact. A catalogue
|
||||
* whose account was never recorded is a different state with a different
|
||||
* cure — see StripeCatalogueMode::belongsToAnotherMode(). Telling that
|
||||
* operator to clear anything would be telling them to delete a catalogue
|
||||
* their live contracts bill on.
|
||||
*/
|
||||
private function staleCatalogueMessage(): string
|
||||
{
|
||||
return sprintf(
|
||||
'The stored catalogue was created in the [%s] account; this installation is now in [%s] mode. '
|
||||
.'Stripe objects belong to the account that issued them, and this command skips every row that '
|
||||
.'already carries an id — so it cannot repair this by running again. Clear '
|
||||
.'plan_families.stripe_product_id, plan_prices.stripe_price_id and the stripe_plan_prices / '
|
||||
.'stripe_addon_prices registers first, then run it. Nothing was created.',
|
||||
StripeCatalogueMode::recorded()?->value,
|
||||
OperatingMode::current()->value,
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* The Stripe Prices one priced catalogue row is sold on, brought into step.
|
||||
*
|
||||
|
|
|
|||
|
|
@ -0,0 +1,20 @@
|
|||
<?php
|
||||
|
||||
namespace App\Exceptions;
|
||||
|
||||
use RuntimeException;
|
||||
|
||||
/**
|
||||
* Der aktive Betriebsmodus hat keinen Stripe-Schlüssel.
|
||||
*
|
||||
* Eine eigene Klasse, damit Aufrufer und Tests genau diesen Fall greifen können
|
||||
* statt „irgendetwas ging schief" — und damit die Kasse ihn in einen Satz
|
||||
* übersetzen kann, den ein Kunde lesen kann.
|
||||
*/
|
||||
class StripeNotConfigured extends RuntimeException
|
||||
{
|
||||
public static function forMode(string $mode): self
|
||||
{
|
||||
return new self("No Stripe secret is stored for the [{$mode}] operating mode.");
|
||||
}
|
||||
}
|
||||
|
|
@ -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();
|
||||
}
|
||||
}
|
||||
|
|
@ -4,6 +4,7 @@ namespace App\Http\Controllers;
|
|||
|
||||
use App\Actions\ApplyStripeBillingEvent;
|
||||
use App\Actions\StartCustomerProvisioning;
|
||||
use App\Support\StripeWebhookSecret;
|
||||
use Illuminate\Http\JsonResponse;
|
||||
use Illuminate\Http\Request;
|
||||
|
||||
|
|
@ -20,7 +21,7 @@ class StripeWebhookController extends Controller
|
|||
ApplyStripeBillingEvent $billing,
|
||||
): JsonResponse {
|
||||
$payload = $request->getContent();
|
||||
$secret = (string) config('services.stripe.webhook_secret');
|
||||
$secret = StripeWebhookSecret::current();
|
||||
|
||||
if (blank($secret)) {
|
||||
// Fail closed: an unconfigured secret must not authorize provisioning
|
||||
|
|
|
|||
|
|
@ -0,0 +1,46 @@
|
|||
<?php
|
||||
|
||||
namespace App\Livewire\Admin;
|
||||
|
||||
use App\Support\OperatingMode;
|
||||
use LivewireUI\Modal\ModalComponent;
|
||||
|
||||
/**
|
||||
* Confirmation before the operating mode changes (R23).
|
||||
*
|
||||
* Wortgleiches Muster zu ConfirmSaveSecret: dieses Modal mutiert nichts selbst
|
||||
* — es ruft `OperatingMode::set()` nirgends auf. Bestätigen löst nur ein
|
||||
* Ereignis aus, das Integrations::switchMode() auffängt; deren eigene Sperre
|
||||
* (secrets.manage + kürzlich bestätigtes Passwort) bleibt die einzige Stelle,
|
||||
* die tatsächlich etwas schreibt. Ein Modus, der hier gefälscht hereinkäme,
|
||||
* muss trotzdem switchMode()s eigenes tryFrom() und guardSecrets() passieren.
|
||||
*/
|
||||
class ConfirmSwitchMode extends ModalComponent
|
||||
{
|
||||
public string $mode;
|
||||
|
||||
public function mount(string $mode): void
|
||||
{
|
||||
$this->authorize('secrets.manage');
|
||||
|
||||
// Ein Wert aus dem Aufrufort dieses Modals — dieselbe Prüfung, die
|
||||
// switchMode() gleich noch einmal macht: ein unbekannter Modus 404t
|
||||
// hier, statt eine Bestätigung für etwas zu zeigen, das nachher
|
||||
// ohnehin still nichts tut.
|
||||
abort_if(OperatingMode::tryFrom($mode) === null, 404);
|
||||
|
||||
$this->mode = $mode;
|
||||
}
|
||||
|
||||
public function confirm(): void
|
||||
{
|
||||
$this->authorize('secrets.manage');
|
||||
$this->dispatch('mode-switch-confirmed', mode: $this->mode);
|
||||
$this->closeModal();
|
||||
}
|
||||
|
||||
public function render()
|
||||
{
|
||||
return view('livewire.admin.confirm-switch-mode');
|
||||
}
|
||||
}
|
||||
|
|
@ -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(),
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -53,6 +53,10 @@ class Hosts extends Component
|
|||
'datacenters' => Datacenter::query()->orderBy('name')->get(),
|
||||
'statuses' => ['pending', 'onboarding', 'active', 'error', 'disabled'],
|
||||
'total' => Host::query()->count(),
|
||||
// Für die aufklappbare Anleitung: dieselbe Adresse, die später in
|
||||
// der Befehlszeile steht, damit hier nichts anderes behauptet wird
|
||||
// als dort getan wird.
|
||||
'archiveUrl' => \App\Support\HostTakeoverCommand::archiveUrl(),
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -9,6 +9,7 @@ use App\Services\Env\InvalidEnvContentException;
|
|||
use App\Services\Mail\InboundMailbox;
|
||||
use App\Services\Mail\InboundMailStatus;
|
||||
use App\Services\Secrets\SecretVault;
|
||||
use App\Support\OperatingMode;
|
||||
use App\Support\ProvisioningSettings;
|
||||
use App\Support\Settings;
|
||||
use Illuminate\Support\Facades\Artisan;
|
||||
|
|
@ -190,6 +191,34 @@ class Integrations extends Component
|
|||
}
|
||||
}
|
||||
|
||||
// ---- The operating mode — governs every vault entry below it. ----
|
||||
|
||||
/**
|
||||
* Den Betriebsmodus umlegen.
|
||||
*
|
||||
* Hinter derselben Sperre wie der Tresor — `secrets.manage` plus bestätigtes
|
||||
* Passwort — weil der Wechsel auf Live der Moment ist, ab dem echtes Geld
|
||||
* fließt. Das ist keine Umschaltfläche zum Danebenklicken.
|
||||
*/
|
||||
#[On('mode-switch-confirmed')]
|
||||
public function switchMode(string $mode): void
|
||||
{
|
||||
$this->guardSecrets();
|
||||
|
||||
// Ein String aus dem Browser. tryFrom, nicht from.
|
||||
$target = OperatingMode::tryFrom($mode);
|
||||
|
||||
if ($target === null) {
|
||||
return;
|
||||
}
|
||||
|
||||
OperatingMode::set($target);
|
||||
|
||||
$this->dispatch('notify', message: __('integrations.mode_switched', [
|
||||
'mode' => __('readiness.mode.'.$target->value),
|
||||
]));
|
||||
}
|
||||
|
||||
// ---- Plain settings — App\Support\Settings, hosts.manage, no password. ----
|
||||
|
||||
public function saveInfra(): void
|
||||
|
|
@ -493,6 +522,7 @@ class Integrations extends Component
|
|||
'canInfra' => $canInfra,
|
||||
'unlocked' => $unlocked,
|
||||
'usable' => $vault->isUsable(),
|
||||
'mode' => OperatingMode::current(),
|
||||
'restart' => $restart,
|
||||
'entries' => collect(SecretVault::REGISTRY)
|
||||
->map(fn (array $meta, string $key) => [
|
||||
|
|
|
|||
|
|
@ -3,15 +3,18 @@
|
|||
namespace App\Livewire\Admin;
|
||||
|
||||
use App\Livewire\Concerns\BuildsRunSteps;
|
||||
use App\Mail\Transport\MailboxTransport;
|
||||
use App\Models\Customer;
|
||||
use App\Models\Host;
|
||||
use App\Services\Provisioning\HostCapacity;
|
||||
use App\Support\AdminArea;
|
||||
use App\Support\Settings;
|
||||
use App\Models\Instance;
|
||||
use App\Models\MonitoringTarget;
|
||||
use App\Models\ProvisioningRun;
|
||||
use App\Models\Subscription;
|
||||
use App\Services\Billing\PlanCatalogue;
|
||||
use App\Services\Provisioning\HostCapacity;
|
||||
use App\Support\AdminArea;
|
||||
use App\Support\Readiness;
|
||||
use App\Support\Settings;
|
||||
use Illuminate\Support\Carbon;
|
||||
use Illuminate\Support\Number;
|
||||
use Livewire\Attributes\Layout;
|
||||
|
|
@ -234,7 +237,7 @@ class Overview extends Component
|
|||
$capacity = app(HostCapacity::class);
|
||||
|
||||
try {
|
||||
$sellable = app(\App\Services\Billing\PlanCatalogue::class)->sellable();
|
||||
$sellable = app(PlanCatalogue::class)->sellable();
|
||||
} catch (\Throwable) {
|
||||
// A catalogue that cannot be read has its own alarm. It does not
|
||||
// get to take the console's front page down with it.
|
||||
|
|
@ -391,7 +394,7 @@ class Overview extends Component
|
|||
// reports success (MailboxTester builds its own transport on purpose), and
|
||||
// the mail sits in storage/logs. Somebody registering and waiting for a
|
||||
// verification mail has no way to find that out. Asked exactly that way.
|
||||
if (in_array(config('mail.default'), ['log', 'array', null], true)) {
|
||||
if (in_array(config('mail.default'), MailboxTransport::NON_DELIVERING, true)) {
|
||||
$notices[] = [
|
||||
'level' => 'warning',
|
||||
'text' => __('admin.notice.mail_not_delivering', ['mailer' => (string) (config('mail.default') ?? 'null')]),
|
||||
|
|
@ -420,6 +423,19 @@ class Overview extends Component
|
|||
];
|
||||
}
|
||||
|
||||
// What is not yet set up at all, as opposed to everything above (what
|
||||
// IS set up but currently unhealthy). Readiness::blocking() is the
|
||||
// same list App\Livewire\Admin\Readiness renders in full — this is
|
||||
// only the count and a link to it, not a second copy of the reasons.
|
||||
$blockingReadiness = count(Readiness::blocking());
|
||||
if ($blockingReadiness > 0) {
|
||||
$notices[] = [
|
||||
'level' => 'warning',
|
||||
'text' => trans_choice('admin.notice.readiness_blocking', $blockingReadiness, ['n' => $blockingReadiness]),
|
||||
'route' => 'admin.readiness',
|
||||
];
|
||||
}
|
||||
|
||||
return $notices;
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -0,0 +1,181 @@
|
|||
<?php
|
||||
|
||||
namespace App\Livewire\Admin;
|
||||
|
||||
use App\Services\Dns\DnsTokenCheck;
|
||||
use App\Services\Proxmox\VmTemplateCheck;
|
||||
use App\Services\Stripe\StripeCheck;
|
||||
use App\Services\Vpn\WireguardEndpointCheck;
|
||||
use App\Support\OperatingMode;
|
||||
use App\Support\Readiness as ReadinessRegistry;
|
||||
use App\Support\Readiness\Check;
|
||||
use App\Support\Settings;
|
||||
use Illuminate\Support\Carbon;
|
||||
use Illuminate\Support\Facades\Gate;
|
||||
use Livewire\Attributes\Layout;
|
||||
use Livewire\Component;
|
||||
|
||||
/**
|
||||
* The one page an operator setting up a live server goes through top to
|
||||
* bottom, until nothing blocking is left — App\Support\Readiness rendered,
|
||||
* with a button on the handful of checks that have to ask a real service
|
||||
* instead of just reading a field.
|
||||
*
|
||||
* Aliased on import (`ReadinessRegistry`) rather than `use App\Support\
|
||||
* Readiness;` bare: this class is ALSO named Readiness, in the same PHP
|
||||
* process, and an unaliased reference here would resolve to itself — which
|
||||
* has no byGroup()/blocking()/isReady() — rather than to the registry.
|
||||
*
|
||||
* mount() checks the same gate as Admin\Integrations
|
||||
* (Gate::any(['hosts.manage', 'secrets.manage'])), because this page is where
|
||||
* both halves it configures — App\Support\Settings and SecretVault — come
|
||||
* back together as one list of what is missing.
|
||||
*/
|
||||
#[Layout('layouts.admin')]
|
||||
class Readiness extends Component
|
||||
{
|
||||
/**
|
||||
* Prüfschlüssel → Klasse. Die Liste IST die Erlaubnis.
|
||||
*
|
||||
* runCheck() receives its argument from wire:click in the browser — a
|
||||
* string a stranger could type into devtools just as easily as an
|
||||
* operator clicking the real button. Without this fixed map, that
|
||||
* argument would be a way to instantiate and run ANY class through app().
|
||||
* Four entries: the three on-demand checks Task 9 built (each asks a real
|
||||
* service instead of only reading a field) plus StripeCheck, which
|
||||
* already had this exact run() shape from Admin\Integrations' own
|
||||
* "Testen" button.
|
||||
*/
|
||||
private const RUNNABLE = [
|
||||
'provisioning.dns_token' => DnsTokenCheck::class,
|
||||
'onboarding.wg_hub' => WireguardEndpointCheck::class,
|
||||
'provisioning.vm_template' => VmTemplateCheck::class,
|
||||
'billing.stripe_secret' => StripeCheck::class,
|
||||
];
|
||||
|
||||
/**
|
||||
* Check::key → the App\Support\Settings row it is written under.
|
||||
*
|
||||
* Display only, and carries no threshold of its own: whether a heartbeat
|
||||
* counts as fresh is decided exactly once, by
|
||||
* App\Support\Readiness\OperationChecks::isFresh(), and shows up here
|
||||
* only as the check's own `satisfied`/severity — this map exists solely
|
||||
* so heartbeatDisplay() knows which stored value to format for which
|
||||
* check. (A previous version of this comment claimed a staleness
|
||||
* threshold was kept in sync with OperationChecks::STALE_AFTER_MINUTES —
|
||||
* there is no such value here to keep in sync. CLAUDE.md's R19 names this
|
||||
* exact shape of mistake — a call that reads as an assurance and is not
|
||||
* one — as worse than no comment at all, because it stops the next
|
||||
* reader from checking for themselves.)
|
||||
*/
|
||||
private const HEARTBEAT_KEYS = [
|
||||
'operation.scheduler' => 'heartbeat.scheduler',
|
||||
'operation.queue_provisioning' => 'heartbeat.queue_provisioning',
|
||||
];
|
||||
|
||||
/**
|
||||
* The on-demand checks' own answers, keyed by Check::key.
|
||||
*
|
||||
* Never pre-filled in mount(): DnsTokenCheck writes a real TXT record to
|
||||
* the production DNS zone the moment it runs, so nothing here may run
|
||||
* except in direct response to an operator's own click.
|
||||
*
|
||||
* @var array<string, array<string, mixed>>
|
||||
*/
|
||||
public array $results = [];
|
||||
|
||||
public function mount(): void
|
||||
{
|
||||
abort_unless(Gate::any(['hosts.manage', 'secrets.manage']), 403);
|
||||
}
|
||||
|
||||
public function runCheck(string $key): void
|
||||
{
|
||||
$this->authorize('secrets.manage');
|
||||
|
||||
// Der Schlüssel kommt aus dem Browser. Ohne diese Zeile wäre das ein
|
||||
// Weg, beliebige Klassen aufzurufen.
|
||||
$class = self::RUNNABLE[$key] ?? null;
|
||||
|
||||
if ($class === null) {
|
||||
return;
|
||||
}
|
||||
|
||||
$this->results[$key] = app($class)->run();
|
||||
}
|
||||
|
||||
/**
|
||||
* Where an operator goes to actually fix ONE entry.
|
||||
*
|
||||
* Not always Admin\Integrations: six of the nine `tab` values used across
|
||||
* the five check groups name a DIFFERENT admin page entirely — 'hosts',
|
||||
* 'plans', 'datacenters', 'mail', 'templates' and 'company' are pages of
|
||||
* their own, not Integrations tabs, and forwarding them as `?tab=` would
|
||||
* either 404 or (since Integrations::mount() falls back silently to its
|
||||
* own first tab for an unrecognised value) land quietly on the wrong
|
||||
* section. Every OTHER `tab` value that exists in the five check group
|
||||
* files today (checked at the source, Fix-Runde) really IS one of
|
||||
* Integrations::TABS — nine of them were not, until that same Fix-Runde
|
||||
* corrected them there rather than papering over it here (see
|
||||
* BillingChecks/OnboardingChecks/ProvisioningChecks/DeliveryChecks). The
|
||||
* `default` arm below is therefore a pure safety net for a value nobody
|
||||
* currently produces, not a route any check relies on — kept so a future
|
||||
* typo degrades to the Integrations landing tab instead of an
|
||||
* UnhandledMatchError taking the whole page down.
|
||||
*/
|
||||
public function checkUrl(Check $check): string
|
||||
{
|
||||
if (in_array($check->tab, Integrations::TABS, true)) {
|
||||
return route('admin.integrations', ['tab' => $check->tab]);
|
||||
}
|
||||
|
||||
return match ($check->tab) {
|
||||
'hosts' => route('admin.hosts'),
|
||||
'plans' => route('admin.plans'),
|
||||
'datacenters' => route('admin.datacenters'),
|
||||
'mail' => route('admin.mail'),
|
||||
'templates' => route('admin.templates'),
|
||||
// Company details AND the invoice series both live on
|
||||
// Admin\Finance — there is no dedicated "company" page.
|
||||
'company' => route('admin.finance'),
|
||||
default => route('admin.integrations'),
|
||||
};
|
||||
}
|
||||
|
||||
public function render()
|
||||
{
|
||||
return view('livewire.admin.readiness', [
|
||||
'groups' => ReadinessRegistry::byGroup(),
|
||||
'blockingCount' => count(ReadinessRegistry::blocking()),
|
||||
'ready' => ReadinessRegistry::isReady(),
|
||||
'mode' => OperatingMode::current(),
|
||||
'runnable' => array_keys(self::RUNNABLE),
|
||||
'heartbeats' => collect(self::HEARTBEAT_KEYS)
|
||||
->map(fn (string $settingsKey) => $this->heartbeatDisplay($settingsKey))
|
||||
->all(),
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* The heartbeat's own stored moment, in the operator's zone (R19) — or
|
||||
* null for "nothing stored" AND for "unreadable", the same forgiving
|
||||
* either-way OperationChecks::isFresh() already uses. A readiness page
|
||||
* whose only job is to calmly report what is missing must not itself go
|
||||
* down over a garbled timestamp (see that method's own docblock for the
|
||||
* incident this already caused once, inside Readiness::all() itself).
|
||||
*/
|
||||
private function heartbeatDisplay(string $settingsKey): ?string
|
||||
{
|
||||
$raw = Settings::get($settingsKey);
|
||||
|
||||
if (blank($raw)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
try {
|
||||
return Carbon::parse($raw)->local()->isoFormat('DD.MM.YYYY HH:mm:ss');
|
||||
} catch (\Throwable) {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -17,6 +17,7 @@ use App\Services\Billing\PlanCatalogue;
|
|||
use App\Services\Billing\StorageAllowance;
|
||||
use App\Services\Billing\TaxTreatment;
|
||||
use App\Services\Provisioning\DiskUsageProbe;
|
||||
use App\Services\Secrets\SecretVault;
|
||||
use App\Services\Traffic\TrafficMeter;
|
||||
use Illuminate\Support\Collection;
|
||||
use Illuminate\Support\Facades\Cache;
|
||||
|
|
@ -60,6 +61,15 @@ class Billing extends Component
|
|||
*/
|
||||
public function purchase(string $type, ?string $key = null, int $quantity = 1): void
|
||||
{
|
||||
// Vor allem anderen: ohne Schlüssel des aktiven Modus entsteht kein
|
||||
// Auftrag. Nach der Zahlung wäre es zu spät, und ein halb angelegter
|
||||
// Auftrag ohne Stripe-Sitzung ist genau die Leiche, die niemand findet.
|
||||
if (blank(app(SecretVault::class)->get('stripe.secret'))) {
|
||||
$this->addError('purchase', __('billing.stripe_not_configured'));
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
$customer = $this->requireCustomer();
|
||||
if ($customer === null) {
|
||||
return;
|
||||
|
|
|
|||
|
|
@ -40,8 +40,14 @@ class MailboxTransport implements TransportInterface
|
|||
* not the exact string 'log'. `null` is here because a stored setting can
|
||||
* decode to it just as easily as to a real value (see the port note in
|
||||
* delegate()): an unconfigured mailer must fail SAFE, not fail open.
|
||||
*
|
||||
* Public: this is the one place that knows what "does not really send"
|
||||
* means, and App\Support\Readiness\DeliveryChecks reads it directly
|
||||
* rather than carrying a second, narrower copy of the same rule (a
|
||||
* `!== 'log'` check once passed under this suite's own MAIL_MAILER=array,
|
||||
* which delegate() below has always treated as just as inert as 'log').
|
||||
*/
|
||||
private const NON_DELIVERING = ['log', 'array', null];
|
||||
public const NON_DELIVERING = ['log', 'array', null];
|
||||
|
||||
private ?TransportInterface $delegate = null;
|
||||
|
||||
|
|
|
|||
|
|
@ -0,0 +1,39 @@
|
|||
<?php
|
||||
|
||||
namespace App\Provisioning\Jobs;
|
||||
|
||||
use App\Support\Settings;
|
||||
use Illuminate\Bus\Queueable;
|
||||
use Illuminate\Contracts\Queue\ShouldQueue;
|
||||
use Illuminate\Foundation\Bus\Dispatchable;
|
||||
use Illuminate\Queue\InteractsWithQueue;
|
||||
use Illuminate\Queue\SerializesModels;
|
||||
|
||||
/**
|
||||
* A sign of life from the provisioning worker.
|
||||
*
|
||||
* The scheduler enqueues it, the worker executes it — so it proves exactly what
|
||||
* the scheduler heartbeat (routes/console.php) does NOT prove: that someone is
|
||||
* actually pulling jobs off the `provisioning` queue. The two fail
|
||||
* independently, and "scheduler up, worker down" is the silent case: paid
|
||||
* orders pile up on the queue, and nothing reports anything, because the
|
||||
* scheduler itself is doing exactly what it is supposed to do.
|
||||
*
|
||||
* Written through Settings, not the cache: a heartbeat that vanishes on a Redis
|
||||
* restart would report an outage that never happened, which is worse than no
|
||||
* check at all — nobody trusts it after the second false alarm.
|
||||
*/
|
||||
class RecordProvisioningHeartbeat implements ShouldQueue
|
||||
{
|
||||
use Dispatchable, InteractsWithQueue, Queueable, SerializesModels;
|
||||
|
||||
public function __construct()
|
||||
{
|
||||
$this->onQueue('provisioning');
|
||||
}
|
||||
|
||||
public function handle(): void
|
||||
{
|
||||
Settings::set('heartbeat.queue_provisioning', now()->toIso8601String());
|
||||
}
|
||||
}
|
||||
|
|
@ -45,7 +45,17 @@ class RegisterHostDns extends HostStep
|
|||
// lock with only a candidate in hand let two concurrent onboardings pick
|
||||
// the same name and overwrite each other's record.
|
||||
$name = $host->dns_name ?: $this->reserveName($host);
|
||||
$fqdn = $name.'.node.'.config('provisioning.dns.zone', 'clupilot.com');
|
||||
// Die PLATTFORM-Zone, nicht die Kundenzone. Diese Zeile las bis hierher
|
||||
// `provisioning.dns.zone` — die Zone der Kundeninstanzen —, und auf
|
||||
// dieser Installation hieß ein Host damit `fsn-01.node.clupilot.cloud`.
|
||||
// Der Kopfkommentar oben, die Prüfregel in Datacenters und der Test in
|
||||
// ServicesTest sagen alle drei `.com`; nur diese Zeile sagte etwas
|
||||
// anderes. Die zwei Zonen sind laut OfficialDomains ausdrücklich
|
||||
// getrennt — eine Nextcloud ist fremde Software, bei der sich Fremde
|
||||
// anmelden, und sie teilt sich deshalb keinen Cookie-Geltungsbereich mit
|
||||
// dem Portal. Ein Hostname in dieser Zone hebt die Trennung nicht auf,
|
||||
// aber er stellt sie in Frage, und der nächste Griff daneben ist teurer.
|
||||
$fqdn = $name.'.node.'.config('provisioning.dns.platform_zone');
|
||||
|
||||
try {
|
||||
$this->dns->write($name, $fqdn, $host->wg_ip);
|
||||
|
|
|
|||
|
|
@ -0,0 +1,107 @@
|
|||
<?php
|
||||
|
||||
namespace App\Services\Dns;
|
||||
|
||||
use App\Services\Secrets\SecretVault;
|
||||
use App\Support\ProvisioningSettings;
|
||||
use Illuminate\Support\Facades\Http;
|
||||
use Illuminate\Support\Str;
|
||||
use Throwable;
|
||||
|
||||
/**
|
||||
* Whether the configured Hetzner DNS token can actually WRITE to the zone,
|
||||
* not merely read it.
|
||||
*
|
||||
* Listing the zone proves nothing: a read-only token lists it just as
|
||||
* cleanly as a read-write one, and looks identical in the console — it only
|
||||
* fails once a customer VM needs its A record, after the customer has
|
||||
* already paid. So this writes a throwaway TXT record and deletes it again
|
||||
* immediately; the write (and its rejection) is the only thing that actually
|
||||
* tells the two kinds of token apart.
|
||||
*
|
||||
* Deliberately does its own HTTP instead of going through HttpHetznerDnsClient:
|
||||
* that client always reads the STORED token from the vault, and this check
|
||||
* has to be able to test a candidate value before it is ever saved — exactly
|
||||
* the same reason StripeCheck does not go through HttpStripeClient.
|
||||
*/
|
||||
final class DnsTokenCheck
|
||||
{
|
||||
/** @return array<string, mixed> */
|
||||
public function run(?string $candidate = null): array
|
||||
{
|
||||
$token = $candidate ?: app(SecretVault::class)->get('dns.token');
|
||||
$zone = ProvisioningSettings::dnsZone();
|
||||
|
||||
if (blank($token) || blank($zone)) {
|
||||
return ['ok' => false, 'reason' => 'missing'];
|
||||
}
|
||||
|
||||
try {
|
||||
$zones = Http::withHeaders(['Auth-API-Token' => $token])->acceptJson()->timeout(15)
|
||||
->get('https://dns.hetzner.com/api/v1/zones');
|
||||
} catch (Throwable) {
|
||||
return ['ok' => false, 'reason' => 'unreachable'];
|
||||
}
|
||||
|
||||
if ($zones->status() === 401 || $zones->status() === 403) {
|
||||
return ['ok' => false, 'reason' => 'rejected'];
|
||||
}
|
||||
|
||||
$zoneId = collect($zones->json('zones') ?? [])->firstWhere('name', $zone)['id'] ?? null;
|
||||
|
||||
if ($zoneId === null) {
|
||||
return ['ok' => false, 'reason' => 'zone_not_found', 'zone' => $zone];
|
||||
}
|
||||
|
||||
// The actual point of this whole check. A read-only token gets this
|
||||
// far and looks in the console like a working one; it fails only when
|
||||
// a customer VM needs its A record — after payment.
|
||||
//
|
||||
// Guarded the same way as the GET above: this call is just as capable
|
||||
// of hitting a broken network as the first one, and "the network
|
||||
// died mid-write" must stay distinct from "the token cannot write"
|
||||
// (read_only, below) — both are failures, but only one of them says
|
||||
// anything about the token.
|
||||
try {
|
||||
$probe = Http::withHeaders(['Auth-API-Token' => $token])->acceptJson()->timeout(15)
|
||||
->post('https://dns.hetzner.com/api/v1/records', [
|
||||
'zone_id' => $zoneId,
|
||||
'type' => 'TXT',
|
||||
'name' => '_clupilot-write-probe-'.Str::lower(Str::random(12)),
|
||||
'value' => 'clupilot readiness probe',
|
||||
'ttl' => 60,
|
||||
]);
|
||||
} catch (Throwable) {
|
||||
return ['ok' => false, 'reason' => 'unreachable'];
|
||||
}
|
||||
|
||||
if (! $probe->successful()) {
|
||||
return ['ok' => false, 'reason' => 'read_only', 'status' => $probe->status()];
|
||||
}
|
||||
|
||||
// The write already proved the token can write — that is the whole
|
||||
// point of this check, and nothing past this line changes that
|
||||
// verdict. Always attempt to clean up, and guard the attempt itself:
|
||||
// a delete that throws is handled exactly like one that comes back
|
||||
// non-2xx, because both leave the same TXT record sitting in the
|
||||
// zone. Named in the result either way, so someone can remove it by
|
||||
// hand instead of a record nobody knows exists.
|
||||
$recordId = $probe->json('record.id');
|
||||
|
||||
try {
|
||||
$removed = Http::withHeaders(['Auth-API-Token' => $token])->acceptJson()->timeout(15)
|
||||
->delete('https://dns.hetzner.com/api/v1/records/'.$recordId)
|
||||
->successful();
|
||||
} catch (Throwable) {
|
||||
$removed = false;
|
||||
}
|
||||
|
||||
$result = ['ok' => true, 'reason' => 'writable', 'probe_removed' => $removed];
|
||||
|
||||
if (! $removed) {
|
||||
$result['leftover_record_id'] = $recordId;
|
||||
}
|
||||
|
||||
return $result;
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,104 @@
|
|||
<?php
|
||||
|
||||
namespace App\Services\Proxmox;
|
||||
|
||||
use App\Models\Host;
|
||||
use App\Models\PlanVersion;
|
||||
use Throwable;
|
||||
|
||||
/**
|
||||
* Whether every published plan's VM template actually EXISTS on a node, not
|
||||
* merely that a `template_vmid` is filled in somewhere.
|
||||
*
|
||||
* Asks the same question as the onboarding step VerifyVmTemplate, but over
|
||||
* ALL active hosts instead of one, and BEFORE a purchase instead of after:
|
||||
* ProvisioningChecks::vm_template (Task 8) is a field check only — it cannot
|
||||
* tell a `template_vmid` that was typed by mistake from one that is really
|
||||
* there, and asking Proxmox on every page load does not belong on a
|
||||
* readiness page. This is the on-demand button that actually asks.
|
||||
*/
|
||||
final class VmTemplateCheck
|
||||
{
|
||||
public function __construct(private ProxmoxClient $pve) {}
|
||||
|
||||
/** @return array<string, mixed> */
|
||||
public function run(): array
|
||||
{
|
||||
$required = PlanVersion::query()
|
||||
->whereNotNull('published_at')
|
||||
->whereNotNull('template_vmid')
|
||||
->where(fn ($q) => $q->whereNull('available_until')->orWhere('available_until', '>', now()))
|
||||
->pluck('template_vmid')->map(fn ($v) => (int) $v)->unique()->values()->all();
|
||||
|
||||
// A catalogue with no published version requires no template. The
|
||||
// same case VerifyVmTemplate explicitly lets through.
|
||||
if ($required === []) {
|
||||
return ['ok' => true, 'reason' => 'nothing_published'];
|
||||
}
|
||||
|
||||
$hosts = Host::query()->where('status', 'active')->whereNotNull('api_token_ref')->get();
|
||||
|
||||
if ($hosts->isEmpty()) {
|
||||
return ['ok' => false, 'reason' => 'no_usable_host'];
|
||||
}
|
||||
|
||||
$missing = [];
|
||||
$unverifiable = [];
|
||||
|
||||
foreach ($required as $vmid) {
|
||||
$found = false;
|
||||
$aHostFailedToAnswer = false;
|
||||
|
||||
foreach ($hosts as $host) {
|
||||
try {
|
||||
if ($this->pve->forHost($host)->vmExists($host->node ?? 'pve', $vmid)) {
|
||||
$found = true;
|
||||
break;
|
||||
}
|
||||
} catch (Throwable) {
|
||||
// A host that is not answering right now is not proof of
|
||||
// a missing template — it might be sitting on exactly
|
||||
// this host. Keep looking at the others; a later "yes"
|
||||
// still wins outright (see $found above).
|
||||
$aHostFailedToAnswer = true;
|
||||
|
||||
continue;
|
||||
}
|
||||
}
|
||||
|
||||
if ($found) {
|
||||
continue;
|
||||
}
|
||||
|
||||
// Two different verdicts, not one: every host answered "no" is a
|
||||
// confirmed gap (build the template); some host never answered
|
||||
// is merely unverified (ask again once it is back). Telling an
|
||||
// operator to build something that may already exist on a host
|
||||
// that is simply not responding right now sends them to fix the
|
||||
// wrong problem.
|
||||
if ($aHostFailedToAnswer) {
|
||||
$unverifiable[] = $vmid;
|
||||
} else {
|
||||
$missing[] = $vmid;
|
||||
}
|
||||
}
|
||||
|
||||
if ($missing !== []) {
|
||||
$result = ['ok' => false, 'reason' => 'template_missing', 'vmids' => $missing];
|
||||
|
||||
if ($unverifiable !== []) {
|
||||
// Named separately, never merged into vmids: one is
|
||||
// confirmed and actionable, the other merely unanswered.
|
||||
$result['unverifiable'] = $unverifiable;
|
||||
}
|
||||
|
||||
return $result;
|
||||
}
|
||||
|
||||
if ($unverifiable !== []) {
|
||||
return ['ok' => false, 'reason' => 'hosts_unreachable', 'vmids' => $unverifiable];
|
||||
}
|
||||
|
||||
return ['ok' => true, 'reason' => 'present'];
|
||||
}
|
||||
}
|
||||
|
|
@ -4,6 +4,7 @@ namespace App\Services\Secrets;
|
|||
|
||||
use App\Models\Operator;
|
||||
use App\Services\Stripe\StripeCheck;
|
||||
use App\Support\OperatingMode;
|
||||
use Illuminate\Contracts\Encryption\DecryptException;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use Illuminate\Support\Facades\Log;
|
||||
|
|
@ -79,6 +80,12 @@ final class SecretVault
|
|||
'label' => 'secrets.item.stripe_secret',
|
||||
'check' => StripeCheck::class,
|
||||
'env_key' => 'STRIPE_SECRET',
|
||||
// Kein Rückfall, in keine Richtung, und auch nicht auf die Umgebung.
|
||||
// Ohne das benutzt ein Testkauf bei fehlendem Testschlüssel still den
|
||||
// Live-Schlüssel und bucht echtes Geld ab, während die Konsole
|
||||
// „Testbetrieb aktiv" anzeigt. Jeder andere Eintrag hier bezeichnet
|
||||
// dasselbe Konto in beiden Modi; dieser nicht.
|
||||
'strict' => true,
|
||||
],
|
||||
'dns.token' => [
|
||||
'config' => 'provisioning.dns.token',
|
||||
|
|
@ -112,6 +119,10 @@ final class SecretVault
|
|||
/**
|
||||
* The stored value, or the configured one when nothing is stored.
|
||||
*
|
||||
* Signature unchanged from before the vault grew a test/live split — the
|
||||
* three callers (HttpStripeClient, StripeCheck, SshTraefikWriter) have no
|
||||
* business knowing which mode is active; that is resolved in here.
|
||||
*
|
||||
* Falls back only when there is genuinely no row — never on a decryption
|
||||
* failure. Falling back then would quietly resume using an old key from the
|
||||
* environment while the console shows the new one.
|
||||
|
|
@ -119,9 +130,15 @@ final class SecretVault
|
|||
public function get(string $key): ?string
|
||||
{
|
||||
$this->assertKnown($key);
|
||||
$row = $this->row($key);
|
||||
|
||||
$row = $this->inForce($key, OperatingMode::current());
|
||||
|
||||
if ($row === null) {
|
||||
// Strict entries never see the environment: see isStrict() below.
|
||||
if ($this->isStrict($key)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
$configured = config(self::REGISTRY[$key]['config']);
|
||||
|
||||
return $configured === null ? null : (string) $configured;
|
||||
|
|
@ -136,29 +153,51 @@ final class SecretVault
|
|||
}
|
||||
}
|
||||
|
||||
/** Where the value in force comes from: stored, environment, or nowhere. */
|
||||
public function source(string $key): string
|
||||
/**
|
||||
* Where the value in force under this mode comes from — four answers now.
|
||||
*
|
||||
* 'stored_live' is the third, and it exists because the second-best answers
|
||||
* were both lies. The measured case: mode test, dns.token only in the :live
|
||||
* slot, HETZNER_DNS_TOKEN also in the .env. get() hands out the stored live
|
||||
* value (the fallback it has always had), while this method reported
|
||||
* 'environment' — the console then read "Aus der Serverdatei" over a
|
||||
* credential that is in force from the database, and an operator changing
|
||||
* the .env line would have seen no effect at all. That is R19's dummy in
|
||||
* miniature: a display that reads like an answer, is wrong, and stops the
|
||||
* next person looking.
|
||||
*
|
||||
* Returning 'stored' instead would trade one lie for another: the console
|
||||
* shows the "Vergessen" button only for 'stored', and it would then have
|
||||
* pointed at the EMPTY test slot, which forget() would happily delete
|
||||
* nothing from.
|
||||
*
|
||||
* The $mode argument names which mode is IN FORCE, not which row to look
|
||||
* at — same question get() answers, so that the two cannot disagree.
|
||||
*/
|
||||
public function source(string $key, ?OperatingMode $mode = null): string
|
||||
{
|
||||
$this->assertKnown($key);
|
||||
|
||||
return match (true) {
|
||||
$this->row($key) !== null => 'stored',
|
||||
$this->row($key, $mode) !== null => 'stored',
|
||||
$this->fallback($key, $mode) !== null => 'stored_live',
|
||||
$this->isStrict($key) => 'none',
|
||||
filled(config(self::REGISTRY[$key]['config'])) => 'environment',
|
||||
default => 'none',
|
||||
};
|
||||
}
|
||||
|
||||
public function outline(string $key): ?string
|
||||
public function outline(string $key, ?OperatingMode $mode = null): ?string
|
||||
{
|
||||
return $this->row($key)?->outline;
|
||||
return $this->inForce($key, $mode)?->outline;
|
||||
}
|
||||
|
||||
public function updatedAt(string $key): ?string
|
||||
public function updatedAt(string $key, ?OperatingMode $mode = null): ?string
|
||||
{
|
||||
return $this->row($key)?->updated_at;
|
||||
return $this->inForce($key, $mode)?->updated_at;
|
||||
}
|
||||
|
||||
public function put(string $key, string $value, Operator $by): void
|
||||
public function put(string $key, string $value, Operator $by, ?OperatingMode $mode = null): void
|
||||
{
|
||||
$this->assertKnown($key);
|
||||
|
||||
|
|
@ -176,18 +215,18 @@ final class SecretVault
|
|||
// created_at only on the first write: rotating a key must not rewrite
|
||||
// when it was first set, which is the one piece of audit metadata that
|
||||
// answers "how long has this been here?".
|
||||
if ($this->row($key) === null) {
|
||||
if ($this->row($key, $mode) === null) {
|
||||
$attributes['created_at'] = now();
|
||||
}
|
||||
|
||||
DB::table('app_secrets')->updateOrInsert(['key' => $key], $attributes);
|
||||
DB::table('app_secrets')->updateOrInsert(['key' => $this->rowKey($key, $mode)], $attributes);
|
||||
}
|
||||
|
||||
/** Remove the stored value, falling back to whatever the environment says. */
|
||||
public function forget(string $key): void
|
||||
public function forget(string $key, ?OperatingMode $mode = null): void
|
||||
{
|
||||
$this->assertKnown($key);
|
||||
DB::table('app_secrets')->where('key', $key)->delete();
|
||||
DB::table('app_secrets')->where('key', $this->rowKey($key, $mode))->delete();
|
||||
}
|
||||
|
||||
/** Is storing credentials possible at all on this installation? */
|
||||
|
|
@ -196,9 +235,54 @@ final class SecretVault
|
|||
return app(SecretCipher::class)->isUsable();
|
||||
}
|
||||
|
||||
private function row(string $key): ?object
|
||||
/** The storage key for one entry: an entry has two slots, test and live. */
|
||||
private function rowKey(string $key, ?OperatingMode $mode = null): string
|
||||
{
|
||||
return DB::table('app_secrets')->where('key', $key)->first();
|
||||
return $key.':'.($mode ?? OperatingMode::current())->value;
|
||||
}
|
||||
|
||||
private function row(string $key, ?OperatingMode $mode = null): ?object
|
||||
{
|
||||
return DB::table('app_secrets')->where('key', $this->rowKey($key, $mode))->first();
|
||||
}
|
||||
|
||||
/**
|
||||
* The stored row that actually applies under this mode: its own slot, or
|
||||
* the live slot standing in for it.
|
||||
*
|
||||
* ONE place, called by get(), outline() and updatedAt() — and asked by
|
||||
* source() through fallback() — because these four answering the same
|
||||
* question separately is precisely how the console came to say "nothing
|
||||
* stored" about a credential the provisioning run was using.
|
||||
*/
|
||||
private function inForce(string $key, ?OperatingMode $mode = null): ?object
|
||||
{
|
||||
return $this->row($key, $mode) ?? $this->fallback($key, $mode);
|
||||
}
|
||||
|
||||
/**
|
||||
* The live slot filling in for an empty test slot.
|
||||
*
|
||||
* ONLY from test to live, never the other way: using a test credential in
|
||||
* live operation because the real one is missing is the dangerous
|
||||
* direction, and must stay an explicit, visible gap instead. Strict entries
|
||||
* (Stripe) have no fallback at all, in either direction.
|
||||
*/
|
||||
private function fallback(string $key, ?OperatingMode $mode = null): ?object
|
||||
{
|
||||
$mode ??= OperatingMode::current();
|
||||
|
||||
if (! $mode->isTest() || $this->isStrict($key)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return $this->row($key, OperatingMode::Live);
|
||||
}
|
||||
|
||||
/** Does this entry carry the strict trait? Populated in task 3. */
|
||||
private function isStrict(string $key): bool
|
||||
{
|
||||
return (bool) (self::REGISTRY[$key]['strict'] ?? false);
|
||||
}
|
||||
|
||||
private function assertKnown(string $key): void
|
||||
|
|
|
|||
|
|
@ -2,7 +2,9 @@
|
|||
|
||||
namespace App\Services\Stripe;
|
||||
|
||||
use App\Exceptions\StripeNotConfigured;
|
||||
use App\Services\Secrets\SecretVault;
|
||||
use App\Support\OperatingMode;
|
||||
use Illuminate\Http\Client\PendingRequest;
|
||||
use Illuminate\Support\Facades\Http;
|
||||
use RuntimeException;
|
||||
|
|
@ -16,7 +18,12 @@ class HttpStripeClient implements StripeClient
|
|||
{
|
||||
public function isConfigured(): bool
|
||||
{
|
||||
return filled($this->secret());
|
||||
// Reads the vault directly rather than through secret(): that method
|
||||
// now THROWS when the key is missing (see below), and every caller
|
||||
// here — the checkout controller, the catalogue sync commands — asks
|
||||
// this question to decide whether to proceed gracefully. Routing it
|
||||
// through secret() would turn "is it configured?" into an exception.
|
||||
return filled(app(SecretVault::class)->get('stripe.secret'));
|
||||
}
|
||||
|
||||
public function createCheckoutSession(
|
||||
|
|
@ -459,13 +466,9 @@ class HttpStripeClient implements StripeClient
|
|||
|
||||
private function request(?string $idempotencyKey = null): PendingRequest
|
||||
{
|
||||
$secret = (string) $this->secret();
|
||||
|
||||
if (blank($secret)) {
|
||||
throw new RuntimeException('Stripe is not configured (STRIPE_SECRET is empty).');
|
||||
}
|
||||
|
||||
$request = Http::withToken($secret)->acceptJson()->timeout(20);
|
||||
// secret() itself throws when the key is missing, so there is nothing
|
||||
// blank left to check here — see its docblock.
|
||||
$request = Http::withToken($this->secret())->acceptJson()->timeout(20);
|
||||
|
||||
// Stripe replays the original response for a repeated key instead of
|
||||
// creating a second object — but ONLY for a call repeated exactly; a
|
||||
|
|
@ -481,16 +484,25 @@ class HttpStripeClient implements StripeClient
|
|||
}
|
||||
|
||||
/**
|
||||
* The key in force: the one stored in the console if there is one, else the
|
||||
* environment.
|
||||
* The key in force: the one stored in the console for the active operating
|
||||
* mode. Throws rather than returning blank — see StripeNotConfigured.
|
||||
*
|
||||
* Resolved HERE, at the point of use, rather than overlaid onto config at
|
||||
* boot — an overlay would add a query to every request including the public
|
||||
* site, and a queue worker would keep whatever was true when it started.
|
||||
*/
|
||||
private function secret(): ?string
|
||||
private function secret(): string
|
||||
{
|
||||
return app(SecretVault::class)->get('stripe.secret');
|
||||
$secret = app(SecretVault::class)->get('stripe.secret');
|
||||
|
||||
// Ohne Token abzusenden hieße, Stripe mit einem leeren Bearer zu fragen
|
||||
// und einen 401 zu bekommen, den niemand einem fehlenden Schlüssel
|
||||
// zuordnet. Hier abzubrechen sagt, was fehlt.
|
||||
if (blank($secret)) {
|
||||
throw StripeNotConfigured::forMode(OperatingMode::current()->value);
|
||||
}
|
||||
|
||||
return $secret;
|
||||
}
|
||||
|
||||
private function url(string $path): string
|
||||
|
|
|
|||
|
|
@ -3,6 +3,7 @@
|
|||
namespace App\Services\Stripe;
|
||||
|
||||
use App\Services\Secrets\SecretVault;
|
||||
use App\Support\OperatingMode;
|
||||
use Illuminate\Support\Facades\Http;
|
||||
use Throwable;
|
||||
|
||||
|
|
@ -50,7 +51,11 @@ final class StripeCheck
|
|||
'business' => $account->json('settings.dashboard.display_name') ?: $account->json('business_profile.name'),
|
||||
// Stripe does not label the key; the mode is inferred from the
|
||||
// prefix, which is the only thing that is true of both key kinds.
|
||||
'live' => str_starts_with($secret, 'sk_live_') || str_starts_with($secret, 'rk_live_'),
|
||||
// Asked of OperatingMode, not spelled out again here: the same
|
||||
// question decides which slot the migration files a key into and
|
||||
// whether the readiness page reports a key sitting in the wrong
|
||||
// one. Unrecognisable (null) stays false, exactly as before.
|
||||
'live' => OperatingMode::ofStripeKey($secret) === OperatingMode::Live,
|
||||
'restricted' => str_starts_with($secret, 'rk_'),
|
||||
'webhooks' => $this->webhooks($secret),
|
||||
];
|
||||
|
|
|
|||
|
|
@ -0,0 +1,125 @@
|
|||
<?php
|
||||
|
||||
namespace App\Services\Vpn;
|
||||
|
||||
use App\Support\ProvisioningSettings;
|
||||
use Illuminate\Support\Str;
|
||||
|
||||
/**
|
||||
* Whether the WireGuard hub endpoint an onboarding host is told to dial is
|
||||
* even CAPABLE of being reached from outside this network.
|
||||
*
|
||||
* Deliberately makes no network call. A UDP port cannot be knocked on
|
||||
* cleanly from the outside — there is no handshake to observe without a real
|
||||
* peer, and this class runs on every readiness page load, not on demand. What
|
||||
* IS decidable without touching the wire: whether the address configured is
|
||||
* one a host on the internet could even attempt to reach. `10.10.90.185` and
|
||||
* `203.0.113.11` (RFC 5737) both look "set" and are unreachable from any host
|
||||
* outside this operator's own network or a documentation example generator —
|
||||
* exactly the two shapes seen in this installation's configuration on
|
||||
* 2026-07-30.
|
||||
*/
|
||||
final class WireguardEndpointCheck
|
||||
{
|
||||
/**
|
||||
* IPv4 ranges FILTER_FLAG_NO_PRIV_RANGE|FILTER_FLAG_NO_RES_RANGE does NOT
|
||||
* reject, checked one by one against this PHP build (`php -r
|
||||
* 'var_dump(filter_var($ip, FILTER_VALIDATE_IP,
|
||||
* FILTER_FLAG_NO_PRIV_RANGE|FILTER_FLAG_NO_RES_RANGE));'`) so the next
|
||||
* reader does not have to repeat the research:
|
||||
*
|
||||
* Included below, because each is a shape a real installation can
|
||||
* actually end up with:
|
||||
* - 192.0.2.0/24, 198.51.100.0/24, 203.0.113.0/24 (RFC 5737 "TEST-NET"):
|
||||
* documentation/example addresses — seed data and copy-pasted examples
|
||||
* land here. This was the original gap (Task 9 review).
|
||||
* - 100.64.0.0/10 (RFC 6598, Carrier-Grade NAT): an ISP hands this out to
|
||||
* a real customer connection; it is exactly as unreachable from the
|
||||
* outside as RFC 1918, just not covered by NO_PRIV_RANGE. Confirmed
|
||||
* passing filter_var's flags before this fix (fix-round finding).
|
||||
* - 224.0.0.0/4 (multicast): not a shape a *mistake* produces, but never
|
||||
* a single host's own reachable address either — no WireGuard peer's
|
||||
* endpoint can legitimately be a multicast address, so it belongs in
|
||||
* the same bucket as the others rather than being let through by
|
||||
* omission.
|
||||
*
|
||||
* Already rejected by the filter_var flags above WITHOUT this list, so
|
||||
* deliberately NOT duplicated here (verified empirically, not assumed):
|
||||
* 0.0.0.0/8, 127.0.0.0/8 (loopback), 169.254.0.0/16 (link-local),
|
||||
* 240.0.0.0/4 (reserved — this range also covers the 255.255.255.255
|
||||
* broadcast address, confirmed separately).
|
||||
*
|
||||
* Deliberately left UNCHECKED, a considered omission and not an
|
||||
* oversight: 192.0.0.0/24 (IETF protocol assignments), 192.88.99.0/24
|
||||
* (deprecated 6to4 relay anycast), 198.18.0.0/15 (RFC 2544 benchmark
|
||||
* testing). All three pass filter_var's flags today, but none of them is
|
||||
* a shape an operator's real configuration plausibly lands in by
|
||||
* accident or by a real provider's assignment — unlike the TEST-NET
|
||||
* ranges (copy-pasted examples) and CGNAT (an ISP genuinely assigns it).
|
||||
* Adding checks for ranges nobody will ever hit is effort spent on the
|
||||
* task's shape, not its risk (R22).
|
||||
*
|
||||
* @var array<int, string>
|
||||
*/
|
||||
private const NON_PUBLIC_RANGES = [
|
||||
'192.0.2.0/24', // TEST-NET-1 (RFC 5737)
|
||||
'198.51.100.0/24', // TEST-NET-2 (RFC 5737)
|
||||
'203.0.113.0/24', // TEST-NET-3 (RFC 5737)
|
||||
'100.64.0.0/10', // Carrier-Grade NAT (RFC 6598)
|
||||
'224.0.0.0/4', // Multicast
|
||||
];
|
||||
|
||||
/** @return array<string, mixed> */
|
||||
public function run(?string $endpoint = null): array
|
||||
{
|
||||
$endpoint ??= ProvisioningSettings::wgEndpoint();
|
||||
|
||||
if (blank($endpoint)) {
|
||||
return ['ok' => false, 'reason' => 'missing'];
|
||||
}
|
||||
|
||||
if (! str_contains($endpoint, ':')) {
|
||||
return ['ok' => false, 'reason' => 'no_port'];
|
||||
}
|
||||
|
||||
$host = Str::beforeLast($endpoint, ':');
|
||||
|
||||
// No valid IP means: a name. It can resolve publicly, and guessing
|
||||
// here would be worse than letting it through — the check states what
|
||||
// it knows, not what it suspects.
|
||||
if (! filter_var($host, FILTER_VALIDATE_IP)) {
|
||||
return ['ok' => true, 'reason' => 'hostname', 'address' => $host];
|
||||
}
|
||||
|
||||
// NO_PRIV_RANGE covers RFC 1918, NO_RES_RANGE covers loopback and
|
||||
// link-local among others — exactly the private address that stood
|
||||
// at one host on 2026-07-30.
|
||||
$public = filter_var($host, FILTER_VALIDATE_IP, FILTER_FLAG_NO_PRIV_RANGE | FILTER_FLAG_NO_RES_RANGE);
|
||||
|
||||
if ($public === false || $this->isKnownNonPublicRange($host)) {
|
||||
return ['ok' => false, 'reason' => 'not_public', 'address' => $host];
|
||||
}
|
||||
|
||||
return ['ok' => true, 'reason' => 'public', 'address' => $host];
|
||||
}
|
||||
|
||||
private function isKnownNonPublicRange(string $ip): bool
|
||||
{
|
||||
if (! filter_var($ip, FILTER_VALIDATE_IP, FILTER_FLAG_IPV4)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
$long = ip2long($ip);
|
||||
|
||||
foreach (self::NON_PUBLIC_RANGES as $cidr) {
|
||||
[$subnet, $bits] = explode('/', $cidr);
|
||||
$mask = -1 << (32 - (int) $bits);
|
||||
|
||||
if (($long & $mask) === (ip2long($subnet) & $mask)) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
|
@ -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,135 @@
|
|||
<?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'),
|
||||
'--fqdn '.self::fqdnFor($host),
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* 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
|
||||
{
|
||||
$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
|
||||
// Vorgabe. Der Rückfall auf `url()` war deshalb falsch: aufgerufen wird
|
||||
// diese Methode aus der Konsole, also hätte `url()` den KONSOLEN-Namen
|
||||
// geliefert, und die Zeile liefe auf einer Maschine, die den
|
||||
// Adminbereich nicht erreichen darf, in eine 404.
|
||||
//
|
||||
// Also aus APP_URL, denn das ist die Adresse der Plattform und auf jeder
|
||||
// Installation gesetzt.
|
||||
if ($host === '') {
|
||||
$host = (string) (parse_url((string) config('app.url'), PHP_URL_HOST) ?: '');
|
||||
}
|
||||
|
||||
return $host === ''
|
||||
? url('/bootstrap.tar.gz')
|
||||
: 'https://'.$host.'/bootstrap.tar.gz';
|
||||
}
|
||||
|
||||
/**
|
||||
* Der Name, den CluPilot für diesen Host führt: `<rz>-<nn>.node.<zone>`.
|
||||
*
|
||||
* Mitgegeben statt vom Skript geraten. Die erste Fassung leitete ihn auf der
|
||||
* Maschine aus der Rückwärtsauflösung ab und fiel notfalls auf einen fest
|
||||
* verdrahteten Namen zurück — der Installer verlangt einen FQDN, und ein
|
||||
* geratener steht danach in `/etc/hosts`, im Zertifikatsantrag und in jeder
|
||||
* Protokollzeile des Hosts.
|
||||
*
|
||||
* Die PLATTFORM-Zone, nicht die Kundenzone: `clupilot.com`, nicht
|
||||
* `clupilot.cloud`. Die zwei sind laut OfficialDomains getrennt, und ein
|
||||
* Host gehört auf die Seite der Plattform.
|
||||
*/
|
||||
public static function fqdnFor(Host $host): string
|
||||
{
|
||||
$name = $host->dns_name ?: $host->name;
|
||||
|
||||
return $name.'.node.'.config('provisioning.dns.platform_zone');
|
||||
}
|
||||
|
||||
/** 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;
|
||||
}
|
||||
}
|
||||
|
|
@ -94,6 +94,10 @@ final class Navigation
|
|||
// never "all of these" (contrast a single string elsewhere in
|
||||
// this file, which x-shell.nav checks with plain ->can()).
|
||||
['admin.integrations', 'plug', 'integrations', ['hosts.manage', 'secrets.manage']],
|
||||
// Every check App\Support\Readiness knows about, gathered on
|
||||
// one page — same gate as Integrations just above, because
|
||||
// both halves it configures come back together here.
|
||||
['admin.readiness', 'shield-check', 'readiness', ['hosts.manage', 'secrets.manage']],
|
||||
// Two-factor enrolment used to sit here as an entry of its
|
||||
// own. It is a tab of Settings now — it is a setting, and it
|
||||
// was the one place an operator had to leave the settings page
|
||||
|
|
|
|||
|
|
@ -0,0 +1,72 @@
|
|||
<?php
|
||||
|
||||
namespace App\Support;
|
||||
|
||||
/**
|
||||
* Test oder Live — welcher Satz Zugangsdaten gilt.
|
||||
*
|
||||
* In `Settings` und nicht in der `.env`, weil `Settings` seinen Zwischenspeicher
|
||||
* beim Schreiben verwirft: ein Umlegen wirkt sofort, auch in den
|
||||
* Warteschlangen-Prozessen, die seit Stunden laufen. Eine `.env`-Variable
|
||||
* bräuchte einen Containerneustart — genau die Falle, die am 2026-07-30 eine
|
||||
* korrigierte DNS-Zone acht Stunden lang wirkungslos gelassen hat.
|
||||
*
|
||||
* Live ist die Vorgabe. Eine Installation, die nichts gespeichert hat, soll
|
||||
* nicht in einem Modus stehen, der stillschweigend andere Zugangsdaten benutzt.
|
||||
*/
|
||||
enum OperatingMode: string
|
||||
{
|
||||
case Test = 'test';
|
||||
case Live = 'live';
|
||||
|
||||
public const SETTING = 'platform.mode';
|
||||
|
||||
public static function current(): self
|
||||
{
|
||||
// tryFrom, nicht from: ein unbekannter Wert in der Zeile ist ein
|
||||
// Datenfehler und darf nicht jede Anfrage in eine Ausnahme werfen.
|
||||
return self::tryFrom((string) Settings::get(self::SETTING, self::Live->value))
|
||||
?? self::Live;
|
||||
}
|
||||
|
||||
public static function set(self $mode): void
|
||||
{
|
||||
Settings::set(self::SETTING, $mode->value);
|
||||
}
|
||||
|
||||
/**
|
||||
* Zu welchem Modus ein Stripe-Schlüssel gehört — am Präfix, ohne Netzverkehr.
|
||||
*
|
||||
* Stripe beschriftet den Schlüssel nicht; das Präfix ist das Einzige, was
|
||||
* bei beiden Schlüsselarten (`sk_`, und der eingeschränkte `rk_`) wahr ist.
|
||||
* Das reicht, und es muss reichen: die Bereitschaftsseite darf beim
|
||||
* Seitenaufruf nichts erreichen, und die Migration läuft, bevor irgendein
|
||||
* Dienst antwortet.
|
||||
*
|
||||
* `null` heißt „nicht entscheidbar", nicht „live". Wer daraus eine
|
||||
* Vorgabe braucht, sagt sie an seiner Stelle selbst — die Migration setzt
|
||||
* `?? Live` (der Zustand vor ihr, also Fortsetzung statt neuer Behauptung),
|
||||
* die Bereitschaftsprüfung meldet dagegen NUR einen Widerspruch, den sie
|
||||
* beweisen kann.
|
||||
*
|
||||
* Diese eine Stelle, weil dieselbe Frage vorher an drei Orten unabhängig
|
||||
* beantwortet wurde: in der Migration, in StripeCheck und — gar nicht — auf
|
||||
* der Bereitschaftsseite. Drei Formulierungen einer Frage sind der Weg, auf
|
||||
* dem sie auseinanderlaufen.
|
||||
*/
|
||||
public static function ofStripeKey(?string $secret): ?self
|
||||
{
|
||||
$secret = trim((string) $secret);
|
||||
|
||||
return match (true) {
|
||||
str_starts_with($secret, 'sk_test_'), str_starts_with($secret, 'rk_test_') => self::Test,
|
||||
str_starts_with($secret, 'sk_live_'), str_starts_with($secret, 'rk_live_') => self::Live,
|
||||
default => null,
|
||||
};
|
||||
}
|
||||
|
||||
public function isTest(): bool
|
||||
{
|
||||
return $this === self::Test;
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,66 @@
|
|||
<?php
|
||||
|
||||
namespace App\Support;
|
||||
|
||||
use App\Support\Readiness\BillingChecks;
|
||||
use App\Support\Readiness\Check;
|
||||
use App\Support\Readiness\DeliveryChecks;
|
||||
use App\Support\Readiness\OnboardingChecks;
|
||||
use App\Support\Readiness\OperationChecks;
|
||||
use App\Support\Readiness\ProvisioningChecks;
|
||||
|
||||
/**
|
||||
* Jedes Pflichtfeld dieser Installation an einer Stelle, gruppiert nach dem,
|
||||
* was es blockiert — nicht danach, wo der Wert gespeichert ist.
|
||||
*
|
||||
* Ein Betreiber, der eine Installation befüllt, denkt in „was kann ich noch
|
||||
* nicht", nicht in „liegt das im Tresor oder in den Einstellungen".
|
||||
*
|
||||
* Diese Klasse SPERRT NICHTS. Die harten Sperren bleiben, wo sie stehen —
|
||||
* IssueInvoice bei unvollständigen Firmendaten, VerifyVmTemplate bei fehlender
|
||||
* Vorlage, die Kasse bei fehlendem Stripe-Schlüssel. Eine Bereitschaftsseite,
|
||||
* die selbst sperrt, wäre eine zweite Wahrheit neben diesen Prüfungen.
|
||||
*/
|
||||
final class Readiness
|
||||
{
|
||||
/**
|
||||
* Die Gruppen, angehängt. Jede Gruppe kennt nur ihre eigenen
|
||||
* Abhängigkeiten (Stripe, Proxmox, DNS, Mail) — eine Datei, die alle
|
||||
* Gruppen importiert, wäre in jedem einzelnen Gruppentest die ganze Welt.
|
||||
*
|
||||
* @return array<int, Check>
|
||||
*/
|
||||
public static function all(): array
|
||||
{
|
||||
return [
|
||||
...BillingChecks::all(),
|
||||
...OnboardingChecks::all(),
|
||||
...ProvisioningChecks::all(),
|
||||
...DeliveryChecks::all(),
|
||||
...OperationChecks::all(),
|
||||
];
|
||||
}
|
||||
|
||||
/** @return array<int, Check> */
|
||||
public static function blocking(): array
|
||||
{
|
||||
return array_values(array_filter(self::all(), fn (Check $c) => $c->isBlocking()));
|
||||
}
|
||||
|
||||
public static function isReady(): bool
|
||||
{
|
||||
return self::blocking() === [];
|
||||
}
|
||||
|
||||
/** @return array<string, array<int, Check>> */
|
||||
public static function byGroup(): array
|
||||
{
|
||||
$grouped = [];
|
||||
|
||||
foreach (self::all() as $check) {
|
||||
$grouped[$check->group][] = $check;
|
||||
}
|
||||
|
||||
return $grouped;
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,150 @@
|
|||
<?php
|
||||
|
||||
namespace App\Support\Readiness;
|
||||
|
||||
use App\Models\InvoiceSeries;
|
||||
use App\Models\PlanPrice;
|
||||
use App\Services\Secrets\SecretVault;
|
||||
use App\Support\CompanyProfile;
|
||||
use App\Support\OperatingMode;
|
||||
use App\Support\StripeCatalogueMode;
|
||||
use App\Support\StripeWebhookSecret;
|
||||
|
||||
/**
|
||||
* Was gesetzt sein muss, damit überhaupt ein Auftrag entstehen und ein Beleg
|
||||
* herauskommen kann.
|
||||
*/
|
||||
final class BillingChecks
|
||||
{
|
||||
public const GROUP = 'billing';
|
||||
|
||||
/** @return array<int, Check> */
|
||||
public static function all(): array
|
||||
{
|
||||
$mode = OperatingMode::current();
|
||||
|
||||
// get() löst den aktiven Modus selbst auf UND ist für Stripe strikt —
|
||||
// ein Live-Schlüssel im LIVE-Platz zählt im Testbetrieb nicht als
|
||||
// Nachweis, sonst widerspräche diese Seite der Kassensperre.
|
||||
$secret = app(SecretVault::class)->get('stripe.secret');
|
||||
|
||||
// Was im Platz des aktiven Modus liegt, kann trotzdem der Schlüssel des
|
||||
// anderen Kontos sein — eingetippt, nicht zurückgefallen. Am Präfix
|
||||
// ohne Netzverkehr entscheidbar; `null` heißt „nicht entscheidbar" und
|
||||
// wird hier bewusst NICHT als Widerspruch gemeldet: diese Prüfung sagt
|
||||
// nur, was sie beweisen kann.
|
||||
$keyMode = OperatingMode::ofStripeKey($secret);
|
||||
$wrongSlot = $keyMode !== null && $keyMode !== $mode;
|
||||
|
||||
// stripe:sync-catalogue (SyncStripeCatalogue::handle()) only ever
|
||||
// touches PUBLISHED versions — "a draft has promised nothing, and a
|
||||
// Product for it would be a price list entry for something that may
|
||||
// never exist" (its own header comment). A draft's own unsynced price
|
||||
// is therefore not a gap; counting it anyway turns this check into an
|
||||
// alarm that never clears on any installation that has ever drafted a
|
||||
// next version, which stops it being read at all.
|
||||
$published = PlanPrice::query()
|
||||
->whereHas('version', fn ($query) => $query->whereNotNull('published_at'));
|
||||
|
||||
$unsyncedPrices = (clone $published)->whereNull('stripe_price_id')->exists();
|
||||
|
||||
// Eine gefüllte Spalte beweist nur, dass IRGENDEIN Konto diese ID
|
||||
// ausgestellt hat. Welches, steht seit dem Abgleich in einer eigenen
|
||||
// Zeile (StripeCatalogueMode) — ohne sie meldete diese Prüfung nach
|
||||
// dem Umschalten auf Live weiter „erfüllt", und die erste echte
|
||||
// Bestellung bekam von Stripe *No such price*. Ohne Netzverkehr
|
||||
// entschieden: die Bereitschaftsseite erreicht beim Aufruf nichts.
|
||||
//
|
||||
// ZWEI Fälle, nicht einer. „Steht fest: anderes Konto" verlangt das
|
||||
// Leeren der Zeiger und Register; „Herkunft nie festgehalten" verlangt
|
||||
// nur einen neuen Lauf. Der zweite entsteht ohne jeden Kontowechsel —
|
||||
// ein abgebrochener Abgleich, oder eine Bestellung, die sich ihren
|
||||
// fehlenden Preis selbst geholt hat — und die Leeranweisung wäre dort
|
||||
// die Aufforderung, einen Katalog zu löschen, an dem laufende Verträge
|
||||
// abgerechnet werden.
|
||||
$somethingSynced = (clone $published)->whereNotNull('stripe_price_id')->exists();
|
||||
$wrongAccount = $somethingSynced && StripeCatalogueMode::belongsToAnotherMode();
|
||||
$unknownOrigin = $somethingSynced && StripeCatalogueMode::recorded() === null;
|
||||
|
||||
return [
|
||||
new Check(
|
||||
key: 'billing.stripe_secret',
|
||||
group: self::GROUP,
|
||||
severity: Check::SEVERITY_BLOCKING,
|
||||
label: __('readiness.billing.stripe_secret', ['mode' => __('readiness.mode.'.$mode->value)]),
|
||||
// Zwei Ausfälle, zwei Folgen — und „ohne ihn nimmt die Kasse
|
||||
// keine Bestellung an" wäre über einen Schlüssel, der DA ist,
|
||||
// schlicht falsch.
|
||||
breaks: $wrongSlot
|
||||
? __('readiness.billing.stripe_secret_'.$mode->value.'_slot_breaks')
|
||||
: __('readiness.billing.stripe_secret_breaks'),
|
||||
tab: 'services',
|
||||
satisfied: filled($secret) && ! $wrongSlot,
|
||||
),
|
||||
new Check(
|
||||
key: 'billing.webhook_secret',
|
||||
group: self::GROUP,
|
||||
severity: Check::SEVERITY_BLOCKING,
|
||||
label: __('readiness.billing.webhook_secret'),
|
||||
breaks: __('readiness.billing.webhook_secret_breaks'),
|
||||
tab: 'env',
|
||||
// Die Auswahl nach Modus liegt schon in StripeWebhookSecret —
|
||||
// hier noch einmal per config() nach Modus zu unterscheiden
|
||||
// wäre eine zweite Stelle, die dieselbe Entscheidung trifft.
|
||||
satisfied: filled(StripeWebhookSecret::current()),
|
||||
),
|
||||
new Check(
|
||||
key: 'billing.company_details',
|
||||
group: self::GROUP,
|
||||
severity: Check::SEVERITY_BLOCKING,
|
||||
label: __('readiness.billing.company_details'),
|
||||
breaks: __('readiness.billing.company_details_breaks'),
|
||||
tab: 'company',
|
||||
// Die vorhandene Liste wird gerufen, nicht verdoppelt: zwei
|
||||
// Quellen für eine Frage ist der Weg, auf dem sie auseinanderlaufen.
|
||||
satisfied: CompanyProfile::missingForInvoicing() === [],
|
||||
),
|
||||
new Check(
|
||||
key: 'billing.invoice_series',
|
||||
group: self::GROUP,
|
||||
severity: Check::SEVERITY_BLOCKING,
|
||||
label: __('readiness.billing.invoice_series'),
|
||||
breaks: __('readiness.billing.invoice_series_breaks'),
|
||||
tab: 'company',
|
||||
// IssueInvoice draws a series with `where('kind', $kind)
|
||||
// ->where('active', true)` (IssueInvoice.php:373, :444) — a
|
||||
// deactivated row is invisible to it exactly like a missing
|
||||
// one, so the same `active` filter belongs here too. Without
|
||||
// it, an installation with a switched-off 'invoice' series
|
||||
// still counted as ready, the false-green this page exists to
|
||||
// rule out.
|
||||
satisfied: InvoiceSeries::query()
|
||||
->whereIn('kind', ['invoice', 'credit_note', 'cancellation'])
|
||||
->where('active', true)
|
||||
->distinct()
|
||||
->count('kind') === 3,
|
||||
),
|
||||
new Check(
|
||||
key: 'billing.catalogue_synced',
|
||||
group: self::GROUP,
|
||||
severity: Check::SEVERITY_BLOCKING,
|
||||
label: __('readiness.billing.catalogue_synced'),
|
||||
// Der bewiesene Widerspruch zuerst: „noch nicht abgeglichen"
|
||||
// wäre über einen Katalog, der abgeglichen IST — nur im anderen
|
||||
// Konto — die falsche Anweisung, und es ist die teure Richtung
|
||||
// (der Betreiber ruft den Befehl, bekommt „already in step"
|
||||
// und glaubt, es sei erledigt). Danach die unbekannte Herkunft,
|
||||
// deren Kur ein Lauf ist und kein Löschen. Zuletzt die Lücke.
|
||||
breaks: match (true) {
|
||||
$wrongAccount => __('readiness.billing.catalogue_account_breaks', [
|
||||
'mode' => __('readiness.mode.'.$mode->value),
|
||||
]),
|
||||
$unknownOrigin => __('readiness.billing.catalogue_origin_breaks'),
|
||||
default => __('readiness.billing.catalogue_synced_breaks'),
|
||||
},
|
||||
tab: 'services',
|
||||
satisfied: ! $unsyncedPrices && ! $wrongAccount && ! $unknownOrigin,
|
||||
),
|
||||
];
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,33 @@
|
|||
<?php
|
||||
|
||||
namespace App\Support\Readiness;
|
||||
|
||||
/**
|
||||
* Ein Befund der Bereitschaftsprüfung.
|
||||
*
|
||||
* `breaks` ist das Feld, an dem diese Seite hängt. Eine Liste von Feldnamen ist
|
||||
* keine Auskunft — sie sagt, was fehlt, nicht was daraus folgt. Am 2026-07-30
|
||||
* war jedes fehlende Stück in der Konsole unsichtbar, und mehrere brachen die
|
||||
* Kette erst NACH der Zahlung. Genau das gehört hier hinein.
|
||||
*/
|
||||
final class Check
|
||||
{
|
||||
public const SEVERITY_BLOCKING = 'blocking';
|
||||
|
||||
public const SEVERITY_WARNING = 'warning';
|
||||
|
||||
public function __construct(
|
||||
public string $key,
|
||||
public string $group,
|
||||
public string $severity,
|
||||
public string $label,
|
||||
public string $breaks,
|
||||
public string $tab,
|
||||
public bool $satisfied,
|
||||
) {}
|
||||
|
||||
public function isBlocking(): bool
|
||||
{
|
||||
return $this->severity === self::SEVERITY_BLOCKING && ! $this->satisfied;
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,72 @@
|
|||
<?php
|
||||
|
||||
namespace App\Support\Readiness;
|
||||
|
||||
use App\Mail\Transport\MailboxTransport;
|
||||
use App\Models\Mailbox;
|
||||
use App\Models\MailTemplate;
|
||||
use App\Services\Secrets\SecretVault;
|
||||
|
||||
/**
|
||||
* What has to be in place for the customer to actually find out any of this
|
||||
* happened: a mail transport that leaves the server, an address to send
|
||||
* from, and — for support — a way to answer and to hear back.
|
||||
*/
|
||||
final class DeliveryChecks
|
||||
{
|
||||
public const GROUP = 'delivery';
|
||||
|
||||
/** @return array<int, Check> */
|
||||
public static function all(): array
|
||||
{
|
||||
return [
|
||||
new Check(
|
||||
key: 'delivery.mailer_not_log',
|
||||
group: self::GROUP,
|
||||
severity: Check::SEVERITY_BLOCKING,
|
||||
label: __('readiness.delivery.mailer_not_log'),
|
||||
breaks: __('readiness.delivery.mailer_not_log_breaks'),
|
||||
tab: 'mail',
|
||||
// Reads the transport's own definition of "does not really
|
||||
// send" rather than a second, narrower one. `!== 'log'` alone
|
||||
// called 'array' — the MAIL_MAILER this whole suite runs
|
||||
// under — delivered, which MailboxTransport::delegate() has
|
||||
// never agreed with.
|
||||
satisfied: ! in_array(config('mail.default'), MailboxTransport::NON_DELIVERING, true),
|
||||
),
|
||||
new Check(
|
||||
key: 'delivery.mailbox',
|
||||
group: self::GROUP,
|
||||
severity: Check::SEVERITY_BLOCKING,
|
||||
label: __('readiness.delivery.mailbox'),
|
||||
breaks: __('readiness.delivery.mailbox_breaks'),
|
||||
tab: 'mail',
|
||||
satisfied: Mailbox::query()->exists(),
|
||||
),
|
||||
new Check(
|
||||
key: 'delivery.mail_templates',
|
||||
group: self::GROUP,
|
||||
severity: Check::SEVERITY_WARNING,
|
||||
label: __('readiness.delivery.mail_templates'),
|
||||
breaks: __('readiness.delivery.mail_templates_breaks'),
|
||||
tab: 'templates',
|
||||
satisfied: MailTemplate::query()->exists(),
|
||||
),
|
||||
new Check(
|
||||
// Named after the vault entry it checks (inbound_mail.password),
|
||||
// not a shorthand — see the identical reasoning on
|
||||
// onboarding.ssh_private_key in OnboardingChecks.php.
|
||||
key: 'delivery.inbound_mail_password',
|
||||
group: self::GROUP,
|
||||
severity: Check::SEVERITY_WARNING,
|
||||
label: __('readiness.delivery.inbound_password'),
|
||||
breaks: __('readiness.delivery.inbound_password_breaks'),
|
||||
// The inbound-mail card (host/port/user/folder + this
|
||||
// password) is on the 'services' tab — 'integrations' is not
|
||||
// a member of Integrations::TABS at all (Fix-Runde, Befund 1).
|
||||
tab: 'services',
|
||||
satisfied: filled(app(SecretVault::class)->get('inbound_mail.password')),
|
||||
),
|
||||
];
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,115 @@
|
|||
<?php
|
||||
|
||||
namespace App\Support\Readiness;
|
||||
|
||||
use App\Models\Datacenter;
|
||||
use App\Services\Secrets\SecretVault;
|
||||
use App\Services\Wireguard\ConfigVault;
|
||||
use App\Support\ProvisioningSettings;
|
||||
|
||||
/**
|
||||
* What has to be in place before a single host can be onboarded at all: the
|
||||
* SSH identity CluPilot deploys to every host, the two encryption keys the
|
||||
* vault and the VPN config store need to work at all, the WireGuard hub a new
|
||||
* host joins, and somewhere to onboard it into.
|
||||
*/
|
||||
final class OnboardingChecks
|
||||
{
|
||||
public const GROUP = 'onboarding';
|
||||
|
||||
/** @return array<int, Check> */
|
||||
public static function all(): array
|
||||
{
|
||||
return [
|
||||
new Check(
|
||||
// Named after the vault entry it checks (ssh.private_key), not
|
||||
// a shorthand: ReadinessPageTest's guard walks every
|
||||
// SecretVault::REGISTRY key and demands a Check key that
|
||||
// CONTAINS it (dots turned to underscores) — 'onboarding.
|
||||
// ssh_key' does not contain 'ssh_private_key' and slipped
|
||||
// through unnoticed until that guard existed (Task 12).
|
||||
key: 'onboarding.ssh_private_key',
|
||||
group: self::GROUP,
|
||||
severity: Check::SEVERITY_BLOCKING,
|
||||
label: __('readiness.onboarding.ssh_key'),
|
||||
breaks: __('readiness.onboarding.ssh_key_breaks'),
|
||||
// The SSH identity card lives on the 'platform' tab
|
||||
// (integrations.blade.php: "own machines"), beside the
|
||||
// public-key setting it pairs with — not 'integrations',
|
||||
// which is not a member of Integrations::TABS at all and
|
||||
// sent an operator clicking this exact blocking check to the
|
||||
// wrong tab (Fix-Runde, Befund 1).
|
||||
tab: 'platform',
|
||||
// EstablishSshTrust only deploys the PUBLIC half. Every step
|
||||
// after it (HostStep's own shell helper), and later every
|
||||
// Traefik write on a host already onboarded (SshTraefikWriter),
|
||||
// connects with THIS private key.
|
||||
satisfied: filled(app(SecretVault::class)->get('ssh.private_key')),
|
||||
),
|
||||
new Check(
|
||||
key: 'onboarding.secrets_key',
|
||||
group: self::GROUP,
|
||||
severity: Check::SEVERITY_BLOCKING,
|
||||
label: __('readiness.onboarding.secrets_key'),
|
||||
breaks: __('readiness.onboarding.secrets_key_breaks'),
|
||||
// SECRETS_KEY has no field anywhere on Integrations' 'services'
|
||||
// or 'platform' tabs — the only place an operator can actually
|
||||
// set it is the raw .env editor on the 'env' tab.
|
||||
tab: 'env',
|
||||
// isUsable() rather than a bare filled() on the config value —
|
||||
// SecretCipher's own docblock warns that a second, shorter
|
||||
// rule that only checks emptiness lets a malformed-but-non-
|
||||
// empty key sail through, exactly the gap that once let two
|
||||
// other call sites reach an uncaught RuntimeException.
|
||||
satisfied: app(SecretVault::class)->isUsable(),
|
||||
),
|
||||
new Check(
|
||||
key: 'onboarding.vpn_config_key',
|
||||
group: self::GROUP,
|
||||
severity: Check::SEVERITY_BLOCKING,
|
||||
label: __('readiness.onboarding.vpn_config_key'),
|
||||
breaks: __('readiness.onboarding.vpn_config_key_breaks'),
|
||||
// Same reasoning as onboarding.secrets_key just above:
|
||||
// VPN_CONFIG_KEY has no console field either, only the raw
|
||||
// .env editor.
|
||||
tab: 'env',
|
||||
// Same reuse-not-reimplement reasoning as isUsable() above:
|
||||
// ConfigVault::available() is the one place that already knows
|
||||
// what counts as a usable key.
|
||||
satisfied: ConfigVault::available(),
|
||||
),
|
||||
new Check(
|
||||
key: 'onboarding.wg_hub',
|
||||
group: self::GROUP,
|
||||
severity: Check::SEVERITY_BLOCKING,
|
||||
label: __('readiness.onboarding.wg_hub'),
|
||||
breaks: __('readiness.onboarding.wg_hub_breaks'),
|
||||
// Two of the three values this check reads (wgHubPubkey,
|
||||
// wgEndpoint) are 'platform'-tab fields; only the third
|
||||
// (subnet) is .env-only and cannot be changed from the
|
||||
// console at all (see the docblock on satisfied: below). The
|
||||
// tab an operator can actually act on is 'platform'.
|
||||
tab: 'platform',
|
||||
// Two of three is no half a tunnel, it is none: ConfigureWireguard
|
||||
// writes the peer config from all three at once. Subnet is read
|
||||
// straight from config(), never through Settings/ProvisioningSettings
|
||||
// — that class's own docblock explains why: the compose file
|
||||
// (vpn-dns, vpn-gateway) reads the same CLUPILOT_WG_SUBNET at
|
||||
// container boot, so a console-only override would let the
|
||||
// console show one value while the running containers use another.
|
||||
satisfied: filled(ProvisioningSettings::wgHubPublicKey())
|
||||
&& filled(ProvisioningSettings::wgEndpoint())
|
||||
&& filled((string) config('provisioning.wireguard.subnet')),
|
||||
),
|
||||
new Check(
|
||||
key: 'onboarding.datacenter',
|
||||
group: self::GROUP,
|
||||
severity: Check::SEVERITY_BLOCKING,
|
||||
label: __('readiness.onboarding.datacenter'),
|
||||
breaks: __('readiness.onboarding.datacenter_breaks'),
|
||||
tab: 'datacenters',
|
||||
satisfied: Datacenter::query()->exists(),
|
||||
),
|
||||
];
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,120 @@
|
|||
<?php
|
||||
|
||||
namespace App\Support\Readiness;
|
||||
|
||||
use App\Support\Settings;
|
||||
use Illuminate\Support\Carbon;
|
||||
|
||||
/**
|
||||
* Whether anything is actually running this installation, right now — as
|
||||
* opposed to every other group in this file, which asks whether it is
|
||||
* CONFIGURED to run at all.
|
||||
*
|
||||
* Queue-driven delivery has no error path for "nobody is picking up the
|
||||
* queue": a paid order just sits there, forever, with nothing in the console
|
||||
* to tell an operator apart from a quiet afternoon. Two heartbeats because the
|
||||
* scheduler and the provisioning worker fail independently — the dangerous
|
||||
* case is the scheduler running and enqueueing jobs that nobody executes, and
|
||||
* a single heartbeat written by the scheduler alone would call that healthy.
|
||||
*/
|
||||
final class OperationChecks
|
||||
{
|
||||
public const GROUP = 'operation';
|
||||
|
||||
/**
|
||||
* A heartbeat older than this is as good as none: the process that was
|
||||
* writing it has gone quiet. Five minutes is generous slack against a
|
||||
* `->everyMinute()` schedule — a deploy restarting the container, a GC
|
||||
* pause, one skipped tick — without also covering up a worker that has
|
||||
* actually stopped.
|
||||
*/
|
||||
private const STALE_AFTER_MINUTES = 5;
|
||||
|
||||
/**
|
||||
* Fix-Runde (Befund 3): a heartbeat this far into the future is as
|
||||
* untrustworthy as one from the past — a clock skewed between the web,
|
||||
* scheduler and worker containers, or a corrupted value that still
|
||||
* happens to parse. Without an upper bound, `now()->addHour()` written
|
||||
* once by a confused clock would read as healthy forever, since it would
|
||||
* never age past STALE_AFTER_MINUTES from a `greaterThan()`-only check.
|
||||
* One minute is generous slack for the same reason five is above:
|
||||
* everything that writes these timestamps runs on the same host clock,
|
||||
* so more than a minute of "future" is a fault, not jitter.
|
||||
*/
|
||||
private const FUTURE_TOLERANCE_MINUTES = 1;
|
||||
|
||||
/** @return array<int, Check> */
|
||||
public static function all(): array
|
||||
{
|
||||
return [
|
||||
new Check(
|
||||
key: 'operation.scheduler',
|
||||
group: self::GROUP,
|
||||
severity: Check::SEVERITY_BLOCKING,
|
||||
label: __('readiness.operation.scheduler'),
|
||||
breaks: __('readiness.operation.scheduler_breaks'),
|
||||
// Fix-Runde (Befund 4): 'integrations' is not a member of
|
||||
// Integrations::TABS (['services', 'platform', 'env']) — a
|
||||
// link to it would go nowhere. 'env' is where saveEnv() lives,
|
||||
// and saveEnv() is the actual remedy: it restarts the
|
||||
// scheduler and queue-provisioning workers (see its own
|
||||
// docblock), which is exactly what an operator would do about
|
||||
// either check in this group.
|
||||
tab: 'env',
|
||||
satisfied: self::isFresh('heartbeat.scheduler'),
|
||||
),
|
||||
new Check(
|
||||
key: 'operation.queue_provisioning',
|
||||
group: self::GROUP,
|
||||
severity: Check::SEVERITY_BLOCKING,
|
||||
label: __('readiness.operation.queue_provisioning'),
|
||||
breaks: __('readiness.operation.queue_provisioning_breaks'),
|
||||
tab: 'env', // Same fix as above; same remedy (saveEnv()).
|
||||
// Written by RecordProvisioningHeartbeat, which the scheduler
|
||||
// enqueues but only the worker actually runs. A fresh
|
||||
// timestamp here is proof of a running worker, not just of a
|
||||
// running scheduler.
|
||||
satisfied: self::isFresh('heartbeat.queue_provisioning'),
|
||||
),
|
||||
];
|
||||
}
|
||||
|
||||
private static function isFresh(string $key): bool
|
||||
{
|
||||
$recordedAt = Settings::get($key);
|
||||
|
||||
if (blank($recordedAt)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// Fix-Runde (Befund 2): reproduced live — a garbled value in the
|
||||
// heartbeat row (never written by RecordProvisioningHeartbeat or the
|
||||
// scheduler call themselves, but reachable by anyone editing
|
||||
// app_settings directly) made Carbon::parse() throw, which propagated
|
||||
// out of Readiness::all() and took the ENTIRE readiness page down
|
||||
// with it, not just this one check. A page whose only job is to
|
||||
// report calmly what is missing must not join the outage itself. An
|
||||
// unreadable value is treated exactly like a missing one: not
|
||||
// satisfied, nothing thrown.
|
||||
try {
|
||||
$parsed = Carbon::parse($recordedAt);
|
||||
} catch (\Throwable) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// Not diffInMinutes(): App\Models\Incident::durationMinutes() already
|
||||
// documents that it is SIGNED, and `now()->diffInMinutes($recordedAt)`
|
||||
// with $recordedAt in the past returns a NEGATIVE number — which
|
||||
// would compare as "less than 5" for a heartbeat six hours stale, and
|
||||
// report a dead worker as healthy. A direct threshold comparison has
|
||||
// no sign to get backwards.
|
||||
//
|
||||
// Bounded on both sides (Befund 3): stale in the past beyond
|
||||
// STALE_AFTER_MINUTES, but ALSO not more than FUTURE_TOLERANCE_MINUTES
|
||||
// ahead of now — otherwise a heartbeat from a skewed clock would read
|
||||
// as healthy indefinitely, since "in the future" always satisfies a
|
||||
// one-sided "not older than five minutes" comparison.
|
||||
return $parsed->greaterThan(now()->subMinutes(self::STALE_AFTER_MINUTES))
|
||||
&& $parsed->lessThanOrEqualTo(now()->addMinutes(self::FUTURE_TOLERANCE_MINUTES));
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,107 @@
|
|||
<?php
|
||||
|
||||
namespace App\Support\Readiness;
|
||||
|
||||
use App\Models\Host;
|
||||
use App\Models\PlanVersion;
|
||||
use App\Services\Secrets\SecretVault;
|
||||
use App\Support\ProvisioningSettings;
|
||||
|
||||
/**
|
||||
* What has to be in place for a PAID order to actually turn into a running,
|
||||
* reachable machine: somewhere to put it, a name for it, and a router that
|
||||
* knows about it.
|
||||
*/
|
||||
final class ProvisioningChecks
|
||||
{
|
||||
public const GROUP = 'provisioning';
|
||||
|
||||
/** @return array<int, Check> */
|
||||
public static function all(): array
|
||||
{
|
||||
return [
|
||||
new Check(
|
||||
key: 'provisioning.dns_token',
|
||||
group: self::GROUP,
|
||||
severity: Check::SEVERITY_BLOCKING,
|
||||
label: __('readiness.provisioning.dns_token'),
|
||||
breaks: __('readiness.provisioning.dns_token_breaks'),
|
||||
// The DNS card (token + zone + Traefik path) is on the
|
||||
// 'services' tab — 'integrations' is not a member of
|
||||
// Integrations::TABS at all (Fix-Runde, Befund 1).
|
||||
tab: 'services',
|
||||
satisfied: filled(app(SecretVault::class)->get('dns.token')),
|
||||
),
|
||||
new Check(
|
||||
key: 'provisioning.dns_zone',
|
||||
group: self::GROUP,
|
||||
severity: Check::SEVERITY_BLOCKING,
|
||||
label: __('readiness.provisioning.dns_zone'),
|
||||
breaks: __('readiness.provisioning.dns_zone_breaks'),
|
||||
// Same DNS card as provisioning.dns_token just above.
|
||||
tab: 'services',
|
||||
satisfied: filled(ProvisioningSettings::dnsZone()),
|
||||
),
|
||||
new Check(
|
||||
key: 'provisioning.traefik_path',
|
||||
group: self::GROUP,
|
||||
severity: Check::SEVERITY_WARNING,
|
||||
label: __('readiness.provisioning.traefik_path'),
|
||||
breaks: __('readiness.provisioning.traefik_path_breaks'),
|
||||
// Same DNS card — traefikDynamicPath is a field on it.
|
||||
tab: 'services',
|
||||
satisfied: filled(ProvisioningSettings::traefikDynamicPath()),
|
||||
),
|
||||
new Check(
|
||||
key: 'provisioning.usable_host',
|
||||
group: self::GROUP,
|
||||
severity: Check::SEVERITY_BLOCKING,
|
||||
label: __('readiness.provisioning.usable_host'),
|
||||
breaks: __('readiness.provisioning.usable_host_breaks'),
|
||||
tab: 'hosts',
|
||||
// `active` ALONE is not enough. A host without a readable
|
||||
// token is still selectable to ReserveResources, and the
|
||||
// order then dies in CloneVirtualMachine — after payment.
|
||||
satisfied: Host::query()
|
||||
->where('status', 'active')
|
||||
->whereNotNull('api_token_ref')
|
||||
->exists(),
|
||||
),
|
||||
new Check(
|
||||
key: 'provisioning.vm_template',
|
||||
group: self::GROUP,
|
||||
severity: Check::SEVERITY_BLOCKING,
|
||||
label: __('readiness.provisioning.vm_template'),
|
||||
breaks: __('readiness.provisioning.vm_template_breaks'),
|
||||
tab: 'plans',
|
||||
// A field check only. Whether the template actually EXISTS on
|
||||
// a node is answered by the button in Task 9 — that has to
|
||||
// ask Proxmox, and a live API call does not belong on every
|
||||
// page load of this readiness page.
|
||||
satisfied: PlanVersion::query()
|
||||
->whereNotNull('published_at')
|
||||
->whereNull('template_vmid')
|
||||
->doesntExist(),
|
||||
),
|
||||
new Check(
|
||||
key: 'provisioning.monitoring_token',
|
||||
group: self::GROUP,
|
||||
// Follows the same condition RegisterMonitoring itself reads
|
||||
// (RegisterMonitoring.php: `if (config('provisioning.
|
||||
// monitoring.required', false)) { throw $e; }`). Warning by
|
||||
// default — monitoring degrades, then the run continues — but
|
||||
// an operator who has switched that setting on made
|
||||
// monitoring a real gate, and a fixed severity would then
|
||||
// claim a looser rule than the pipeline actually enforces.
|
||||
severity: config('provisioning.monitoring.required', false)
|
||||
? Check::SEVERITY_BLOCKING
|
||||
: Check::SEVERITY_WARNING,
|
||||
label: __('readiness.provisioning.monitoring_token'),
|
||||
breaks: __('readiness.provisioning.monitoring_token_breaks'),
|
||||
// The monitoring card is on the 'services' tab too.
|
||||
tab: 'services',
|
||||
satisfied: filled(app(SecretVault::class)->get('monitoring.token')),
|
||||
),
|
||||
];
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,103 @@
|
|||
<?php
|
||||
|
||||
namespace App\Support;
|
||||
|
||||
use App\Models\PlanFamily;
|
||||
use App\Models\PlanPrice;
|
||||
use App\Models\StripeAddonPrice;
|
||||
use App\Models\StripePlanPrice;
|
||||
|
||||
/**
|
||||
* In welchem Stripe-KONTO die gespeicherten Objekt-IDs entstanden sind.
|
||||
*
|
||||
* Der Betriebsmodus schaltet die Zugangsdaten um. Er schaltet nicht um, was aus
|
||||
* ihnen entstanden ist: eine Stripe-Price-ID und eine Product-ID gehören dem
|
||||
* Konto, das sie ausgestellt hat, und ein Testkonto und ein Livekonto sind zwei
|
||||
* Konten. Jedes Zugangsdatum hat auf dieser Installation zwei Plätze; die
|
||||
* daraus abgeleiteten IDs stehen in einwertigen Spalten
|
||||
* (`plan_families.stripe_product_id`, `plan_prices.stripe_price_id`) und in den
|
||||
* zwei Registern (`stripe_plan_prices`, `stripe_addon_prices`).
|
||||
*
|
||||
* Ohne diese Zeile lief der geplante Ablauf dieser Installation ins Leere:
|
||||
* abgleichen im Testbetrieb, Live-Schlüssel hinterlegen, umschalten — und
|
||||
* `billing.catalogue_synced` meldete weiter „erfüllt", weil es nur prüfte, ob
|
||||
* die Spalte gefüllt ist. Die Seite sagte „Bereit für Livebetrieb", und die
|
||||
* erste echte Bestellung schickte eine Test-Preis-ID an die Live-API: *No such
|
||||
* price*, kein Auftrag. Genau die falsche Grünmeldung, gegen die diese Seite
|
||||
* gebaut ist, ausgelöst durch genau den Schalter, den dieses Vorhaben einführt.
|
||||
*
|
||||
* **Warum eine Einstellungszeile und nicht die Objekte selbst.** Stripe schreibt
|
||||
* die Kontoart nicht in die ID: `prod_…` und `price_…` sehen im Test- und im
|
||||
* Livekonto gleich aus (nur SCHLÜSSEL tragen `_test_`/`_live_` im Präfix — das
|
||||
* ist OperatingMode::ofStripeKey()). Die Herkunft ist aus einer gespeicherten ID
|
||||
* also nicht ableitbar, und nachfragen dürfte die Bereitschaftsseite nicht: sie
|
||||
* erreicht beim Seitenaufruf nichts über das Netz. Bleibt: beim Abgleich
|
||||
* festhalten, in welchem Modus die IDs entstanden sind.
|
||||
*
|
||||
* **Warum EINE Zeile für den ganzen Katalog.** Ein Lauf von
|
||||
* stripe:sync-catalogue benutzt genau einen Schlüssel, also genau ein Konto. Und
|
||||
* der Befehl weigert sich, in einen Katalog hineinzuarbeiten, der zu einem
|
||||
* anderen Konto gehört (siehe dort) — ohne diese Weigerung könnte ein Lauf
|
||||
* einen halb gemischten Zustand hinterlassen, über den eine einzelne Zeile dann
|
||||
* lügen würde.
|
||||
*/
|
||||
final class StripeCatalogueMode
|
||||
{
|
||||
public const SETTING = 'billing.catalogue_mode';
|
||||
|
||||
/** Der Modus, in dem der Katalog zuletzt angelegt wurde — null: noch nie. */
|
||||
public static function recorded(): ?OperatingMode
|
||||
{
|
||||
$stored = Settings::get(self::SETTING);
|
||||
|
||||
return $stored === null ? null : OperatingMode::tryFrom((string) $stored);
|
||||
}
|
||||
|
||||
/** Festhalten, wessen Konto die gerade angelegten IDs gehören. */
|
||||
public static function record(?OperatingMode $mode = null): void
|
||||
{
|
||||
Settings::set(self::SETTING, ($mode ?? OperatingMode::current())->value);
|
||||
}
|
||||
|
||||
/**
|
||||
* Steht fest, dass der gespeicherte Katalog einem ANDEREN Konto gehört?
|
||||
*
|
||||
* Nur dann — nicht schon, wenn die Herkunft unbekannt ist. Das sind zwei
|
||||
* Zustände mit zwei Handlungsanweisungen, und sie zusammenzuwerfen war ein
|
||||
* teurer Fehler: „Herkunft nie festgehalten" entsteht ganz ohne Kontowechsel,
|
||||
* nämlich wenn ein Abgleich mitten im Anlegen abbricht oder wenn eine
|
||||
* Bestellung sich ihren fehlenden Preis selbst holt (CheckoutController →
|
||||
* PlanPrices::ensure(), BookAddon → SyncStripeAddonItems →
|
||||
* AddonPrices::ensure() — keiner der beiden ruft record()). Die Objekte
|
||||
* stammen dann aus genau dem geltenden Konto, und wer in diesem Zustand die
|
||||
* Leeranweisung für einen Kontowechsel liest, löscht einen Katalog, an dem
|
||||
* laufende Verträge abgerechnet werden.
|
||||
*
|
||||
* Unbekannte Herkunft ist trotzdem kein Freibrief: die Bereitschaftsseite
|
||||
* meldet sie weiter blockierend, nur mit dem Satz, der dazu passt („lauf den
|
||||
* Abgleich noch einmal"). Beweisen kann sie die Herkunft nicht.
|
||||
*/
|
||||
public static function belongsToAnotherMode(): bool
|
||||
{
|
||||
$recorded = self::recorded();
|
||||
|
||||
return $recorded !== null && $recorded !== OperatingMode::current();
|
||||
}
|
||||
|
||||
/**
|
||||
* Liegt überhaupt eine Stripe-Objekt-ID in der Datenbank?
|
||||
*
|
||||
* Alle vier Orte, an denen der Abgleich etwas hinterlässt — auch die zwei
|
||||
* Register, weil PlanPrices::inStep() für den Netto-Preis eines
|
||||
* Reverse-Charge-Kunden ALLEIN das Register fragt: eine geleerte Spalte in
|
||||
* `plan_prices` ohne gelöschte Registerzeile ließe genau diese Hälfte des
|
||||
* Katalogs still auf das alte Konto zeigen.
|
||||
*/
|
||||
public static function hasStoredObjects(): bool
|
||||
{
|
||||
return PlanFamily::query()->whereNotNull('stripe_product_id')->exists()
|
||||
|| PlanPrice::query()->whereNotNull('stripe_price_id')->exists()
|
||||
|| StripePlanPrice::query()->exists()
|
||||
|| StripeAddonPrice::query()->exists();
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,33 @@
|
|||
<?php
|
||||
|
||||
namespace App\Support;
|
||||
|
||||
/**
|
||||
* Der Signaturschlüssel des aktiven Betriebsmodus.
|
||||
*
|
||||
* Beide Werte bleiben in der `.env`, nicht im Tresor (siehe Kopfkommentar von
|
||||
* App\Services\Secrets\SecretVault): dieser Wert wird bei jedem eingehenden
|
||||
* Zahlungsereignis gelesen, und ein Datenbankproblem würde die
|
||||
* Signaturprüfung still fehlschlagen lassen — die eine Fehlerart, die laut
|
||||
* bleiben muss.
|
||||
*
|
||||
* Die AUSWAHL fragt allerdings die Datenbank (OperatingMode::current()), was
|
||||
* diese Abhängigkeit auf einem Umweg wieder einführt. Entschärft durch die
|
||||
* Richtung des Ausfalls: `Settings::get()` liefert bei unerreichbarer Tabelle
|
||||
* seinen Vorgabewert `live`, also wird der Live-Schlüssel benutzt. Ein echtes
|
||||
* Zahlungsereignis wird weiter korrekt geprüft, ein Testereignis scheitert
|
||||
* laut. Andersherum wäre es die gefährliche Richtung.
|
||||
*
|
||||
* Eigene Klasse statt einer privaten Methode auf dem Controller: die Auswahl
|
||||
* selbst ist der Prüfgegenstand (siehe StripeWebhookSecretByModeTest), und ein
|
||||
* Test soll dafür nicht per Reflection ins Controller-Innere greifen müssen.
|
||||
*/
|
||||
final class StripeWebhookSecret
|
||||
{
|
||||
public static function current(): string
|
||||
{
|
||||
return OperatingMode::current()->isTest()
|
||||
? (string) config('services.stripe.webhook_secret_test')
|
||||
: (string) config('services.stripe.webhook_secret');
|
||||
}
|
||||
}
|
||||
|
|
@ -354,6 +354,33 @@ return [
|
|||
// googleusercontent.com or vercel.app.
|
||||
'zone' => env('CLUPILOT_DNS_ZONE', 'clupilot.cloud'),
|
||||
|
||||
/*
|
||||
| Die Zone der PLATTFORM — Website, Portal, Konsole und die Namen der
|
||||
| Hosts selbst. Nicht die Kundenzone darüber.
|
||||
|
|
||||
| Sie musste eigens entstehen, weil genau diese Trennung im Code schon
|
||||
| überall behauptet und an einer Stelle gebrochen wurde: der Kopfkommentar
|
||||
| von RegisterHostDns nennt `fsn-01.node.clupilot.com`, die Prüfregel in
|
||||
| Datacenters nennt denselben Namen, und der Test in ServicesTest schreibt
|
||||
| ihn wörtlich — der Schritt selbst baute den Namen aber aus `zone` oben,
|
||||
| und die steht auf der KUNDENZONE. Auf dieser Installation hieß ein Host
|
||||
| damit `fsn-01.node.clupilot.cloud`: in der Zone, die absichtlich von der
|
||||
| Plattform getrennt ist, mit derselben Begründung, die in
|
||||
| OfficialDomains steht.
|
||||
|
|
||||
| Ohne Angabe aus APP_URL abgeleitet, letzte zwei Labels. Das trifft
|
||||
| `app.dev.clupilot.com` → `clupilot.com` und liegt bei mehrteiligen
|
||||
| Endungen wie `co.uk` daneben — dort wird CLUPILOT_PLATFORM_ZONE gesetzt.
|
||||
*/
|
||||
'platform_zone' => env('CLUPILOT_PLATFORM_ZONE') ?: (function () {
|
||||
$host = parse_url((string) env('APP_URL', ''), PHP_URL_HOST) ?: '';
|
||||
$labels = array_filter(explode('.', $host));
|
||||
|
||||
return count($labels) >= 2
|
||||
? implode('.', array_slice($labels, -2))
|
||||
: 'clupilot.com';
|
||||
})(),
|
||||
|
||||
// Internal, WireGuard-only host names (fsn-01.node.…) — the vpn-dns
|
||||
// container's dnsmasq --hostsdir. NOT the public Hetzner zone above:
|
||||
// a host's tunnel address must never be resolvable outside the tunnel,
|
||||
|
|
|
|||
|
|
@ -37,6 +37,11 @@ return [
|
|||
|
||||
'stripe' => [
|
||||
'webhook_secret' => env('STRIPE_WEBHOOK_SECRET'),
|
||||
// Stripe issues a SEPARATE signing secret per operating mode. Which of
|
||||
// the two applies is decided by App\Support\StripeWebhookSecret, not
|
||||
// here — see its header comment for why that lookup still touches the
|
||||
// database despite both values living in the .env.
|
||||
'webhook_secret_test' => env('STRIPE_WEBHOOK_SECRET_TEST'),
|
||||
// Secret key for the REST API. Blank means "not connected": the
|
||||
// catalogue sync says so and does nothing, rather than half-creating
|
||||
// products against an account that is not there.
|
||||
|
|
|
|||
|
|
@ -0,0 +1,126 @@
|
|||
<?php
|
||||
|
||||
use App\Services\Secrets\SecretCipher;
|
||||
use App\Support\OperatingMode;
|
||||
use App\Support\Settings;
|
||||
use App\Support\StripeCatalogueMode;
|
||||
use Illuminate\Database\Migrations\Migration;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use Illuminate\Support\Str;
|
||||
|
||||
/**
|
||||
* Jedes Zugangsdatum bekommt zwei Plätze — und die vorhandene Zeile wird
|
||||
* einsortiert statt umbenannt.
|
||||
*
|
||||
* Für vier der fünf Einträge ist die Antwort dieselbe: was da liegt, wurde für
|
||||
* den echten Betrieb hinterlegt, also `:live`. Nur Stripe kann es selbst sagen,
|
||||
* weil sein Schlüssel sein Präfix trägt — und daran hängt mehr als eine Zeile:
|
||||
* liegt dort ein Testschlüssel, war diese Installation erkennbar im
|
||||
* Testbetrieb, und der Modus folgt daraus statt aus einer Vorgabe.
|
||||
*/
|
||||
return new class extends Migration
|
||||
{
|
||||
public function up(): void
|
||||
{
|
||||
$stripeMode = null;
|
||||
|
||||
foreach (DB::table('app_secrets')->get() as $row) {
|
||||
// Schon einsortiert (etwa bei einem zweiten Lauf): unangetastet.
|
||||
if (Str::contains($row->key, ':')) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$mode = OperatingMode::Live;
|
||||
|
||||
if ($row->key === 'stripe.secret') {
|
||||
$mode = $this->stripeModeOf($row->value);
|
||||
$stripeMode = $mode;
|
||||
}
|
||||
|
||||
DB::table('app_secrets')
|
||||
->where('id', $row->id)
|
||||
->update(['key' => $row->key.':'.$mode->value]);
|
||||
}
|
||||
|
||||
// Nur wenn ein Stripe-Schlüssel da war. Ohne ihn bleibt die Vorgabe
|
||||
// stehen, und die ist live.
|
||||
if ($stripeMode !== null) {
|
||||
Settings::set(OperatingMode::SETTING, $stripeMode->value);
|
||||
|
||||
// Und derselbe Schluss noch einmal, eine Ebene weiter: was bei
|
||||
// Stripe an Produkten und Preisen angelegt wurde, wurde mit
|
||||
// GENAU DIESEM Schlüssel angelegt — ein anderer war auf dieser
|
||||
// Installation nie hinterlegt. Die IDs gehören also dem Konto, das
|
||||
// dieser Schlüssel öffnet, und das ist ab jetzt festgehalten
|
||||
// (StripeCatalogueMode). Ohne diesen Nachtrag stünde ein längst
|
||||
// abgeglichener Katalog als „Herkunft unbekannt" da, und die
|
||||
// Bereitschaftsseite verlangte einen neuen Abgleich, den niemand
|
||||
// braucht. Nur wenn wirklich IDs da sind: eine Zeile über einen
|
||||
// Katalog, den es nicht gibt, wäre eine Behauptung ohne Gegenstand.
|
||||
if (StripeCatalogueMode::hasStoredObjects()) {
|
||||
Settings::set(StripeCatalogueMode::SETTING, $stripeMode->value);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private function stripeModeOf(string $stored): OperatingMode
|
||||
{
|
||||
try {
|
||||
$secret = app(SecretCipher::class)->decrypt($stored);
|
||||
} catch (Throwable) {
|
||||
// Unlesbar heißt: wir wissen es nicht. `:live` ist der Zustand vor
|
||||
// dieser Migration, also die Fortsetzung des Status quo statt einer
|
||||
// neuen Behauptung. Die Bereitschaftsseite meldet ihn ohnehin.
|
||||
return OperatingMode::Live;
|
||||
}
|
||||
|
||||
// Die Präfixregel steht in OperatingMode und wird hier gerufen, nicht
|
||||
// wiederholt: dieselbe Frage entscheidet auf der Bereitschaftsseite, ob
|
||||
// ein Schlüssel im falschen Platz liegt, und in StripeCheck, was der
|
||||
// Konsole als Kontoart gemeldet wird. `?? Live` ist wie oben der
|
||||
// Status quo für einen Wert, der sich nicht zuordnen lässt.
|
||||
return OperatingMode::ofStripeKey($secret) ?? OperatingMode::Live;
|
||||
}
|
||||
|
||||
public function down(): void
|
||||
{
|
||||
$rows = DB::table('app_secrets')->get();
|
||||
|
||||
// Zwei Durchläufe über dieselbe Menge, nicht einer — und die
|
||||
// Reihenfolge zwischen den beiden ist die Entscheidung, nicht eine
|
||||
// Nebenwirkung. `:live` geht zuerst und beansprucht den nackten
|
||||
// Schlüssel bedingungslos; `:test` sieht ihn danach schon vergeben und
|
||||
// weicht. Ein einziger Durchlauf, der beide Endungen im selben Schritt
|
||||
// behandelt, hätte stattdessen der Zeilenreihenfolge der Datenbank
|
||||
// überlassen, welcher der beiden Schlüssel überlebt — bei einem
|
||||
// Zahlungsschlüssel die falsche Art von Zufall.
|
||||
foreach ($rows as $row) {
|
||||
if (Str::endsWith($row->key, ':live')) {
|
||||
DB::table('app_secrets')->where('id', $row->id)->update(['key' => Str::beforeLast($row->key, ':')]);
|
||||
}
|
||||
}
|
||||
|
||||
foreach ($rows as $row) {
|
||||
if (! Str::endsWith($row->key, ':test')) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$bare = Str::beforeLast($row->key, ':');
|
||||
|
||||
// Eine Zeile je Eintrag kann zurück; die zweite hat im alten Schema
|
||||
// keinen Platz und würde am Eindeutigkeitsindex scheitern. Existiert
|
||||
// der nackte Schlüssel schon, hat ihn der Live-Durchlauf oben gerade
|
||||
// erst belegt — der Testwert wird verworfen, nie der Live-Wert.
|
||||
if (DB::table('app_secrets')->where('key', $bare)->exists()) {
|
||||
DB::table('app_secrets')->where('id', $row->id)->delete();
|
||||
|
||||
continue;
|
||||
}
|
||||
|
||||
DB::table('app_secrets')->where('id', $row->id)->update(['key' => $bare]);
|
||||
}
|
||||
|
||||
Settings::forget(OperatingMode::SETTING);
|
||||
Settings::forget(StripeCatalogueMode::SETTING);
|
||||
}
|
||||
};
|
||||
|
|
@ -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']);
|
||||
});
|
||||
}
|
||||
};
|
||||
|
|
@ -0,0 +1,76 @@
|
|||
# Die Nextcloud-Aufstellung, die in der goldenen Vorlage liegt.
|
||||
#
|
||||
# Landet als /opt/nextcloud/docker-compose.yml im Abbild. Sie ist bewusst
|
||||
# KUNDENUNABHÄNGIG: kein Kennwort, kein Name, keine Domain steht hier. Alles
|
||||
# Veränderliche kommt aus /opt/nextcloud/.env, die die Pipeline beim Klonen über
|
||||
# cloud-init schreibt.
|
||||
#
|
||||
# Ein Passwort in ein Abbild zu backen hieße, es auf jeden Host und in jede
|
||||
# Kundeninstanz zu kopieren — und es dort auch dann noch liegen zu haben, wenn
|
||||
# der Kunde längst ein anderes gesetzt hat.
|
||||
|
||||
services:
|
||||
db:
|
||||
image: mariadb:11.4
|
||||
restart: always
|
||||
# READ-COMMITTED und Zeilen-Binlog sind die Einstellungen, die Nextcloud
|
||||
# selbst verlangt; ohne sie meldet die Verwaltungsübersicht dauerhaft eine
|
||||
# Warnung und einzelne Sperren verhalten sich anders als dokumentiert.
|
||||
command: >-
|
||||
--transaction-isolation=READ-COMMITTED
|
||||
--binlog-format=ROW
|
||||
--log-bin=binlog
|
||||
volumes:
|
||||
- db:/var/lib/mysql
|
||||
environment:
|
||||
MYSQL_RANDOM_ROOT_PASSWORD: "yes"
|
||||
MYSQL_DATABASE: nextcloud
|
||||
MYSQL_USER: nextcloud
|
||||
MYSQL_PASSWORD: ${MYSQL_PASSWORD:?MYSQL_PASSWORD fehlt in .env}
|
||||
healthcheck:
|
||||
test: ["CMD", "healthcheck.sh", "--connect", "--innodb_initialized"]
|
||||
interval: 10s
|
||||
timeout: 5s
|
||||
retries: 12
|
||||
|
||||
app:
|
||||
image: nextcloud:apache
|
||||
restart: always
|
||||
|
||||
# FALLE 1 aus Block A, nicht verhandelbar: ohne diese Zeile schlagen ALLE
|
||||
# occ-Aufrufe fehl. Die Bereitschaftsprüfung der Pipeline ruft `occ status`,
|
||||
# und ein Abbild ohne diese Zeile besteht sie nie.
|
||||
user: www-data
|
||||
|
||||
ports:
|
||||
- "80:80"
|
||||
depends_on:
|
||||
db:
|
||||
condition: service_healthy
|
||||
volumes:
|
||||
- nextcloud:/var/www/html
|
||||
environment:
|
||||
MYSQL_HOST: db
|
||||
MYSQL_DATABASE: nextcloud
|
||||
MYSQL_USER: nextcloud
|
||||
MYSQL_PASSWORD: ${MYSQL_PASSWORD:?MYSQL_PASSWORD fehlt in .env}
|
||||
|
||||
# Sind diese beiden gesetzt, installiert sich Nextcloud beim ersten Start
|
||||
# selbst. Genau dafür sind sie da — der Kunde soll keine
|
||||
# Einrichtungsmaske sehen.
|
||||
NEXTCLOUD_ADMIN_USER: ${NEXTCLOUD_ADMIN_USER:?NEXTCLOUD_ADMIN_USER fehlt in .env}
|
||||
NEXTCLOUD_ADMIN_PASSWORD: ${NEXTCLOUD_ADMIN_PASSWORD:?NEXTCLOUD_ADMIN_PASSWORD fehlt in .env}
|
||||
|
||||
NEXTCLOUD_TRUSTED_DOMAINS: ${NEXTCLOUD_TRUSTED_DOMAINS:-}
|
||||
|
||||
# TLS endet bei Traefik auf dem Host, nicht hier. Ohne diese drei baut
|
||||
# Nextcloud seine eigenen Adressen mit http:// — die Anmeldung dreht sich
|
||||
# dann im Kreis, und WebDAV-Zugänge zeigen auf eine Adresse, die es so
|
||||
# nicht gibt.
|
||||
OVERWRITEPROTOCOL: https
|
||||
OVERWRITEHOST: ${NEXTCLOUD_PRIMARY_DOMAIN:-}
|
||||
TRUSTED_PROXIES: ${TRUSTED_PROXIES:-172.16.0.0/12}
|
||||
|
||||
volumes:
|
||||
db:
|
||||
nextcloud:
|
||||
File diff suppressed because it is too large
Load Diff
|
|
@ -0,0 +1,552 @@
|
|||
# shellcheck shell=sh
|
||||
#
|
||||
# Die Brücke, die Selbstrücknahme und die Datacenter-Firewall.
|
||||
#
|
||||
# ---------------------------------------------------------------------------
|
||||
# Was sich durch den ISO-Weg geändert hat
|
||||
# ---------------------------------------------------------------------------
|
||||
#
|
||||
# `ConfigureProxmox.php` hält als eigenen Fund fest: **Proxmox auf Debian legt
|
||||
# keine `vmbr0` an — nur der ISO-Installer schreibt sie in
|
||||
# `/etc/network/interfaces`.** Genau daran starb der erste bezahlte Auftrag:
|
||||
# die Übernahme meldete `active` auf einem Host ohne Brücke, an die man eine
|
||||
# Kunden-VM hätte hängen können.
|
||||
#
|
||||
# Dieser Entwurf installiert über die ISO. Die Brücke ist also im Regelfall
|
||||
# schon da, und dieser Abschnitt **prüft sie, statt sie zu bauen**. Das ist kein
|
||||
# Wegkürzen: dieselbe Datei begründet ausführlich, warum sie eine Brücke über die
|
||||
# primäre Netzkarte NICHT aus der Ferne baut — ein Fehler dabei nimmt die
|
||||
# Maschine dauerhaft vom Netz, und zurück kommt sie nur über die Anbieterkonsole.
|
||||
#
|
||||
# Bleibt der Fall, dass doch keine da ist. Dann — und nur dann — wird gebaut, und
|
||||
# zwar mit der Selbstrücknahme aus Block B des Handoffs: sichern, Zeitgeber
|
||||
# stellen, umstellen, nachsehen, abbestellen.
|
||||
#
|
||||
# ---------------------------------------------------------------------------
|
||||
# Was hier NICHT passiert
|
||||
# ---------------------------------------------------------------------------
|
||||
#
|
||||
# Die nftables-Sperre des Hosts gehört **nicht** in diesen Abschnitt, obwohl der
|
||||
# Plan sie in seiner Leseliste führt. `SecureHostFirewall.php` sagt, warum, und
|
||||
# es ist eine Reihenfolgefrage: dieser Schritt lief im alten Ablauf ZULETZT,
|
||||
# „so the tunnel has already carried every prior step through a reboot before
|
||||
# SSH-to-the-world is ever closed". Hier stünde er VOR `wireguard_joined` — und
|
||||
# schlösse Port 22 zu einem Zeitpunkt, zu dem noch kein Tunnel bewiesen ist. Das
|
||||
# ist die Reihenfolge, die einen Host dauerhaft unerreichbar macht.
|
||||
#
|
||||
# Die Sperre steht deshalb in Abschnitt `registered` (Task 9), an derselben
|
||||
# Stelle wie früher: nach dem bewiesenen Handshake, nach Traefik.
|
||||
|
||||
CLUPILOT_ROLLBACK_UNIT='clupilot-network-rollback'
|
||||
CLUPILOT_NET_BACKUP='/var/lib/clupilot/interfaces.vor-der-bruecke'
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Feststellen, was da ist
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
bridge_exists() {
|
||||
ip link show "${1:-vmbr0}" >/dev/null 2>&1
|
||||
}
|
||||
|
||||
# Trägt die Brücke wirklich den Verkehr, oder existiert sie nur?
|
||||
#
|
||||
# Eine `vmbr0` ohne Adresse und ohne Vorgaberoute ist eine Brücke im Sinne von
|
||||
# `ip link show` und sonst nichts. Der alte Schritt prüfte genau das und warf das
|
||||
# Ergebnis weg; sein Kommentar behauptete, er halte die Abwesenheit fest, und er
|
||||
# hielt nichts fest.
|
||||
bridge_carries_default_route() {
|
||||
_dev="$(ip -4 route show default 2>/dev/null | awk '{ for (i = 1; i < NF; i++) if ($i == "dev") { print $(i+1); exit } }')"
|
||||
[ "$_dev" = "${1:-vmbr0}" ]
|
||||
}
|
||||
|
||||
bridge_has_address() {
|
||||
[ -n "$(ip -4 -o addr show dev "${1:-vmbr0}" scope global 2>/dev/null | awk '{ print $4; exit }')" ]
|
||||
}
|
||||
|
||||
# Wie der Anbieter das Netz aufzieht. Eine Brücke, die für den einen Fall
|
||||
# richtig ist, nimmt den anderen vom Netz.
|
||||
#
|
||||
# - `dhcp` — die Adresse kommt per DHCP (Cloud-Produkte).
|
||||
# - `routed` — geroutete Einzeladresse, Gateway AUSSERHALB des eigenen Subnetzes
|
||||
# (Hetzner-dediziert mit /32). Braucht eine pointopoint-Route,
|
||||
# sonst findet der Kernel das Gateway nicht.
|
||||
# - `subnet` — gewöhnliches Subnetz, Gateway darin.
|
||||
detect_network_style() {
|
||||
_iface="${1:-}"
|
||||
[ -n "$_iface" ] || _iface="$(detect_primary_interface)"
|
||||
|
||||
if grep -qsE "iface[[:space:]]+${_iface}[[:space:]]+inet[[:space:]]+dhcp" /etc/network/interfaces /etc/network/interfaces.d/* 2>/dev/null; then
|
||||
printf 'dhcp'
|
||||
return 0
|
||||
fi
|
||||
|
||||
_cidr="$(ip -4 -o addr show dev "$_iface" scope global 2>/dev/null | awk '{ print $4; exit }')"
|
||||
_gw="$(ip -4 route show default 2>/dev/null | awk '{ print $3; exit }')"
|
||||
_prefix="${_cidr##*/}"
|
||||
|
||||
# /32 heißt: das eigene Subnetz besteht aus der eigenen Adresse. Ein Gateway
|
||||
# darin kann es nicht geben.
|
||||
if [ "$_prefix" = '32' ]; then
|
||||
printf 'routed'
|
||||
return 0
|
||||
fi
|
||||
|
||||
# Liegt das Gateway im eigenen Subnetz? `ip route get` beantwortet das, ohne
|
||||
# dass dieses Skript Netzmasken rechnen muss — und rechnet dabei mit
|
||||
# derselben Logik, die der Kernel später anwendet.
|
||||
if [ -n "$_gw" ] && ip -4 route get "$_gw" 2>/dev/null | grep -q "dev ${_iface}.*src"; then
|
||||
printf 'subnet'
|
||||
return 0
|
||||
fi
|
||||
|
||||
printf 'routed'
|
||||
}
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Die Selbstrücknahme
|
||||
# ---------------------------------------------------------------------------
|
||||
#
|
||||
# Das Muster aus Block B, unverändert, nur im Skript statt in der Pipeline:
|
||||
# sichern, Zeitgeber auf fünf Minuten, umstellen, nachsehen, abbestellen.
|
||||
#
|
||||
# Der Zeitgeber ist eine systemd-Einheit und kein `sleep &`: ein Hintergrundlauf
|
||||
# stirbt mit seiner Sitzung, und die Sitzung ist genau das, was abreißt, wenn die
|
||||
# Umstellung schiefgeht. Er ist der einzige Grund, warum dieser Abschnitt die
|
||||
# Netzkonfiguration überhaupt anfassen darf.
|
||||
|
||||
backup_network_config() {
|
||||
mkdir -p "$(dirname -- "$CLUPILOT_NET_BACKUP")"
|
||||
tar czf "${CLUPILOT_NET_BACKUP}.tar.gz" \
|
||||
-C / etc/network/interfaces $( [ -d /etc/network/interfaces.d ] && printf 'etc/network/interfaces.d' ) \
|
||||
2>/dev/null
|
||||
}
|
||||
|
||||
# Stellt einen Zeitgeber, der die gesicherte Konfiguration zurückspielt und das
|
||||
# Netz neu startet, falls ihn niemand abbestellt.
|
||||
schedule_network_rollback() {
|
||||
_minutes="${1:-5}"
|
||||
|
||||
cat > "/usr/local/sbin/${CLUPILOT_ROLLBACK_UNIT}.sh" <<EOF
|
||||
#!/bin/sh
|
||||
# Von CluPilots Bootstrap gestellt. Spielt die Netzkonfiguration von VOR der
|
||||
# Brücke zurück, falls der Bootstrap sie nicht binnen ${_minutes} Minuten
|
||||
# abbestellt hat — weil er das nur kann, wenn die Maschine noch erreichbar ist.
|
||||
set -e
|
||||
tar xzf '${CLUPILOT_NET_BACKUP}.tar.gz' -C /
|
||||
if command -v ifreload >/dev/null 2>&1; then
|
||||
ifreload -a
|
||||
else
|
||||
systemctl restart networking
|
||||
fi
|
||||
logger -t clupilot 'Netzkonfiguration zurückgespielt: die Brücke hat den Host vom Netz genommen.'
|
||||
EOF
|
||||
chmod 700 "/usr/local/sbin/${CLUPILOT_ROLLBACK_UNIT}.sh"
|
||||
|
||||
cat > "/etc/systemd/system/${CLUPILOT_ROLLBACK_UNIT}.service" <<EOF
|
||||
[Unit]
|
||||
Description=CluPilot: Netzkonfiguration zurückspielen, wenn die Brücke den Host vom Netz nimmt
|
||||
|
||||
[Service]
|
||||
Type=oneshot
|
||||
ExecStart=/usr/local/sbin/${CLUPILOT_ROLLBACK_UNIT}.sh
|
||||
EOF
|
||||
|
||||
cat > "/etc/systemd/system/${CLUPILOT_ROLLBACK_UNIT}.timer" <<EOF
|
||||
[Unit]
|
||||
Description=CluPilot: Frist für die Selbstrücknahme der Netzumstellung
|
||||
|
||||
[Timer]
|
||||
OnActiveSec=${_minutes}min
|
||||
AccuracySec=1s
|
||||
EOF
|
||||
|
||||
systemctl daemon-reload
|
||||
systemctl start "${CLUPILOT_ROLLBACK_UNIT}.timer"
|
||||
log "Selbstrücknahme steht: ohne Abbestellung läuft sie in ${_minutes} Minuten"
|
||||
}
|
||||
|
||||
cancel_network_rollback() {
|
||||
systemctl stop "${CLUPILOT_ROLLBACK_UNIT}.timer" 2>/dev/null || true
|
||||
rm -f "/etc/systemd/system/${CLUPILOT_ROLLBACK_UNIT}.timer" \
|
||||
"/etc/systemd/system/${CLUPILOT_ROLLBACK_UNIT}.service" \
|
||||
"/usr/local/sbin/${CLUPILOT_ROLLBACK_UNIT}.sh"
|
||||
systemctl daemon-reload 2>/dev/null || true
|
||||
log 'Selbstrücknahme abbestellt'
|
||||
}
|
||||
|
||||
# Antwortet die Maschine noch?
|
||||
#
|
||||
# Ehrlich benannt: das hier prüft von INNEN nach außen. Ein Beweis, dass die
|
||||
# Maschine von außen erreichbar ist, ginge nur über einen Gegenüber — und den
|
||||
# gibt es vor dem Tunnelbeitritt nicht. Es reicht trotzdem, weil eine Brücke, die
|
||||
# den Host vom Netz nimmt, in aller Regel beide Richtungen nimmt: Gateway
|
||||
# unerreichbar, Namensauflösung tot, kein Paket kommt durch.
|
||||
#
|
||||
# Was es NICHT abdeckt, deckt der Zeitgeber ab. Deshalb steht er da.
|
||||
host_still_reachable() {
|
||||
_gw="$(ip -4 route show default 2>/dev/null | awk '{ print $3; exit }')"
|
||||
|
||||
[ -n "$_gw" ] || return 1
|
||||
ping -c2 -W2 "$_gw" >/dev/null 2>&1 || return 1
|
||||
|
||||
# Zweite Stufe: irgendetwas jenseits des Gateways. Ein erreichbares Gateway
|
||||
# bei totem Weiterweg ist genau der Zustand, den eine falsche Route erzeugt.
|
||||
http_get "$CLUPILOT_PROBE_URL" >/dev/null 2>&1
|
||||
}
|
||||
|
||||
# Schreibt eine Brücke über die primäre Netzkarte.
|
||||
#
|
||||
# Läuft NUR, wenn der ISO-Installer keine hinterlassen hat — und dann unter dem
|
||||
# Zeitgeber. Die drei Formen unterscheiden sich in genau der Zeile, die
|
||||
# entscheidet, ob die Maschine danach noch da ist.
|
||||
build_bridge() {
|
||||
_iface="$1"
|
||||
_style="$2"
|
||||
_cidr="$3"
|
||||
_gw="$4"
|
||||
|
||||
_ip="${_cidr%%/*}"
|
||||
|
||||
case "$_style" in
|
||||
dhcp)
|
||||
_inet='iface vmbr0 inet dhcp'
|
||||
_addr=''
|
||||
;;
|
||||
routed)
|
||||
# Gateway außerhalb des eigenen Subnetzes. Ohne `pointopoint` findet
|
||||
# der Kernel keinen Weg dorthin: die Route zeigte auf ein Netz, in
|
||||
# dem das Gateway nicht liegt, und die Maschine wäre still weg.
|
||||
_inet='iface vmbr0 inet static'
|
||||
_addr=" address ${_ip}/32
|
||||
pointopoint ${_gw}
|
||||
gateway ${_gw}"
|
||||
;;
|
||||
*)
|
||||
_inet='iface vmbr0 inet static'
|
||||
_addr=" address ${_cidr}
|
||||
gateway ${_gw}"
|
||||
;;
|
||||
esac
|
||||
|
||||
cat > /etc/network/interfaces <<EOF
|
||||
# Von CluPilots Bootstrap geschrieben, weil der Installer keine vmbr0
|
||||
# hinterlassen hat. Anbieterform: ${_style}.
|
||||
source /etc/network/interfaces.d/*
|
||||
|
||||
auto lo
|
||||
iface lo inet loopback
|
||||
|
||||
iface ${_iface} inet manual
|
||||
|
||||
auto vmbr0
|
||||
${_inet}
|
||||
${_addr}
|
||||
bridge-ports ${_iface}
|
||||
bridge-stp off
|
||||
bridge-fd 0
|
||||
EOF
|
||||
|
||||
if command -v ifreload >/dev/null 2>&1; then
|
||||
ifreload -a
|
||||
else
|
||||
systemctl restart networking
|
||||
fi
|
||||
}
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# WireGuard
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
# Netzadresse zu einer Adresse mit Präfix: 10.66.0.11/24 -> 10.66.0.0/24
|
||||
#
|
||||
# Gerechnet statt geraten. `AllowedIPs` mit der Wirtsadresse statt der
|
||||
# Netzadresse ließe den Tunnel handshaken und danach jeden anderen Teilnehmer
|
||||
# unerreichbar — ein Fehler, der aussieht wie ein Routingproblem irgendwo sonst.
|
||||
ipv4_network() {
|
||||
_ip="${1%%/*}"
|
||||
_prefix="${1##*/}"
|
||||
|
||||
IFS=. read -r _a _b _c _d <<EOF
|
||||
$_ip
|
||||
EOF
|
||||
|
||||
_num=$(( (_a << 24) + (_b << 16) + (_c << 8) + _d ))
|
||||
_mask=$(( (0xFFFFFFFF << (32 - _prefix)) & 0xFFFFFFFF ))
|
||||
_net=$(( _num & _mask ))
|
||||
|
||||
printf '%d.%d.%d.%d/%d' \
|
||||
$(( (_net >> 24) & 255 )) $(( (_net >> 16) & 255 )) \
|
||||
$(( (_net >> 8) & 255 )) $(( _net & 255 )) "$_prefix"
|
||||
}
|
||||
|
||||
# Die Tunneladresse von CluPilot — dieselbe, die `--api` trägt.
|
||||
#
|
||||
# Sie ist auch das Ziel für den Handshake-Beweis: wenn CluPilot über den Tunnel
|
||||
# antwortet, steht der Tunnel. Ein eigener Parameter dafür wäre ein zweiter Wert,
|
||||
# der dasselbe meint und irgendwann auseinanderläuft.
|
||||
api_host() {
|
||||
printf '%s' "$CLUPILOT_API" | sed -e 's#^[a-z]*://##' -e 's#[:/].*##'
|
||||
}
|
||||
|
||||
render_wireguard_config() {
|
||||
_subnet="$(ipv4_network "$CLUPILOT_WG_IP")"
|
||||
|
||||
cat <<EOF
|
||||
[Interface]
|
||||
Address = ${CLUPILOT_WG_IP}
|
||||
PrivateKey = ${CLUPILOT_WG_PRIVATE}
|
||||
|
||||
[Peer]
|
||||
PublicKey = ${CLUPILOT_HUB_PUBKEY}
|
||||
Endpoint = ${CLUPILOT_HUB_ENDPOINT}
|
||||
AllowedIPs = ${_subnet}
|
||||
PersistentKeepalive = 25
|
||||
EOF
|
||||
}
|
||||
|
||||
# Bringt wg0 in den Zustand, den die Übernahme braucht: jetzt oben UND nach
|
||||
# einem Neustart oben, mit der Konfiguration, die gerade geschrieben wurde.
|
||||
#
|
||||
# Drei Zustände, getrennt behandelt, weil die eine Zeile, die das hier ersetzt —
|
||||
# `systemctl enable --now wg-quick@wg0 || wg-quick up wg0` — zwei davon falsch
|
||||
# machte: der Rückfall brachte die Schnittstelle hoch OHNE die systemd-Freigabe,
|
||||
# also kam der Tunnel nach dem Neustart nicht wieder; und beim nächsten Versuch
|
||||
# scheiterten beide Hälften an „wg0 already exists", also lief der Schritt ewig
|
||||
# gegen einen Tunnel, der in Wahrheit funktionierte.
|
||||
#
|
||||
# **enable vor start**: `enable` ist das, was den Neustart überlebt, und es wird
|
||||
# auch dann geprüft und nachgeholt, wenn die Schnittstelle schon anderweitig
|
||||
# oben ist.
|
||||
bring_up_wireguard() {
|
||||
_config_changed="$1"
|
||||
_unit='wg-quick@wg0'
|
||||
|
||||
_present=0
|
||||
ip link show wg0 >/dev/null 2>&1 && _present=1
|
||||
|
||||
# `is-enabled` wird nach seinem Rückgabewert gefragt, nicht nach seinem
|
||||
# Wortlaut: eine nie freigegebene Template-Unit („disabled", 1) und eine, die
|
||||
# es noch nicht gibt, werden damit gleich behandelt.
|
||||
if ! systemctl is-enabled --quiet "$_unit" 2>/dev/null; then
|
||||
if ! systemctl enable "$_unit" >/dev/null 2>&1; then
|
||||
log "konnte ${_unit} nicht freigeben — wg0 überlebte keinen Neustart"
|
||||
return 1
|
||||
fi
|
||||
fi
|
||||
|
||||
if [ "$_present" -eq 0 ]; then
|
||||
systemctl start "$_unit" >/dev/null 2>&1 || { log 'wg0 ließ sich nicht starten'; return 1; }
|
||||
return 0
|
||||
fi
|
||||
|
||||
# Vorhanden und unverändert: die laufende Schnittstelle passt schon zur
|
||||
# Datei. Hier neu zu starten ist das, was einen funktionierenden Tunnel
|
||||
# kaputt aussehen ließ.
|
||||
[ "$_config_changed" -eq 0 ] && return 0
|
||||
|
||||
# Vorhanden mit geänderter Konfiguration: Neustart, NICHT `wg syncconf`.
|
||||
# syncconf wendet nur Peers an; eine korrigierte `Address` oder `AllowedIPs`
|
||||
# bliebe still wirkungslos — genau die Sorte „die Datei hat sich geändert und
|
||||
# nichts ist passiert".
|
||||
systemctl restart "$_unit" >/dev/null 2>&1 || { log 'Neustart von wg0 mit der korrigierten Konfiguration fehlgeschlagen'; return 1; }
|
||||
return 0
|
||||
}
|
||||
|
||||
# Der Tunnel gilt erst mit BEWIESENEM Handshake, nicht mit geschriebener Datei.
|
||||
#
|
||||
# Eine gespeicherte Tunneladresse ohne Beweis machte jeden weiteren
|
||||
# Verbindungsversuch unbrauchbar, und die Rettung war Datenbank-Handarbeit.
|
||||
wireguard_handshake_proven() {
|
||||
_target="$(api_host)"
|
||||
[ -n "$_target" ] || return 1
|
||||
|
||||
_tries="${1:-10}"
|
||||
while [ "$_tries" -gt 0 ]; do
|
||||
if ping -c1 -W2 "$_target" >/dev/null 2>&1; then
|
||||
return 0
|
||||
fi
|
||||
_tries=$((_tries - 1))
|
||||
sleep 3
|
||||
done
|
||||
|
||||
return 1
|
||||
}
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Die Host-Firewall (nftables)
|
||||
# ---------------------------------------------------------------------------
|
||||
#
|
||||
# Läuft ZULETZT, in Abschnitt `registered`. Der Grund steht in
|
||||
# `SecureHostFirewall.php` und ist eine Reihenfolgefrage: erst wenn der Tunnel
|
||||
# jeden vorigen Schritt getragen hat, darf SSH nach außen zugehen. Früher
|
||||
# geschlossen, und ein Host hinge mitten in der Übernahme fest.
|
||||
#
|
||||
# nftables statt pve-firewall, und nur die INPUT-Kette: FORWARD und OUTPUT
|
||||
# bleiben auf der Kernel-Vorgabe, damit der Verkehr der Kunden-VMs über die
|
||||
# Brücken nie von einer Host-Regel berührt wird. Zwei Filter, die beide den
|
||||
# Eingang besitzen wollen, sind der Weg zu einem unerreichbaren Host — deshalb
|
||||
# schaltet `enable_datacenter_firewall` die Node-Firewall ausdrücklich ab.
|
||||
#
|
||||
# **Keine automatische Wiederöffnung.** Kein Zeitgeber, kein „nach N Minuten ohne
|
||||
# Handshake wieder auf". Eine Firewall, die sich unter Störung selbst öffnet, ist
|
||||
# keine Firewall. Für den Notfall liegt ein Skript bereit, das von der
|
||||
# Anbieterkonsole aus gestartet wird.
|
||||
|
||||
CLUPILOT_EMERGENCY_SCRIPT='/usr/local/sbin/clupilot-emergency-open-firewall.sh'
|
||||
|
||||
render_nftables_config() {
|
||||
_wg_subnet="$1"
|
||||
|
||||
cat <<NFT
|
||||
#!/usr/sbin/nft -f
|
||||
# Von CluPilots Bootstrap geschrieben.
|
||||
# Nicht von Hand ändern — der nächste Lauf überschreibt die Datei.
|
||||
# Um den Host im Notfall vollständig zu öffnen, von der Anbieterkonsole aus
|
||||
# ${CLUPILOT_EMERGENCY_SCRIPT} starten.
|
||||
|
||||
flush ruleset
|
||||
|
||||
table inet clupilot_filter {
|
||||
chain input {
|
||||
type filter hook input priority 0; policy drop;
|
||||
|
||||
iif "lo" accept
|
||||
ct state invalid drop
|
||||
ct state established,related accept
|
||||
|
||||
# ICMPv6 ist nicht optional. Neighbour Discovery (135/136) und Router
|
||||
# Advertisements kommen in der INPUT-Kette an, ein blankes policy drop
|
||||
# nimmt IPv6 also mit, sobald der Neighbour-Cache abläuft — Minuten, nicht
|
||||
# Tage. packet-too-big ist die andere Hälfte: daran lernt die Gegenstelle
|
||||
# einer TLS-Verbindung die Pfad-MTU, und es zu verwerfen lässt große
|
||||
# Übertragungen ins Leere laufen, statt sie scheitern zu lassen. Eine
|
||||
# Regel pro Zeile, absichtlich: ein Regelwerk, das man zeilenweise lesen
|
||||
# kann, kann man auch zeilenweise prüfen.
|
||||
meta nfproto ipv6 icmpv6 type { destination-unreachable, packet-too-big, time-exceeded, parameter-problem, echo-request, echo-reply, nd-router-solicit, nd-router-advert, nd-neighbor-solicit, nd-neighbor-advert, mld-listener-query, mld-listener-report, mld-listener-done } accept
|
||||
|
||||
# IPv4-ICMP, aus demselben Grund: destination-unreachable trägt
|
||||
# fragmentation-needed (Code 4), worauf die IPv4-Pfad-MTU-Erkennung
|
||||
# beruht. echo ist dabei, damit der Host auf ein ping antwortet — auch auf
|
||||
# das des Hubs.
|
||||
meta nfproto ipv4 icmp type { destination-unreachable, time-exceeded, parameter-problem, echo-request, echo-reply } accept
|
||||
|
||||
# Öffentliches Web — Traefik bedient hier den Kundenverkehr.
|
||||
tcp dport { 80, 443 } accept
|
||||
|
||||
# DHCP-Antworten. Manche Anbieter vergeben die IPv4-Adresse per DHCP, und
|
||||
# ein REBIND kommt als frisches eingehendes Paket an, für das conntrack
|
||||
# keinen Eintrag hat — verworfen, die Lease läuft ab, und der Host
|
||||
# verliert die Adresse, über die er erreicht wird. Bewusst eng: nur eine
|
||||
# Antwort vom Server-Port an den Client-Port.
|
||||
udp sport 67 udp dport 68 accept
|
||||
|
||||
# Verwaltungszugänge: nur über den Tunnel.
|
||||
ip saddr ${_wg_subnet} tcp dport { 22, 8006 } accept
|
||||
}
|
||||
}
|
||||
NFT
|
||||
}
|
||||
|
||||
render_emergency_script() {
|
||||
cat <<'SH'
|
||||
#!/bin/sh
|
||||
# CluPilots Notöffnung der Firewall.
|
||||
#
|
||||
# NUR von der Anbieterkonsole (KVM/seriell) starten, wenn dieser Host über das
|
||||
# Netz nicht mehr erreichbar ist. Es tut genau eine Sache: nftables abschalten
|
||||
# und alle Regeln verwerfen, sodass der Host auf jedem Port wieder annimmt.
|
||||
#
|
||||
# Bewusst von Hand. Nichts hier öffnet die Firewall selbsttätig, auf einen
|
||||
# Zeitgeber hin oder weil ein Handshake eine Weile alt aussah — eine Firewall,
|
||||
# die sich unter Störung selbst öffnet, ist keine Firewall. Die Regeln kommen
|
||||
# zurück mit:
|
||||
# nft -f /etc/nftables.conf && systemctl enable --now nftables
|
||||
|
||||
set -e
|
||||
|
||||
systemctl disable --now nftables 2>/dev/null || true
|
||||
nft flush ruleset 2>/dev/null || true
|
||||
|
||||
echo "Firewallregeln verworfen: dieser Host nimmt jetzt auf jedem Port an."
|
||||
SH
|
||||
}
|
||||
|
||||
# Sperrt den Host ab. Verlangt VORHER einen bewiesenen Tunnel und fasst sonst
|
||||
# nichts an.
|
||||
apply_host_firewall() {
|
||||
if ! wireguard_handshake_proven 3; then
|
||||
log 'Tunnel nicht bewiesen — die Host-Firewall wird nicht angewendet'
|
||||
return 1
|
||||
fi
|
||||
|
||||
export DEBIAN_FRONTEND=noninteractive
|
||||
apt-get install -y nftables >/dev/null 2>&1 || { log 'nftables ließ sich nicht installieren'; return 1; }
|
||||
|
||||
render_nftables_config "$(ipv4_network "$CLUPILOT_WG_IP")" > /etc/nftables.conf
|
||||
render_emergency_script > "$CLUPILOT_EMERGENCY_SCRIPT"
|
||||
chmod 700 "$CLUPILOT_EMERGENCY_SCRIPT"
|
||||
|
||||
# Erst prüfen, dann anwenden. Ein Regelwerk mit einem Tippfehler wird von
|
||||
# `nft -f` teilweise geladen — und „teilweise" heißt hier: policy drop steht,
|
||||
# die Ausnahmen nicht.
|
||||
if ! nft -c -f /etc/nftables.conf >/dev/null 2>&1; then
|
||||
log 'nftables-Regelwerk ist nicht gültig — es wird nichts angewendet'
|
||||
return 1
|
||||
fi
|
||||
|
||||
systemctl enable --now nftables >/dev/null 2>&1 || { log 'nftables ließ sich nicht aktivieren'; return 1; }
|
||||
systemctl reload-or-restart nftables >/dev/null 2>&1 || nft -f /etc/nftables.conf
|
||||
|
||||
return 0
|
||||
}
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Die Datacenter-Firewall
|
||||
# ---------------------------------------------------------------------------
|
||||
#
|
||||
# Wörtlich aus `ConfigureProxmox.php`. Ohne sie sind die „nur 80/443"-Regeln,
|
||||
# die für jede Kunden-VM geschrieben werden, **wirkungslos** — auf jedem Host,
|
||||
# den diese Pipeline je übernommen hat.
|
||||
#
|
||||
# `enable 1` allein ist eine Falle. Die Eingangsrichtlinie steht auf DROP, die
|
||||
# Node-Firewall auf ENABLED, und die einzigen Quellen, die dann noch an 8006 und
|
||||
# 22 kommen, sind die Mitglieder des automatisch erzeugten `management`-ipsets —
|
||||
# gespeist aus dem ÖFFENTLICHEN Subnetz. Die Tunneladresse steht nicht darin.
|
||||
#
|
||||
# Also drei Einstellungen, in dieser Reihenfolge, jede mit einer Aufgabe:
|
||||
#
|
||||
# 1. `policy_in`/`policy_out` auf ACCEPT — das ist die HOST-Richtlinie. Ein Gast
|
||||
# liest sein eigenes `vm.fw`, das für jede Kunden-VM ausdrücklich auf DROP
|
||||
# gesetzt wird; ACCEPT hier lockert also keine einzige Kundenregel.
|
||||
# 2. Node-Firewall AUS. Die Host-Richtlinie liegt in nftables. Zwei Filter, die
|
||||
# beide den Eingang besitzen wollen, sind der Weg zu einem Host, der aus
|
||||
# einem Grund unerreichbar ist, den niemand findet: ein Paket müsste von
|
||||
# BEIDEN angenommen werden.
|
||||
# 3. `enable 1` zuletzt, wenn keines der beiden mehr beißen kann.
|
||||
#
|
||||
# `policy_forward` bleibt absichtlich unangetastet — es greift nur auf dem
|
||||
# nftables-Hintergrund von pve-firewall, der hier aus ist.
|
||||
enable_datacenter_firewall() {
|
||||
for _cmd in \
|
||||
'pvesh set /cluster/firewall/options --policy_in ACCEPT --policy_out ACCEPT' \
|
||||
'pvesh set /nodes/"$(hostname)"/firewall/options --enable 0' \
|
||||
'pvesh set /cluster/firewall/options --enable 1'
|
||||
do
|
||||
if ! eval "$_cmd" >/dev/null 2>&1; then
|
||||
log "Datacenter-Firewall: ${_cmd} fehlgeschlagen"
|
||||
return 1
|
||||
fi
|
||||
done
|
||||
|
||||
# Zurücklesen. `pvesh set` mit Rückgabewert 0 heißt nicht, dass es in
|
||||
# `cluster.fw` steht: /etc/pve ist ein repliziertes Dateisystem, und ein
|
||||
# Schreibvorgang ohne Quorum ist genau der Fall, in dem die Regeln jeder
|
||||
# Kunden-VM wirkungslos blieben, während die Übernahme Erfolg meldet.
|
||||
if ! pvesh get /cluster/firewall/options --output-format json 2>/dev/null \
|
||||
| tr -d ' \n' | grep -q '"enable":1'; then
|
||||
log 'Datacenter-Firewall liest sich nicht als eingeschaltet zurück'
|
||||
return 1
|
||||
fi
|
||||
|
||||
return 0
|
||||
}
|
||||
|
|
@ -0,0 +1,557 @@
|
|||
# shellcheck shell=sh
|
||||
#
|
||||
# Proxmox: die Installation (Abschnitte `debian_installed` und `rebooted`) und
|
||||
# die Paketquellen (`proxmox_installed`).
|
||||
#
|
||||
# ---------------------------------------------------------------------------
|
||||
# Warum eine ISO unter QEMU und nicht installimage
|
||||
# ---------------------------------------------------------------------------
|
||||
#
|
||||
# Hetzners `installimage` führt kein Proxmox — es gilt dort als nicht
|
||||
# unterstützt. Wer es darüber versucht, installiert Debian und baut es danach
|
||||
# um; genau das Zwischensystem, das dieser Entwurf abgeschafft hat.
|
||||
#
|
||||
# Der reguläre Weg ist seit PVE 8.2 die offizielle ISO mit eingebetteter
|
||||
# Antwortdatei: `proxmox-auto-install-assistant prepare-iso` backt eine
|
||||
# `answer.toml` hinein, QEMU startet sie im Rettungssystem gegen die *echten*
|
||||
# Platten, und der Eintrag „Automated Installation" wird nach zehn Sekunden von
|
||||
# selbst gewählt. Kein VNC, kein Klick.
|
||||
#
|
||||
# ---------------------------------------------------------------------------
|
||||
# Die Codename-Tabelle
|
||||
# ---------------------------------------------------------------------------
|
||||
#
|
||||
# Wörtlich übernommen aus `app/Provisioning/Steps/Host/InstallProxmoxVe.php`,
|
||||
# das der Plattform-Plan löscht. Sie ist aus einem echten Fehler entstanden: ein
|
||||
# fest verdrahtetes `bookworm` hätte auf Debian 13 PVE-8-Quellen gesetzt, also
|
||||
# ein PVE gegen eine andere libc.
|
||||
#
|
||||
# Jede Ausgabe hat ihren EIGENEN Schlüssel und ihren eigenen Dateinamen —
|
||||
# Proxmox signiert jede Suite einzeln und hat die Datei zwischen PVE 8 und PVE 9
|
||||
# umbenannt. Ein gemeinsamer Name zeigte irgendwann auf den falschen Schlüssel,
|
||||
# ohne dass etwas falsch aussähe.
|
||||
#
|
||||
# Bullseye/PVE 7 fehlt absichtlich: eine Debian-11-Maschine heute bedeutet, dass
|
||||
# jemand das falsche Abbild gewählt hat, und ein lautes Scheitern ist die
|
||||
# bessere Antwort als ein Hypervisor ohne Unterstützung.
|
||||
|
||||
# proxmox_release_for <codename> — gibt "suite|key_url|keyring" aus, oder 1.
|
||||
proxmox_release_for() {
|
||||
case "$1" in
|
||||
bookworm)
|
||||
printf 'bookworm|https://enterprise.proxmox.com/debian/proxmox-release-bookworm.gpg|proxmox-release-bookworm.gpg'
|
||||
;;
|
||||
trixie)
|
||||
printf 'trixie|https://enterprise.proxmox.com/debian/proxmox-archive-keyring-trixie.gpg|proxmox-archive-keyring.gpg'
|
||||
;;
|
||||
*)
|
||||
return 1
|
||||
;;
|
||||
esac
|
||||
}
|
||||
|
||||
# Liest ID und VERSION_CODENAME aus /etc/os-release.
|
||||
#
|
||||
# Gesourct statt gegreppt, damit die Shell das Entwerten übernimmt, und mit
|
||||
# einem Trenner ausgegeben, der in keinem der Werte vorkommen kann.
|
||||
os_release_field() {
|
||||
(
|
||||
# shellcheck disable=SC1091
|
||||
. /etc/os-release 2>/dev/null || exit 1
|
||||
case "$1" in
|
||||
id) printf '%s' "${ID:-}" ;;
|
||||
codename) printf '%s' "${VERSION_CODENAME:-}" ;;
|
||||
pretty) printf '%s' "${PRETTY_NAME:-}" ;;
|
||||
esac
|
||||
)
|
||||
}
|
||||
|
||||
# Schreibt die Proxmox-Paketquelle im deb822-Format.
|
||||
#
|
||||
# `Signed-By` begrenzt den Schlüssel auf GENAU diese Quelle. Ein Schlüssel in
|
||||
# /etc/apt/trusted.gpg.d/ bürgt für JEDE Quelle der Maschine — der
|
||||
# Proxmox-Schlüssel vouchte dann auch für alles, was später in `sources.list`
|
||||
# auftaucht. Genau davor warnt apt seit mehreren Ausgaben.
|
||||
write_proxmox_sources() {
|
||||
_suite="$1"
|
||||
_keyring_path="$2"
|
||||
_file="$3"
|
||||
|
||||
cat > "$_file" <<EOF
|
||||
# Written by CluPilot's bootstrap script.
|
||||
# The suite is derived from /etc/os-release on this host, not assumed.
|
||||
Types: deb
|
||||
URIs: http://download.proxmox.com/debian/pve
|
||||
Suites: ${_suite}
|
||||
Components: pve-no-subscription
|
||||
Architectures: amd64
|
||||
Signed-By: ${_keyring_path}
|
||||
EOF
|
||||
}
|
||||
|
||||
# Die Abonnement-Quellen, die ein Proxmox-Abbild mitbringt.
|
||||
#
|
||||
# Sie scheitern ohne Abonnement bei JEDEM `apt-get update` — und danach schlägt
|
||||
# jede Paketinstallation der folgenden Abschnitte fehl. Das ist keine
|
||||
# Schönheitskorrektur, sondern die Voraussetzung dafür, dass Traefik, WireGuard
|
||||
# und die Vorlage überhaupt installiert werden können.
|
||||
#
|
||||
# Beide Schreibweisen, weil PVE 8 die einzeiligen `.list` benutzte und PVE 9 auf
|
||||
# deb822 umgestellt hat. Und `ceph`, weil dieselbe Falle dort ein zweites Mal
|
||||
# steht.
|
||||
remove_subscription_sources() {
|
||||
for _f in \
|
||||
/etc/apt/sources.list.d/pve-enterprise.list \
|
||||
/etc/apt/sources.list.d/pve-enterprise.sources \
|
||||
/etc/apt/sources.list.d/ceph.list \
|
||||
/etc/apt/sources.list.d/ceph.sources \
|
||||
/etc/apt/sources.list.d/pve-install-repo.list
|
||||
do
|
||||
if [ -f "$_f" ]; then
|
||||
rm -f "$_f"
|
||||
log "entfernt: ${_f}"
|
||||
fi
|
||||
done
|
||||
}
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Was diese Maschine gerade ist: Bootmodus, Netz, Platten
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
detect_boot_mode() {
|
||||
if [ -d /sys/firmware/efi ]; then
|
||||
printf 'uefi'
|
||||
else
|
||||
printf 'bios'
|
||||
fi
|
||||
}
|
||||
|
||||
# Die Schnittstelle, über die die Vorgabe-Route geht. Nicht „die erste, die
|
||||
# nicht lo heißt": eine Maschine mit zwei Karten hat oft eine angeschlossene und
|
||||
# eine nicht.
|
||||
detect_primary_interface() {
|
||||
ip -4 route show default 2>/dev/null | awk '{ for (i = 1; i < NF; i++) if ($i == "dev") { print $(i+1); exit } }'
|
||||
}
|
||||
|
||||
# Setzt CLUPILOT_NET_CIDR, _GATEWAY, _DNS, _MAC aus dem, was das Rettungssystem
|
||||
# gerade benutzt.
|
||||
#
|
||||
# Diese Werte gehen in die Antwortdatei, weil auf einer dedizierten Maschine
|
||||
# kein DHCP antwortet. Was hier herauskommt, ist die Konfiguration des
|
||||
# INSTALLIERTEN Systems — Task 5 baut sie danach zur Brücke um, und genau dort
|
||||
# unterscheiden sich die Anbieter.
|
||||
detect_network() {
|
||||
_iface="$(detect_primary_interface)"
|
||||
if [ -z "$_iface" ]; then
|
||||
return 1
|
||||
fi
|
||||
|
||||
CLUPILOT_NET_IFACE="$_iface"
|
||||
CLUPILOT_NET_CIDR="$(ip -4 -o addr show dev "$_iface" scope global 2>/dev/null | awk '{ print $4; exit }')"
|
||||
CLUPILOT_NET_GATEWAY="$(ip -4 route show default 2>/dev/null | awk '{ print $3; exit }')"
|
||||
CLUPILOT_NET_MAC="$(cat "/sys/class/net/${_iface}/address" 2>/dev/null | tr -d ':')"
|
||||
CLUPILOT_NET_DNS="$(awk '/^nameserver/ { print $2; exit }' /etc/resolv.conf 2>/dev/null)"
|
||||
|
||||
# Ohne Namensauflösung im installierten System käme es nie an die
|
||||
# Paketquellen. Ein öffentlicher Rückfall ist besser als ein leeres Feld,
|
||||
# das der Installer stillschweigend übergeht.
|
||||
[ -n "$CLUPILOT_NET_DNS" ] || CLUPILOT_NET_DNS='9.9.9.9'
|
||||
|
||||
[ -n "$CLUPILOT_NET_CIDR" ] && [ -n "$CLUPILOT_NET_GATEWAY" ] && [ -n "$CLUPILOT_NET_MAC" ]
|
||||
}
|
||||
|
||||
# Alle physischen Platten, kleinste zuerst nicht — Reihenfolge egal, Anzahl
|
||||
# zählt. Wechseldatenträger, Schleifen und RAM-Platten fliegen raus.
|
||||
detect_install_disks() {
|
||||
_disks=''
|
||||
for _dev in /sys/block/*; do
|
||||
_name="${_dev##*/}"
|
||||
case "$_name" in
|
||||
loop*|ram*|sr*|dm-*|md*|zram*|fd*) continue ;;
|
||||
esac
|
||||
[ -r "${_dev}/size" ] || continue
|
||||
[ "$(cat "${_dev}/size" 2>/dev/null || echo 0)" -gt 0 ] || continue
|
||||
# Wechseldatenträger: ein eingesteckter USB-Stick ist keine Systemplatte.
|
||||
[ "$(cat "${_dev}/removable" 2>/dev/null || echo 0)" = '0' ] || continue
|
||||
_disks="${_disks}${_disks:+ }${_name}"
|
||||
done
|
||||
|
||||
printf '%s' "$_disks"
|
||||
}
|
||||
|
||||
# ZFS-Aufstellung nach Plattenzahl.
|
||||
#
|
||||
# Eine Platte: raid0, weil es nichts zu spiegeln gibt — und weil ein Host mit
|
||||
# einer Platte ohnehin ein Ausfall pro Platte ist. Zwei: raid1. Vier oder mehr,
|
||||
# gerade: raid10. Drei oder eine ungerade Zahl über vier: raid1 über die ersten
|
||||
# zwei, und der Rest bleibt liegen, statt eine Aufstellung zu raten, die
|
||||
# hinterher niemand erwartet hat.
|
||||
zfs_raid_for() {
|
||||
case "$1" in
|
||||
1) printf 'raid0' ;;
|
||||
2) printf 'raid1' ;;
|
||||
4|6|8|10) printf 'raid10' ;;
|
||||
*) printf 'raid1' ;;
|
||||
esac
|
||||
}
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Werkzeug und ISO
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
# `proxmox-auto-install-assistant` ins RETTUNGSSYSTEM.
|
||||
#
|
||||
# Die Quelle richtet sich nach dem Codename des Rettungssystems, nicht nach dem
|
||||
# des Ziels: das Werkzeug läuft hier, nicht dort. Unbekannter Codename bricht ab
|
||||
# — nicht „trixie ist das neueste, also trixie". Das nächste Debian ist auch
|
||||
# unbekannt, und dann sieht niemand hin.
|
||||
# Welche PVE-Hauptversion zu welchem Debian gehört.
|
||||
#
|
||||
# Die andere Hälfte der Tabelle: `proxmox_release_for` sagt, welche Suite zu
|
||||
# einem Debian passt; das hier sagt, welche PVE-Version dabei herauskommen muss.
|
||||
# Gebraucht wird es, um ein ABBILD abzunehmen — bei dem niemand die Suite
|
||||
# gewählt hat, sondern Proxmox das Paar schon zusammengestellt hatte. Passt es
|
||||
# nicht, ist das Abbild falsch, und kein Nachbessern der Quellen heilt das.
|
||||
pve_major_for_codename() {
|
||||
case "$1" in
|
||||
bookworm) printf '8' ;;
|
||||
trixie) printf '9' ;;
|
||||
*) return 1 ;;
|
||||
esac
|
||||
}
|
||||
|
||||
# Legt den Proxmox-Release-Schlüssel ab und gibt seinen Pfad aus.
|
||||
#
|
||||
# Nach /etc/apt/keyrings/ und über `Signed-By` benannt, NICHT nach
|
||||
# /etc/apt/trusted.gpg.d/ — dort bürgte er für jede Quelle der Maschine.
|
||||
install_proxmox_key() {
|
||||
_key_url="$1"
|
||||
_keyring="$2"
|
||||
_keyring_path="/etc/apt/keyrings/${_keyring}"
|
||||
|
||||
mkdir -p /etc/apt/keyrings
|
||||
if ! http_download "$_key_url" "$_keyring_path"; then
|
||||
return 1
|
||||
fi
|
||||
chmod 644 "$_keyring_path"
|
||||
|
||||
printf '%s' "$_keyring_path"
|
||||
}
|
||||
|
||||
# Zerlegt "suite|key_url|keyring" in CLUPILOT_PVE_SUITE, _KEY_URL, _KEYRING.
|
||||
split_proxmox_release() {
|
||||
CLUPILOT_PVE_SUITE="${1%%|*}"
|
||||
_rest="${1#*|}"
|
||||
CLUPILOT_PVE_KEY_URL="${_rest%%|*}"
|
||||
CLUPILOT_PVE_KEYRING="${_rest##*|}"
|
||||
}
|
||||
|
||||
ensure_autoinstall_assistant() {
|
||||
if command -v proxmox-auto-install-assistant >/dev/null 2>&1; then
|
||||
log 'proxmox-auto-install-assistant ist vorhanden'
|
||||
return 0
|
||||
fi
|
||||
|
||||
_id="$(os_release_field id)"
|
||||
_codename="$(os_release_field codename)"
|
||||
|
||||
if [ "$_id" != 'debian' ] || ! _release="$(proxmox_release_for "$_codename")"; then
|
||||
log "Rettungssystem meldet ID=${_id:-(leer)}, VERSION_CODENAME=${_codename:-(leer)} ($(os_release_field pretty))"
|
||||
return 1
|
||||
fi
|
||||
|
||||
split_proxmox_release "$_release"
|
||||
|
||||
if ! _keyring_path="$(install_proxmox_key "$CLUPILOT_PVE_KEY_URL" "$CLUPILOT_PVE_KEYRING")"; then
|
||||
log "Proxmox-Release-Schlüssel für ${_codename} nicht ladbar: ${CLUPILOT_PVE_KEY_URL}"
|
||||
return 1
|
||||
fi
|
||||
|
||||
write_proxmox_sources "$CLUPILOT_PVE_SUITE" "$_keyring_path" '/etc/apt/sources.list.d/pve-install-repo.sources'
|
||||
|
||||
export DEBIAN_FRONTEND=noninteractive
|
||||
apt-get update >/dev/null 2>&1 || return 1
|
||||
apt-get -y install proxmox-auto-install-assistant >/dev/null 2>&1 || return 1
|
||||
|
||||
command -v proxmox-auto-install-assistant >/dev/null 2>&1
|
||||
}
|
||||
|
||||
# Neueste PVE-9-ISO aus der Liste des Anbieters.
|
||||
#
|
||||
# Nicht fest verdrahtet: die Liste gehört Proxmox und wächst. Ein fester Name
|
||||
# wäre in ein paar Monaten eine 404 mitten in einer Übernahme.
|
||||
discover_pve_iso() {
|
||||
_listing="$(http_get "${CLUPILOT_ISO_MIRROR}/" || true)"
|
||||
[ -n "$_listing" ] || return 1
|
||||
|
||||
printf '%s' "$_listing" \
|
||||
| grep -oE 'proxmox-ve_9[0-9.\-]*\.iso' \
|
||||
| sort -u -V \
|
||||
| tail -1
|
||||
}
|
||||
|
||||
# Lädt die ISO und prüft ihre SHA256 gegen die Liste des Anbieters.
|
||||
#
|
||||
# Ohne diese Prüfung startet die Maschine ein Betriebssystem, das unterwegs
|
||||
# irgendetwas geworden sein kann. Eine abgebrochene Übertragung reicht dafür
|
||||
# schon; Bösartigkeit braucht es gar nicht.
|
||||
fetch_proxmox_iso() {
|
||||
_name="$1"
|
||||
_target="$2"
|
||||
|
||||
log "lade ${_name}"
|
||||
if ! http_download "${CLUPILOT_ISO_MIRROR}/${_name}" "$_target"; then
|
||||
return 1
|
||||
fi
|
||||
|
||||
_sums="$(http_get "${CLUPILOT_ISO_MIRROR}/SHA256SUMS" || true)"
|
||||
if [ -z "$_sums" ]; then
|
||||
log 'SHA256SUMS nicht abrufbar'
|
||||
return 1
|
||||
fi
|
||||
|
||||
_expected="$(printf '%s\n' "$_sums" | awk -v n="$_name" '$2 == n || $2 == "*" n { print $1; exit }')"
|
||||
if [ -z "$_expected" ]; then
|
||||
log "keine Prüfsumme für ${_name} in SHA256SUMS"
|
||||
return 1
|
||||
fi
|
||||
|
||||
_actual="$(sha256sum "$_target" | awk '{ print $1 }')"
|
||||
if [ "$_actual" != "$_expected" ]; then
|
||||
log "Prüfsumme stimmt nicht: erwartet ${_expected}, bekommen ${_actual}"
|
||||
rm -f "$_target"
|
||||
return 1
|
||||
fi
|
||||
|
||||
log "Prüfsumme stimmt (${_expected})"
|
||||
return 0
|
||||
}
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Der Wiederanlauf nach dem Neustart
|
||||
# ---------------------------------------------------------------------------
|
||||
#
|
||||
# `[first-boot]` mit `source = "from-iso"` nimmt GENAU EINE ausführbare Datei
|
||||
# mit. Also trägt diese eine Datei alles: das Skript samt `lib/`, die
|
||||
# Fortschrittsdatei und die Aufrufargumente — als Archiv in base64 im eigenen
|
||||
# Rumpf.
|
||||
#
|
||||
# Das löst zugleich die Falle, an der „dieselbe Fortschrittsdatei" sonst
|
||||
# scheitert: das Rettungssystem läuft im Arbeitsspeicher, sein Wurzelverzeichnis
|
||||
# ist nach dem Neustart weg. Über die ISO kommt der Stand mit, ohne dass jemand
|
||||
# den frisch angelegten ZFS-Pool aus dem Rettungssystem heraus einhängen müsste.
|
||||
#
|
||||
# Der WireGuard-Schlüssel liegt damit in der ISO. Die ISO liegt im
|
||||
# Arbeitsspeicher des Rettungssystems und ist nach dem Neustart fort; der
|
||||
# Schlüssel selbst wird in Task 9 ohnehin gegen einen frischen getauscht. Die
|
||||
# entpackten Argumente bekommen 0600 in einem 0700-Verzeichnis.
|
||||
build_first_boot_shim() {
|
||||
_shim="$1"
|
||||
_payload="${CLUPILOT_WORK_DIR}/payload.tar.gz"
|
||||
|
||||
# Argumente für den wiederaufgenommenen Lauf festhalten.
|
||||
_args_file="${CLUPILOT_STATE_DIR}/arguments"
|
||||
umask 077
|
||||
cat > "$_args_file" <<EOF
|
||||
CLUPILOT_CODE='${CLUPILOT_CODE}'
|
||||
CLUPILOT_WG_PRIVATE='${CLUPILOT_WG_PRIVATE}'
|
||||
CLUPILOT_WG_IP='${CLUPILOT_WG_IP}'
|
||||
CLUPILOT_HUB_PUBKEY='${CLUPILOT_HUB_PUBKEY}'
|
||||
CLUPILOT_HUB_ENDPOINT='${CLUPILOT_HUB_ENDPOINT}'
|
||||
CLUPILOT_API='${CLUPILOT_API}'
|
||||
CLUPILOT_FQDN='${CLUPILOT_FQDN}'
|
||||
CLUPILOT_ROOT_SSH_KEY='${CLUPILOT_ROOT_SSH_KEY}'
|
||||
EOF
|
||||
chmod 600 "$_args_file"
|
||||
|
||||
# Laut statt still: läge das Arbeitsverzeichnis im Zustandsverzeichnis,
|
||||
# kopierte der nächste Schritt ein Verzeichnis in sich selbst und die ISO
|
||||
# landete im Hook. Beides wäre erst auf echter Hardware aufgefallen.
|
||||
case "$CLUPILOT_WORK_DIR" in
|
||||
"$CLUPILOT_STATE_DIR"|"$CLUPILOT_STATE_DIR"/*)
|
||||
log "Arbeitsverzeichnis ${CLUPILOT_WORK_DIR} liegt im Zustandsverzeichnis ${CLUPILOT_STATE_DIR} — das darf nicht sein"
|
||||
return 1
|
||||
;;
|
||||
esac
|
||||
|
||||
# Über ein Staging-Verzeichnis, damit im Archiv die ZIELpfade stehen. Ein
|
||||
# `tar -C <elternteil> <name>` schriebe `bootstrap/…` und `clupilot/…` hinein
|
||||
# — ausgepackt nach `/` ergäbe das `/bootstrap` und `/clupilot`, und der Hook
|
||||
# liefe ins Leere.
|
||||
_stage="${CLUPILOT_WORK_DIR}/stage"
|
||||
rm -rf "$_stage"
|
||||
mkdir -p "${_stage}/opt/clupilot" "${_stage}/var/lib"
|
||||
cp -a "$CLUPILOT_BOOTSTRAP_DIR" "${_stage}/opt/clupilot/bootstrap"
|
||||
cp -a "$CLUPILOT_STATE_DIR" "${_stage}/var/lib/clupilot"
|
||||
|
||||
tar czf "$_payload" -C "$_stage" opt var
|
||||
rm -rf "$_stage"
|
||||
|
||||
cat > "$_shim" <<'HEADER'
|
||||
#!/bin/sh
|
||||
# Von CluPilots Bootstrap erzeugt. Läuft einmal, beim ersten Hochfahren des
|
||||
# frisch installierten Hosts, und nimmt den Lauf dort wieder auf, wo der
|
||||
# Neustart ihn unterbrochen hat.
|
||||
set -eu
|
||||
|
||||
mkdir -p /opt/clupilot /var/lib
|
||||
sed -n '/^__NUTZLAST__$/,$p' "$0" | tail -n +2 | base64 -d | tar xzf - -C /
|
||||
chmod 700 /var/lib/clupilot
|
||||
[ -f /var/lib/clupilot/arguments ] && chmod 600 /var/lib/clupilot/arguments
|
||||
|
||||
exec /opt/clupilot/bootstrap/clupilot-bootstrap.sh --resume
|
||||
HEADER
|
||||
|
||||
printf '__NUTZLAST__\n' >> "$_shim"
|
||||
base64 "$_payload" >> "$_shim"
|
||||
chmod 755 "$_shim"
|
||||
}
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Die Antwortdatei
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
write_answer_file() {
|
||||
_file="$1"
|
||||
_disks="$2"
|
||||
_raid="$3"
|
||||
|
||||
# Als TOML-Liste: ["nvme0n1", "nvme1n1"]
|
||||
_disk_list="$(printf '%s' "$_disks" | awk '{ for (i = 1; i <= NF; i++) printf "%s\"%s\"", (i > 1 ? ", " : ""), $i }')"
|
||||
|
||||
# Wird erzeugt und weggeworfen. Niemand braucht es: die Plattform meldet
|
||||
# sich nicht mehr per SSH an (Spec §6), und eine halb installierte Maschine
|
||||
# wird neu aufgesetzt statt nachgebessert. Ein aufbewahrtes Kennwort wäre
|
||||
# nur eine weitere Stelle, an der ein Geheimnis liegt.
|
||||
_root_password="$(head -c 32 /dev/urandom | od -An -tx1 | tr -d ' \n')"
|
||||
|
||||
_ssh_keys=''
|
||||
if [ -n "${CLUPILOT_ROOT_SSH_KEY:-}" ]; then
|
||||
_ssh_keys="root-ssh-keys = [\"${CLUPILOT_ROOT_SSH_KEY}\"]"
|
||||
fi
|
||||
|
||||
cat > "$_file" <<EOF
|
||||
# Von CluPilots Bootstrap erzeugt. Unbeaufsichtigte Installation nach
|
||||
# https://pve.proxmox.com/wiki/Automated_Installation
|
||||
|
||||
[global]
|
||||
keyboard = "de"
|
||||
country = "de"
|
||||
fqdn = "${CLUPILOT_FQDN}"
|
||||
mailto = "root@localhost"
|
||||
timezone = "Europe/Vienna"
|
||||
root-password = "${_root_password}"
|
||||
${_ssh_keys}
|
||||
reboot-on-error = false
|
||||
|
||||
[network]
|
||||
source = "from-answer"
|
||||
cidr = "${CLUPILOT_NET_CIDR}"
|
||||
gateway = "${CLUPILOT_NET_GATEWAY}"
|
||||
dns = "${CLUPILOT_NET_DNS}"
|
||||
filter.ID_NET_NAME_MAC = "*${CLUPILOT_NET_MAC}"
|
||||
|
||||
[disk-setup]
|
||||
filesystem = "zfs"
|
||||
disk-list = [${_disk_list}]
|
||||
|
||||
[disk-setup.zfs]
|
||||
raid = "${_raid}"
|
||||
# Die Vorgabe nimmt sich die Hälfte des Arbeitsspeichers. Den braucht ein Host,
|
||||
# der Kunden trägt, für die Gäste.
|
||||
arc-max = ${CLUPILOT_ZFS_ARC_MAX_MB}
|
||||
|
||||
[first-boot]
|
||||
source = "from-iso"
|
||||
# Nach dem Hochfahren, wenn das Netz steht — vorher könnte der Lauf CluPilot
|
||||
# nicht erreichen und bliebe an seinem eigenen Melden hängen.
|
||||
ordering = "fully-up"
|
||||
EOF
|
||||
}
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Der Lauf unter QEMU
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
# Ein Viertel des Arbeitsspeichers für den Installer, mindestens 2 GB, höchstens
|
||||
# 8. Mehr bringt nichts, weniger lässt den Installer bei ZFS ins Straucheln
|
||||
# kommen.
|
||||
installer_memory_mb() {
|
||||
_total_kb="$(awk '/^MemTotal:/ { print $2; exit }' /proc/meminfo)"
|
||||
_mb=$((_total_kb / 1024 / 4))
|
||||
[ "$_mb" -lt 2048 ] && _mb=2048
|
||||
[ "$_mb" -gt 8192 ] && _mb=8192
|
||||
printf '%s' "$_mb"
|
||||
}
|
||||
|
||||
installer_cpus() {
|
||||
_cpus="$(nproc 2>/dev/null || echo 2)"
|
||||
[ "$_cpus" -gt 8 ] && _cpus=8
|
||||
[ "$_cpus" -lt 1 ] && _cpus=1
|
||||
printf '%s' "$_cpus"
|
||||
}
|
||||
|
||||
# Startet die vorbereitete ISO gegen die echten Platten.
|
||||
#
|
||||
# `-no-reboot`: der Installer startet am Ende neu, und QEMU beendet sich dabei.
|
||||
# Genau daran erkennt dieses Skript, dass er fertig ist — ohne im Gast
|
||||
# nachzusehen, was von außen nicht ginge.
|
||||
#
|
||||
# `-vnc 127.0.0.1:0` ist der Notausgang für den Fehlerfall: bleibt der Installer
|
||||
# doch einmal in einer Maske stehen, kann der Betreiber über einen SSH-Tunnel ins
|
||||
# Rettungssystem nachsehen, was auf dem Bildschirm steht. Nur auf localhost,
|
||||
# damit daraus kein offener Dienst wird.
|
||||
run_installer_under_qemu() {
|
||||
_iso="$1"
|
||||
_disks="$2"
|
||||
_boot_mode="$3"
|
||||
|
||||
_drives=''
|
||||
for _disk in $_disks; do
|
||||
_drives="${_drives} -drive file=/dev/${_disk},format=raw,media=disk,if=virtio,cache=none"
|
||||
done
|
||||
|
||||
_firmware=''
|
||||
if [ "$_boot_mode" = 'uefi' ]; then
|
||||
for _ovmf in /usr/share/OVMF/OVMF_CODE.fd /usr/share/ovmf/OVMF.fd /usr/share/qemu/OVMF.fd; do
|
||||
if [ -f "$_ovmf" ]; then
|
||||
_firmware="-bios ${_ovmf}"
|
||||
break
|
||||
fi
|
||||
done
|
||||
if [ -z "$_firmware" ]; then
|
||||
log 'UEFI-Maschine, aber keine OVMF-Firmware gefunden (Paket ovmf)'
|
||||
return 1
|
||||
fi
|
||||
fi
|
||||
|
||||
log "QEMU startet: ${_boot_mode}, $(installer_memory_mb) MB, $(installer_cpus) Kerne, Platten: ${_disks}"
|
||||
|
||||
# shellcheck disable=SC2086
|
||||
timeout "$CLUPILOT_INSTALL_TIMEOUT" qemu-system-x86_64 \
|
||||
-enable-kvm \
|
||||
-cpu host \
|
||||
-smp "$(installer_cpus)" \
|
||||
-m "$(installer_memory_mb)" \
|
||||
${_firmware} \
|
||||
-cdrom "$_iso" \
|
||||
-boot d \
|
||||
${_drives} \
|
||||
-netdev user,id=net0 -device virtio-net-pci,netdev=net0 \
|
||||
-display none \
|
||||
-vnc 127.0.0.1:0 \
|
||||
-no-reboot \
|
||||
> "${CLUPILOT_WORK_DIR}/qemu.log" 2>&1
|
||||
|
||||
_rc=$?
|
||||
if [ "$_rc" -eq 124 ]; then
|
||||
log "QEMU nach ${CLUPILOT_INSTALL_TIMEOUT} s abgebrochen — der Installer ist nicht fertig geworden"
|
||||
return 1
|
||||
fi
|
||||
if [ "$_rc" -ne 0 ]; then
|
||||
log "QEMU endete mit ${_rc}; siehe ${CLUPILOT_WORK_DIR}/qemu.log"
|
||||
return 1
|
||||
fi
|
||||
|
||||
return 0
|
||||
}
|
||||
|
|
@ -0,0 +1,211 @@
|
|||
# shellcheck shell=sh
|
||||
#
|
||||
# Die Übergabe: Proxmox-Rolle und Token erzeugen, bei CluPilot registrieren, den
|
||||
# WireGuard-Schlüssel tauschen.
|
||||
#
|
||||
# ---------------------------------------------------------------------------
|
||||
# Die Rolle, und warum `|| true` hier NICHT steht
|
||||
# ---------------------------------------------------------------------------
|
||||
#
|
||||
# `pveum role add … || true` wendet die Rechteliste nur bei dem Lauf an, der die
|
||||
# Rolle zum ersten Mal anlegt. Ein Recht, das später dazukam, erreichte damit
|
||||
# neue Hosts und keinen einzigen bestehenden. Genau so fehlte `Sys.Modify`
|
||||
# überall — mit einem Symptom, das viel später und ganz woanders auftrat: ein
|
||||
# 403 auf `POST /cluster/backup`, und die Bereitstellung eines zahlenden Kunden
|
||||
# scheiterte bei `register_backup`.
|
||||
#
|
||||
# Deshalb: `role add`, und wenn das scheitert (die Rolle gibt es schon, der
|
||||
# gewöhnliche Fall), `role modify` — zwei getrennte Befehle, damit ein
|
||||
# Fehlschlag zuzuordnen ist. `modify` ohne `-append` ERSETZT die Liste, der Host
|
||||
# passt sich also der Konfiguration an, statt anzusammeln, was irgendeine
|
||||
# frühere Fassung einmal gewährt hat.
|
||||
#
|
||||
# Für Benutzer und ACL galt `|| true` bisher als geduldet, weil „gibt es schon"
|
||||
# dort der Normalfall ist. Auch das entfällt: statt den Fehler zu verwerfen,
|
||||
# wird nachgesehen, ob der gewünschte Zustand steht. Ein `|| true` verdeckt
|
||||
# nicht nur „gibt es schon", sondern auch „ging schief".
|
||||
|
||||
CLUPILOT_PVE_ROLE='CluPilotAutomation'
|
||||
CLUPILOT_PVE_USER='automation@pve'
|
||||
CLUPILOT_PVE_TOKEN_NAME='clupilot'
|
||||
|
||||
# Wörtlich aus `config/provisioning.php`. Sys.Modify ist nicht optional: es steht
|
||||
# in `PVE/API2/Backup.pm` als `check => ['perm', '/', ['Sys.Modify']]` — an der
|
||||
# Quelle geprüft, nicht aus einem Forenbeitrag geschlossen — und deckt zugleich
|
||||
# `PUT /cluster/firewall/options` ab. Gewährt auf `/`, weil beide Endpunkte
|
||||
# gegen `/` prüfen und nichts Engeres sie zufriedenstellt.
|
||||
CLUPILOT_PVE_PRIVS='VM.Allocate,VM.Clone,VM.Config.Disk,VM.Config.CPU,VM.Config.Memory,VM.Config.Network,VM.Config.Options,VM.Config.Cloudinit,VM.PowerMgmt,VM.Monitor,VM.Audit,VM.Backup,VM.GuestAgent.Audit,VM.GuestAgent.Unrestricted,Datastore.AllocateSpace,Datastore.Audit,Sys.Audit,Sys.Modify'
|
||||
|
||||
# Ein Feld aus flachem JSON. Nimmt `jq`, wenn es da ist, und sonst einen
|
||||
# eng gefassten Ausdruck.
|
||||
json_field() {
|
||||
_key="$1"
|
||||
_json="$2"
|
||||
|
||||
if command -v jq >/dev/null 2>&1; then
|
||||
printf '%s' "$_json" | jq -r --arg k "$_key" '.[$k] // empty' 2>/dev/null
|
||||
return 0
|
||||
fi
|
||||
|
||||
printf '%s' "$_json" \
|
||||
| sed -n "s/.*\"${_key}\"[[:space:]]*:[[:space:]]*\"\\([^\"]*\\)\".*/\\1/p" \
|
||||
| head -1
|
||||
}
|
||||
|
||||
converge_proxmox_role() {
|
||||
if pveum role add "$CLUPILOT_PVE_ROLE" -privs "$CLUPILOT_PVE_PRIVS" >/dev/null 2>&1; then
|
||||
log "Proxmox-Rolle ${CLUPILOT_PVE_ROLE} angelegt"
|
||||
return 0
|
||||
fi
|
||||
|
||||
if pveum role modify "$CLUPILOT_PVE_ROLE" -privs "$CLUPILOT_PVE_PRIVS" >/dev/null 2>&1; then
|
||||
log "Proxmox-Rolle ${CLUPILOT_PVE_ROLE} auf die aktuelle Rechteliste gebracht"
|
||||
return 0
|
||||
fi
|
||||
|
||||
log 'Die Rechteliste der Proxmox-Rolle ließ sich nicht angleichen'
|
||||
return 1
|
||||
}
|
||||
|
||||
# Benutzer und ACL — ohne `|| true`, aber mit Nachsehen.
|
||||
ensure_proxmox_user() {
|
||||
if ! pveum user list --output-format json 2>/dev/null | grep -q "\"${CLUPILOT_PVE_USER}\""; then
|
||||
if ! pveum user add "$CLUPILOT_PVE_USER" >/dev/null 2>&1; then
|
||||
# Nicht blind schlucken: nachsehen, ob er jetzt da ist. Wenn ja, war
|
||||
# es ein Wettlauf und kein Fehler.
|
||||
if ! pveum user list --output-format json 2>/dev/null | grep -q "\"${CLUPILOT_PVE_USER}\""; then
|
||||
log "Proxmox-Benutzer ${CLUPILOT_PVE_USER} ließ sich nicht anlegen"
|
||||
return 1
|
||||
fi
|
||||
fi
|
||||
fi
|
||||
|
||||
if ! pveum acl modify / -user "$CLUPILOT_PVE_USER" -role "$CLUPILOT_PVE_ROLE" >/dev/null 2>&1; then
|
||||
# Auch hier: gilt die Zuweisung inzwischen, war es keiner.
|
||||
if ! pveum acl list --output-format json 2>/dev/null | grep -q "$CLUPILOT_PVE_ROLE"; then
|
||||
log 'Die Rollenzuweisung auf / ließ sich nicht setzen'
|
||||
return 1
|
||||
fi
|
||||
fi
|
||||
|
||||
return 0
|
||||
}
|
||||
|
||||
# Erzeugt den API-Token und gibt "<tokenid>=<secret>" aus.
|
||||
#
|
||||
# Das Geheimnis wird genau einmal gezeigt. Ein halb angelegter Token aus einem
|
||||
# abgebrochenen früheren Lauf wird vorher entfernt, damit ein zweiter Lauf
|
||||
# gleich ausgeht.
|
||||
mint_proxmox_token() {
|
||||
pveum user token remove "$CLUPILOT_PVE_USER" "$CLUPILOT_PVE_TOKEN_NAME" >/dev/null 2>&1 || true
|
||||
|
||||
_out="$(pveum user token add "$CLUPILOT_PVE_USER" "$CLUPILOT_PVE_TOKEN_NAME" -privsep 0 --output-format json 2>/dev/null)"
|
||||
[ -n "$_out" ] || return 1
|
||||
|
||||
_secret="$(json_field value "$_out")"
|
||||
[ -n "$_secret" ] || return 1
|
||||
|
||||
_tokenid="$(json_field 'full-tokenid' "$_out")"
|
||||
[ -n "$_tokenid" ] || _tokenid="${CLUPILOT_PVE_USER}!${CLUPILOT_PVE_TOKEN_NAME}"
|
||||
|
||||
printf '%s=%s' "$_tokenid" "$_secret"
|
||||
}
|
||||
|
||||
# Der Fingerabdruck des SSH-Wirtsschlüssels. Nur zur Anzeige — die Plattform
|
||||
# meldet sich nicht mehr per SSH an (Spec §6).
|
||||
ssh_host_fingerprint() {
|
||||
for _key in /etc/ssh/ssh_host_ed25519_key.pub /etc/ssh/ssh_host_rsa_key.pub; do
|
||||
if [ -f "$_key" ]; then
|
||||
ssh-keygen -lf "$_key" 2>/dev/null | awk '{ print $2; exit }'
|
||||
return 0
|
||||
fi
|
||||
done
|
||||
printf ''
|
||||
}
|
||||
|
||||
# Die Eckdaten dieses Knotens, so wie `RegisterCapacity` sie liest.
|
||||
#
|
||||
# Der größte Speicher, der Platten aufnimmt — NICHT die Summe. Proxmox führt
|
||||
# überlappende Pools (local und local-lvm auf derselben Platte), und zu
|
||||
# summieren hieße, denselben Platz zweimal zu verkaufen.
|
||||
node_total_gb() {
|
||||
pvesm status --content images 2>/dev/null \
|
||||
| awk 'NR > 1 { if ($4 + 0 > max) max = $4 + 0 } END { printf "%d", max / 1048576 }'
|
||||
}
|
||||
|
||||
# Baut den Rumpf für POST /host/register.
|
||||
render_register_payload() {
|
||||
_api_token="$1"
|
||||
_wg_pubkey="$2"
|
||||
|
||||
printf '{"code":"%s","api_token":"%s","wg_pubkey":"%s","node":"%s","pve_version":"%s","total_gb":%s,"total_ram_mb":%s,"cpu_cores":%s,"ssh_host_key":"%s"}' \
|
||||
"$(json_escape "$CLUPILOT_CODE")" \
|
||||
"$(json_escape "$_api_token")" \
|
||||
"$(json_escape "$_wg_pubkey")" \
|
||||
"$(json_escape "$(hostname)")" \
|
||||
"$(json_escape "$(pveversion 2>/dev/null | head -1)")" \
|
||||
"$(node_total_gb)" \
|
||||
"$(awk '/^MemTotal:/ { printf "%d", $2 / 1024; exit }' /proc/meminfo)" \
|
||||
"$(nproc 2>/dev/null || echo 1)" \
|
||||
"$(json_escape "$(ssh_host_fingerprint)")"
|
||||
}
|
||||
|
||||
# POST /host/register — gibt die Antwort aus, oder 1.
|
||||
post_registration() {
|
||||
_payload="$1"
|
||||
|
||||
if command -v curl >/dev/null 2>&1; then
|
||||
curl -sS -f --connect-timeout 10 --max-time 60 \
|
||||
-H 'Content-Type: application/json' -H 'Accept: application/json' \
|
||||
-X POST --data "$_payload" \
|
||||
"${CLUPILOT_API}/host/register" 2>/dev/null
|
||||
return $?
|
||||
fi
|
||||
|
||||
wget -q -O - --timeout=60 \
|
||||
--header='Content-Type: application/json' --header='Accept: application/json' \
|
||||
--post-data="$_payload" \
|
||||
"${CLUPILOT_API}/host/register" 2>/dev/null
|
||||
}
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Der Schlüsseltausch
|
||||
# ---------------------------------------------------------------------------
|
||||
#
|
||||
# Task 6 lässt den Tunnel erst mit bewiesenem Handshake gelten. Hier wird genau
|
||||
# dieser Tunnel im laufenden Betrieb auf einen neuen Schlüssel umgestellt — also
|
||||
# gilt dieselbe Regel, und die Reihenfolge allein genügt nicht: sie sagt, WANN
|
||||
# verworfen wird, nicht OB das Neue trägt.
|
||||
#
|
||||
# 1. neues Paar erzeugen, registrieren, Antwort haben
|
||||
# 2. wg0 auf den neuen privaten Schlüssel umstellen
|
||||
# 3. erneuten Handshake BEWEISEN
|
||||
# 4. erst dann den alten verwerfen
|
||||
#
|
||||
# Kommt der Handshake nicht, wird zurückgestellt und laut gescheitert. Ein Host,
|
||||
# der sich im letzten Schritt selbst aussperrt, ist der eine Fall, den niemand
|
||||
# aus der Ferne repariert — und er passiert am Ende eines Laufs, der bis dahin
|
||||
# alles richtig gemacht hat.
|
||||
swap_wireguard_key() {
|
||||
_new_private="$1"
|
||||
_old_private="$CLUPILOT_WG_PRIVATE"
|
||||
|
||||
CLUPILOT_WG_PRIVATE="$_new_private"
|
||||
printf '%s\n' "$(render_wireguard_config)" > /etc/wireguard/wg0.conf
|
||||
chmod 600 /etc/wireguard/wg0.conf
|
||||
|
||||
systemctl restart wg-quick@wg0 >/dev/null 2>&1 || true
|
||||
|
||||
if wireguard_handshake_proven 10; then
|
||||
log 'Handshake mit dem neuen Schlüssel bewiesen'
|
||||
return 0
|
||||
fi
|
||||
|
||||
log 'Kein Handshake mit dem neuen Schlüssel — es wird auf den alten zurückgestellt'
|
||||
CLUPILOT_WG_PRIVATE="$_old_private"
|
||||
printf '%s\n' "$(render_wireguard_config)" > /etc/wireguard/wg0.conf
|
||||
chmod 600 /etc/wireguard/wg0.conf
|
||||
systemctl restart wg-quick@wg0 >/dev/null 2>&1 || true
|
||||
|
||||
return 1
|
||||
}
|
||||
|
|
@ -0,0 +1,264 @@
|
|||
# shellcheck shell=sh
|
||||
#
|
||||
# Melden und Nachreichen — und die HTTP-Grundlage für alle anderen Abschnitte.
|
||||
#
|
||||
# Diese Datei kennt als Einzige den Weg zu CluPilot. Sie hält zusätzlich
|
||||
# `http_get` und `http_download`, damit der Rückfall von curl auf wget an EINER
|
||||
# Stelle steht: ein Rettungssystem bringt mal das eine mit und mal das andere,
|
||||
# und diese Fallunterscheidung fünfmal zu schreiben heißt, sie viermal falsch zu
|
||||
# schreiben.
|
||||
#
|
||||
# Zwei Regeln, aus denen sich alles andere ergibt:
|
||||
#
|
||||
# 1. `report` schreibt IMMER zuerst lokal, dann erst der Sendeversuch. Vor
|
||||
# `wireguard_joined` gibt es keinen Weg zu CluPilot (Spec §5) — die Zeilen
|
||||
# sammeln sich und werden von `flush_reports` nachgereicht.
|
||||
# 2. Ein gescheiterter Sendeversuch ist NORMAL und darf den Lauf nie abbrechen.
|
||||
# Das Skript läuft unter `set -e`; jede Netzoperation hier ist deshalb
|
||||
# ausdrücklich abgefangen.
|
||||
#
|
||||
# Der Zeitstempel ist der des Ereignisses, nicht der der Ankunft. Sonst sieht
|
||||
# eine zwanzigminütige Installation in der Konsole aus wie eine Sekunde, und
|
||||
# niemand kann erkennen, welcher Abschnitt lange gedauert hat.
|
||||
#
|
||||
# ---------------------------------------------------------------------------
|
||||
# Der Umschlag, gegen den die Plattform schreibt
|
||||
# ---------------------------------------------------------------------------
|
||||
#
|
||||
# Die Abschnittsschlüssel sind laut Plan die einzige Absprache zwischen den zwei
|
||||
# Plänen. Der Umschlag darum war nirgends festgelegt — er wird hier festgelegt,
|
||||
# damit die Plattformseite EINE Stelle zum Nachlesen hat:
|
||||
#
|
||||
# POST {api}/host/progress
|
||||
# Content-Type: application/json
|
||||
#
|
||||
# {"code": "<einmal-code>", "events": [
|
||||
# {"section": "rescue_checked", "state": "done",
|
||||
# "message": "...", "occurred_at": "2026-07-30T18:04:11Z"}
|
||||
# ]}
|
||||
#
|
||||
# `state` ist `done` oder `failed`. Einen Zustand für „läuft gerade" gibt es
|
||||
# absichtlich nicht: die Konsole leitet „offen" aus dem Fehlen einer Meldung ab
|
||||
# (Spec §7), und ein dritter Zustand wäre eine zweite Wahrheit über dieselbe
|
||||
# Sache.
|
||||
#
|
||||
# `message` ist immer vorhanden, notfalls leer — ein Feld, das mal da ist und
|
||||
# mal nicht, ist die teurere Variante.
|
||||
|
||||
# Überschreibbar, damit sich die Melde-Mechanik ohne root und ohne echte
|
||||
# Maschine durchspielen lässt. Im Betrieb setzt niemand die Variable: der
|
||||
# Adminbereich schreibt sie nicht in die kopierte Zeile, und das Skript läuft
|
||||
# ohnehin als root.
|
||||
CLUPILOT_STATE_DIR="${CLUPILOT_STATE_DIR:-/var/lib/clupilot}"
|
||||
CLUPILOT_PROGRESS_LOG="${CLUPILOT_STATE_DIR}/progress.jsonl"
|
||||
CLUPILOT_PROGRESS_SENT="${CLUPILOT_STATE_DIR}/progress.sent"
|
||||
|
||||
# Kurz gehalten, weil das hier im Regelfall gegen eine Adresse läuft, die es
|
||||
# noch nicht gibt. Ein langer Zeitablauf je Abschnitt summiert sich zu Minuten,
|
||||
# in denen nichts passiert und niemand sieht warum.
|
||||
CLUPILOT_HTTP_CONNECT_TIMEOUT=5
|
||||
CLUPILOT_HTTP_MAX_TIME=15
|
||||
|
||||
# Legt das Zustandsverzeichnis an. 0700, weil hier später auch die
|
||||
# Aufrufargumente liegen und der WireGuard-Schlüssel darin steht.
|
||||
init_reporting() {
|
||||
mkdir -p "$CLUPILOT_STATE_DIR"
|
||||
chmod 700 "$CLUPILOT_STATE_DIR"
|
||||
[ -f "$CLUPILOT_PROGRESS_LOG" ] || : > "$CLUPILOT_PROGRESS_LOG"
|
||||
[ -f "$CLUPILOT_PROGRESS_SENT" ] || echo 0 > "$CLUPILOT_PROGRESS_SENT"
|
||||
}
|
||||
|
||||
# Macht aus beliebigem Text den Rumpf eines JSON-Strings.
|
||||
#
|
||||
# Der Text kommt oft aus der Ausgabe eines fehlgeschlagenen Befehls, ist also
|
||||
# nicht unter unserer Kontrolle. Reihenfolge zählt: der Backslash zuerst, sonst
|
||||
# verdoppelt der Lauf danach die gerade gesetzten Fluchtzeichen wieder mit.
|
||||
# Zeilenumbrüche und Tabulatoren werden zu Leerzeichen — eine Meldung ist eine
|
||||
# Zeile, und eine eingebettete Zeile zerlegte die JSONL-Datei.
|
||||
json_escape() {
|
||||
printf '%s' "$1" \
|
||||
| tr '\n\r\t' ' ' \
|
||||
| sed -e 's/\\/\\\\/g' -e 's/"/\\"/g'
|
||||
}
|
||||
|
||||
# Der einzige Zeitstempel-Erzeuger im ganzen Skript. UTC mit `Z`, weil das
|
||||
# Skript auf Maschinen mit jeder denkbaren Zonen-Einstellung läuft und die
|
||||
# Konsole nach R19 ohnehin in UTC speichert und erst bei der Anzeige umrechnet.
|
||||
now_iso() {
|
||||
date -u '+%Y-%m-%dT%H:%M:%SZ'
|
||||
}
|
||||
|
||||
# report <section> <state> [message]
|
||||
#
|
||||
# Gibt immer 0 zurück. Ein Melden, das den Lauf abbricht, wäre die Diagnose, die
|
||||
# den Patienten umbringt.
|
||||
report() {
|
||||
_section="$1"
|
||||
_state="$2"
|
||||
_message="${3:-}"
|
||||
|
||||
printf '{"section":"%s","state":"%s","message":"%s","occurred_at":"%s"}\n' \
|
||||
"$(json_escape "$_section")" \
|
||||
"$(json_escape "$_state")" \
|
||||
"$(json_escape "$_message")" \
|
||||
"$(now_iso)" \
|
||||
>> "$CLUPILOT_PROGRESS_LOG"
|
||||
|
||||
log "[${_state}] ${_section}${_message:+ — ${_message}}"
|
||||
|
||||
flush_reports
|
||||
return 0
|
||||
}
|
||||
|
||||
# Reicht alles nach, was noch nicht bestätigt gesendet wurde.
|
||||
#
|
||||
# Der Stand steht als Zeilenzahl in einer eigenen Datei, statt die Zeilen in der
|
||||
# Protokolldatei umzuschreiben: `progress.jsonl` ist damit reines Anhängen und
|
||||
# übersteht einen Stromausfall mitten im Schreiben ohne Sonderbehandlung.
|
||||
#
|
||||
# Gibt immer 0 zurück.
|
||||
flush_reports() {
|
||||
[ -f "$CLUPILOT_PROGRESS_LOG" ] || return 0
|
||||
|
||||
_sent="$(cat "$CLUPILOT_PROGRESS_SENT" 2>/dev/null || echo 0)"
|
||||
_total="$(wc -l < "$CLUPILOT_PROGRESS_LOG")"
|
||||
_sent="$(printf '%s' "$_sent" | tr -cd '0-9')"
|
||||
[ -n "$_sent" ] || _sent=0
|
||||
|
||||
[ "$_total" -gt "$_sent" ] || return 0
|
||||
|
||||
_pending="$(sed -n "$((_sent + 1)),\$p" "$CLUPILOT_PROGRESS_LOG")"
|
||||
[ -n "$_pending" ] || return 0
|
||||
|
||||
_events="$(printf '%s' "$_pending" | tr '\n' ',' | sed -e 's/,$//')"
|
||||
_body="$(printf '{"code":"%s","events":[%s]}' "$(json_escape "$CLUPILOT_CODE")" "$_events")"
|
||||
|
||||
if post_json "${CLUPILOT_API}/host/progress" "$_body"; then
|
||||
# Erst nach der bestätigten Antwort. Andersherum ginge genau der
|
||||
# Abschnitt verloren, dessen Zustellung gescheitert ist.
|
||||
echo "$_total" > "$CLUPILOT_PROGRESS_SENT"
|
||||
log "nachgereicht: $((_total - _sent)) Meldung(en)"
|
||||
fi
|
||||
|
||||
return 0
|
||||
}
|
||||
|
||||
# section_done <section> — 0, wenn dieser Abschnitt schon einmal `done` gemeldet
|
||||
# hat.
|
||||
#
|
||||
# Die Fortschrittsdatei IST der Zustand. Es gibt keine zweite Buchführung
|
||||
# darüber, was schon lief — eine zweite wäre eine zweite Wahrheit, und nach dem
|
||||
# Neustart in Task 3 wäre genau sie diejenige, die fehlt.
|
||||
#
|
||||
# Bewusst gegen den festen Rumpf geprüft, den `report` schreibt, statt gegen ein
|
||||
# lose passendes Muster: `grep rescue_checked` fände auch eine Meldung, in deren
|
||||
# `message` der Name zufällig vorkommt.
|
||||
section_done() {
|
||||
[ -f "$CLUPILOT_PROGRESS_LOG" ] || return 1
|
||||
|
||||
grep -q "^{\"section\":\"$1\",\"state\":\"done\"," "$CLUPILOT_PROGRESS_LOG"
|
||||
}
|
||||
|
||||
# run_section <section> <funktion> — führt einen Abschnitt aus, wenn er noch
|
||||
# nicht erledigt ist, und meldet sein Ergebnis.
|
||||
#
|
||||
# Das `if` statt `&&` ist Absicht: unter `set -e` bricht ein `&&`-Ausdruck den
|
||||
# Lauf ab, bevor `report ... failed` je liefe — und dann stünde in der Konsole
|
||||
# nichts, obwohl genau dieser Fall der ist, für den sie gebaut wurde.
|
||||
run_section() {
|
||||
_key="$1"
|
||||
_fn="$2"
|
||||
|
||||
if section_done "$_key"; then
|
||||
log "übersprungen: ${_key} (schon erledigt)"
|
||||
return 0
|
||||
fi
|
||||
|
||||
if "$_fn"; then
|
||||
report "$_key" done "${CLUPILOT_SECTION_NOTE:-}"
|
||||
CLUPILOT_SECTION_NOTE=''
|
||||
return 0
|
||||
fi
|
||||
|
||||
report "$_key" failed "${CLUPILOT_SECTION_NOTE:-abgebrochen in ${_key}}"
|
||||
CLUPILOT_SECTION_NOTE=''
|
||||
return 1
|
||||
}
|
||||
|
||||
# post_json <url> <body> — 0 bei 2xx, sonst 1. Schreibt nichts nach stdout.
|
||||
#
|
||||
# Der Rückfall auf wget ist kein Luxus: Rettungssysteme sind unterschiedlich
|
||||
# bestückt, und genau hier ist noch keine Gelegenheit gewesen, etwas
|
||||
# nachzuinstallieren.
|
||||
post_json() {
|
||||
_url="$1"
|
||||
_payload="$2"
|
||||
|
||||
if command -v curl >/dev/null 2>&1; then
|
||||
# `-f` lässt curl bei 4xx/5xx mit Fehler enden, statt die Fehlerseite
|
||||
# als Erfolg zu behandeln.
|
||||
curl -sS -f \
|
||||
--connect-timeout "$CLUPILOT_HTTP_CONNECT_TIMEOUT" \
|
||||
--max-time "$CLUPILOT_HTTP_MAX_TIME" \
|
||||
-H 'Content-Type: application/json' \
|
||||
-H 'Accept: application/json' \
|
||||
-X POST --data "$_payload" \
|
||||
"$_url" >/dev/null 2>&1
|
||||
return $?
|
||||
fi
|
||||
|
||||
if command -v wget >/dev/null 2>&1; then
|
||||
wget -q -O /dev/null \
|
||||
--timeout="$CLUPILOT_HTTP_MAX_TIME" \
|
||||
--header='Content-Type: application/json' \
|
||||
--header='Accept: application/json' \
|
||||
--post-data="$_payload" \
|
||||
"$_url"
|
||||
return $?
|
||||
fi
|
||||
|
||||
return 1
|
||||
}
|
||||
|
||||
# http_get <url> — Rumpf nach stdout, 0 bei Erfolg.
|
||||
#
|
||||
# Für kleine Antworten: Verzeichnislisten, Prüfsummendateien. Nicht für ISOs;
|
||||
# dafür gibt es `http_download`, das nicht durch den Speicher geht.
|
||||
http_get() {
|
||||
if command -v curl >/dev/null 2>&1; then
|
||||
curl -sS -fL --connect-timeout 10 --max-time 120 "$1" 2>/dev/null
|
||||
return $?
|
||||
fi
|
||||
|
||||
if command -v wget >/dev/null 2>&1; then
|
||||
wget -q -O - --timeout=120 "$1" 2>/dev/null
|
||||
return $?
|
||||
fi
|
||||
|
||||
return 1
|
||||
}
|
||||
|
||||
# http_download <url> <ziel> — 0 bei Erfolg.
|
||||
#
|
||||
# Kein `-C -`/`--continue`: eine halb geladene ISO, die beim zweiten Lauf
|
||||
# fortgesetzt wird, ergibt eine Datei mit richtiger Größe und falschem Inhalt,
|
||||
# wenn sich die Quelle dazwischen geändert hat. Lieber ganz neu — die Prüfsumme
|
||||
# fängt es zwar auch ab, aber erst nach dem Herunterladen.
|
||||
http_download() {
|
||||
_url="$1"
|
||||
_target="$2"
|
||||
|
||||
rm -f "$_target"
|
||||
|
||||
if command -v curl >/dev/null 2>&1; then
|
||||
curl -sS -fL --connect-timeout 10 --max-time 3600 -o "$_target" "$_url" 2>/dev/null
|
||||
return $?
|
||||
fi
|
||||
|
||||
if command -v wget >/dev/null 2>&1; then
|
||||
wget -q -O "$_target" --timeout=3600 "$_url" 2>/dev/null
|
||||
return $?
|
||||
fi
|
||||
|
||||
return 1
|
||||
}
|
||||
|
|
@ -0,0 +1,252 @@
|
|||
# shellcheck shell=sh
|
||||
#
|
||||
# Die goldene Vorlage: ein Debian-13-Cloud-Abbild, in das Docker, Compose und
|
||||
# die Nextcloud-Aufstellung eingebaut werden, konvertiert zur Proxmox-Vorlage
|
||||
# auf VMID 9000.
|
||||
#
|
||||
# ---------------------------------------------------------------------------
|
||||
# Die drei Fallen aus Block A
|
||||
# ---------------------------------------------------------------------------
|
||||
#
|
||||
# Sie sind nicht verhandelbar, und sie werden hier alle drei GEPRÜFT statt
|
||||
# angenommen — jede von ihnen hat einmal einen bezahlten Auftrag gekostet:
|
||||
#
|
||||
# 1. `user: www-data` am `app`-Dienst der Compose-Datei. Ohne sie schlagen ALLE
|
||||
# `occ`-Aufrufe fehl. Steht in `assets/docker-compose.yml`; hier wird
|
||||
# nachgesehen, dass sie es auch ins Abbild geschafft hat.
|
||||
# 2. Die Root-Partition ist die LETZTE auf der Platte und liegt NICHT auf LVM.
|
||||
# `GrowGuestFilesystem` erkennt LVM absichtlich nicht und schriebe sonst ein
|
||||
# Kontingent über ein Dateisystem, das nie gewachsen ist.
|
||||
# 3. `qemu-guest-agent` MUSS im Abbild sein — `WaitForGuestAgent` hängt daran,
|
||||
# und ohne ihn wartet jede Bereitstellung bis zum Zeitablauf.
|
||||
#
|
||||
# ---------------------------------------------------------------------------
|
||||
# Warum das Cloud-Abbild und nicht der Installer
|
||||
# ---------------------------------------------------------------------------
|
||||
#
|
||||
# Debians Cloud-Abbild bringt genau die Eigenschaften mit, die Falle 2 verlangt:
|
||||
# eine einzige Root-Partition, als letzte auf der Platte, ohne LVM. Und es hat
|
||||
# cloud-init, worüber die Pipeline beim Klonen die `.env` in den Gast schreibt.
|
||||
# Beides selbst zu bauen hieße, es beim ersten Kunden zu prüfen.
|
||||
|
||||
CLUPILOT_TEMPLATE_VMID="${CLUPILOT_TEMPLATE_VMID:-9000}"
|
||||
CLUPILOT_TEMPLATE_NAME='clupilot-nextcloud-vorlage'
|
||||
CLUPILOT_CLOUD_IMAGE_BASE="${CLUPILOT_CLOUD_IMAGE_BASE:-https://cloud.debian.org/images/cloud/trixie/latest}"
|
||||
CLUPILOT_CLOUD_IMAGE='debian-13-genericcloud-amd64.qcow2'
|
||||
|
||||
# Wohin die Vorlagenplatte kommt. `local-zfs` ist das, was der PVE-Installer auf
|
||||
# ZFS anlegt; `local-lvm` wäre es bei ext4. Zur Laufzeit gesucht statt geraten.
|
||||
detect_vm_storage() {
|
||||
for _candidate in local-zfs local-lvm local; do
|
||||
if pvesm status --storage "$_candidate" >/dev/null 2>&1; then
|
||||
# Muss Platten aufnehmen können. `local` kann das oft NICHT — dort
|
||||
# liegen nur Abbilder und Sicherungen, und ein `qm importdisk`
|
||||
# dorthin scheitert erst nach dem Kopieren.
|
||||
if pvesm status --storage "$_candidate" --content images >/dev/null 2>&1; then
|
||||
printf '%s' "$_candidate"
|
||||
return 0
|
||||
fi
|
||||
fi
|
||||
done
|
||||
|
||||
# Sonst der erste Speicher, der Platten aufnimmt.
|
||||
pvesm status --content images 2>/dev/null | awk 'NR > 1 { print $1; exit }'
|
||||
}
|
||||
|
||||
# Lädt das Cloud-Abbild und prüft es gegen Debians SHA512SUMS.
|
||||
fetch_cloud_image() {
|
||||
_target="$1"
|
||||
|
||||
if ! http_download "${CLUPILOT_CLOUD_IMAGE_BASE}/${CLUPILOT_CLOUD_IMAGE}" "$_target"; then
|
||||
log "Cloud-Abbild nicht ladbar: ${CLUPILOT_CLOUD_IMAGE_BASE}/${CLUPILOT_CLOUD_IMAGE}"
|
||||
return 1
|
||||
fi
|
||||
|
||||
_sums="$(http_get "${CLUPILOT_CLOUD_IMAGE_BASE}/SHA512SUMS" || true)"
|
||||
if [ -z "$_sums" ]; then
|
||||
log 'SHA512SUMS von cloud.debian.org nicht abrufbar'
|
||||
return 1
|
||||
fi
|
||||
|
||||
_expected="$(printf '%s\n' "$_sums" | awk -v n="$CLUPILOT_CLOUD_IMAGE" '$2 == n || $2 == "*" n { print $1; exit }')"
|
||||
if [ -z "$_expected" ]; then
|
||||
log "keine Prüfsumme für ${CLUPILOT_CLOUD_IMAGE}"
|
||||
return 1
|
||||
fi
|
||||
|
||||
_actual="$(sha512sum "$_target" | awk '{ print $1 }')"
|
||||
if [ "$_actual" != "$_expected" ]; then
|
||||
log 'Prüfsumme des Cloud-Abbilds stimmt nicht'
|
||||
rm -f "$_target"
|
||||
return 1
|
||||
fi
|
||||
|
||||
log 'Cloud-Abbild geladen, Prüfsumme stimmt'
|
||||
return 0
|
||||
}
|
||||
|
||||
# FALLE 2, geprüft statt angenommen.
|
||||
#
|
||||
# Zwei Aussagen: kein LVM, und die Root-Partition ist die letzte auf der Platte.
|
||||
# `virt-filesystems` beantwortet beide, ohne das Abbild einzuhängen.
|
||||
verify_image_layout() {
|
||||
_image="$1"
|
||||
|
||||
_fs="$(virt-filesystems --long --parts --blkdevs -h -a "$_image" 2>/dev/null || true)"
|
||||
if [ -z "$_fs" ]; then
|
||||
log 'virt-filesystems liefert nichts — Abbildaufbau nicht prüfbar'
|
||||
return 1
|
||||
fi
|
||||
|
||||
if printf '%s' "$_fs" | grep -qiE '(lvm|/dev/[a-z0-9_-]+/[a-z0-9_-]+)'; then
|
||||
log 'Das Abbild benutzt LVM. GrowGuestFilesystem erkennt LVM absichtlich nicht und schriebe ein Kontingent über ein Dateisystem, das nie gewachsen ist.'
|
||||
return 1
|
||||
fi
|
||||
|
||||
# Die Wurzel ist die Partition mit der höchsten Nummer, wenn sie die letzte
|
||||
# ist. Anders gefragt: gibt es hinter der Root-Partition noch eine?
|
||||
_last_part="$(printf '%s\n' "$_fs" | awk '/^\/dev\/[a-z]+[0-9]+/ { print $1 }' | sort -V | tail -1)"
|
||||
_root_part="$(virt-filesystems --long -a "$_image" 2>/dev/null | awk '$0 !~ /swap/ && /ext4|xfs|btrfs/ { print $1; }' | sort -V | tail -1)"
|
||||
|
||||
if [ -n "$_last_part" ] && [ -n "$_root_part" ] && [ "$_last_part" != "$_root_part" ]; then
|
||||
log "Die Root-Partition (${_root_part}) ist nicht die letzte auf der Platte (${_last_part}). GrowGuestFilesystem wächst nur die letzte."
|
||||
return 1
|
||||
fi
|
||||
|
||||
log "Abbildaufbau in Ordnung: kein LVM, Wurzel auf ${_root_part:-(unbestimmt)} als letzte Partition"
|
||||
return 0
|
||||
}
|
||||
|
||||
# Baut alles in das Abbild ein, was die Vorlage ausmacht.
|
||||
#
|
||||
# `virt-customize` statt „VM starten und hineinreden": es braucht kein Netz im
|
||||
# Gast, keine Anmeldung und keinen laufenden Rechner, und es ist wiederholbar.
|
||||
customise_cloud_image() {
|
||||
_image="$1"
|
||||
_compose="$2"
|
||||
|
||||
# Ohne diese Zeile hängt jede Bereitstellung an WaitForGuestAgent bis zum
|
||||
# Zeitablauf — Falle 3.
|
||||
_packages='qemu-guest-agent,ca-certificates,curl,gnupg'
|
||||
|
||||
if ! virt-customize -a "$_image" \
|
||||
--install "$_packages" \
|
||||
--run-command 'systemctl enable qemu-guest-agent' \
|
||||
>> "${CLUPILOT_WORK_DIR}/virt-customize.log" 2>&1
|
||||
then
|
||||
log 'virt-customize: Grundpakete ließen sich nicht einbauen'
|
||||
return 1
|
||||
fi
|
||||
|
||||
# Docker aus der Quelle von Docker selbst, nicht aus Debian: `docker.io` hat
|
||||
# kein Compose-Plugin, und `docker-compose` in Debian ist die alte
|
||||
# Python-Fassung, die diese Compose-Datei nicht liest.
|
||||
if ! virt-customize -a "$_image" \
|
||||
--run-command 'install -m 0755 -d /etc/apt/keyrings' \
|
||||
--run-command 'curl -fsSL https://download.docker.com/linux/debian/gpg -o /etc/apt/keyrings/docker.asc' \
|
||||
--run-command 'chmod a+r /etc/apt/keyrings/docker.asc' \
|
||||
--run-command 'echo "deb [arch=amd64 signed-by=/etc/apt/keyrings/docker.asc] https://download.docker.com/linux/debian trixie stable" > /etc/apt/sources.list.d/docker.list' \
|
||||
--run-command 'apt-get update' \
|
||||
--install 'docker-ce,docker-ce-cli,containerd.io,docker-buildx-plugin,docker-compose-plugin' \
|
||||
--run-command 'systemctl enable docker' \
|
||||
>> "${CLUPILOT_WORK_DIR}/virt-customize.log" 2>&1
|
||||
then
|
||||
log 'virt-customize: Docker ließ sich nicht einbauen'
|
||||
return 1
|
||||
fi
|
||||
|
||||
if ! virt-customize -a "$_image" \
|
||||
--mkdir /opt/nextcloud \
|
||||
--copy-in "${_compose}:/opt/nextcloud" \
|
||||
--run-command 'systemctl enable docker.service containerd.service' \
|
||||
>> "${CLUPILOT_WORK_DIR}/virt-customize.log" 2>&1
|
||||
then
|
||||
log 'virt-customize: die Compose-Datei kam nicht ins Abbild'
|
||||
return 1
|
||||
fi
|
||||
|
||||
return 0
|
||||
}
|
||||
|
||||
# Prüft im fertigen Abbild nach, dass die drei Fallen wirklich zu sind.
|
||||
#
|
||||
# Nach dem Einbauen, nicht davor: `virt-customize` meldet auch dann Erfolg, wenn
|
||||
# apt ein Paket stillschweigend nicht installiert hat.
|
||||
verify_image_contents() {
|
||||
_image="$1"
|
||||
|
||||
if ! virt-ls -a "$_image" /usr/bin 2>/dev/null | grep -q '^qemu-ga$'; then
|
||||
log 'qemu-guest-agent ist NICHT im Abbild (Falle 3) — jede Bereitstellung hinge an WaitForGuestAgent'
|
||||
return 1
|
||||
fi
|
||||
|
||||
_compose_in_image="$(virt-cat -a "$_image" /opt/nextcloud/docker-compose.yml 2>/dev/null || true)"
|
||||
if [ -z "$_compose_in_image" ]; then
|
||||
log '/opt/nextcloud/docker-compose.yml fehlt im Abbild'
|
||||
return 1
|
||||
fi
|
||||
|
||||
if ! printf '%s' "$_compose_in_image" | grep -qE '^[[:space:]]*user:[[:space:]]*www-data[[:space:]]*$'; then
|
||||
log 'Die Compose-Datei im Abbild hat kein `user: www-data` (Falle 1) — alle occ-Aufrufe schlügen fehl'
|
||||
return 1
|
||||
fi
|
||||
|
||||
if ! virt-ls -a "$_image" /usr/libexec/docker/cli-plugins 2>/dev/null | grep -q 'docker-compose'; then
|
||||
if ! virt-ls -a "$_image" /usr/lib/docker/cli-plugins 2>/dev/null | grep -q 'docker-compose'; then
|
||||
log 'Das Compose-Plugin ist nicht im Abbild'
|
||||
return 1
|
||||
fi
|
||||
fi
|
||||
|
||||
log 'Abbildinhalt in Ordnung: qemu-guest-agent, Compose-Plugin, user: www-data'
|
||||
return 0
|
||||
}
|
||||
|
||||
# Legt die VM an, hängt die Platte ein und macht eine Vorlage daraus.
|
||||
create_proxmox_template() {
|
||||
_image="$1"
|
||||
_storage="$2"
|
||||
_vmid="$CLUPILOT_TEMPLATE_VMID"
|
||||
|
||||
if qm status "$_vmid" >/dev/null 2>&1; then
|
||||
log "VMID ${_vmid} existiert bereits — wird ersetzt"
|
||||
qm destroy "$_vmid" --purge >/dev/null 2>&1 || true
|
||||
fi
|
||||
|
||||
qm create "$_vmid" \
|
||||
--name "$CLUPILOT_TEMPLATE_NAME" \
|
||||
--memory 4096 \
|
||||
--cores 2 \
|
||||
--net0 "virtio,bridge=vmbr0" \
|
||||
--scsihw virtio-scsi-single \
|
||||
--ostype l26 \
|
||||
--agent enabled=1 \
|
||||
--serial0 socket \
|
||||
--vga serial0 \
|
||||
>> "${CLUPILOT_WORK_DIR}/qm.log" 2>&1 || { log 'qm create fehlgeschlagen'; return 1; }
|
||||
|
||||
# Platte importieren und als scsi0 einhängen.
|
||||
qm importdisk "$_vmid" "$_image" "$_storage" >> "${CLUPILOT_WORK_DIR}/qm.log" 2>&1 \
|
||||
|| { log 'qm importdisk fehlgeschlagen'; return 1; }
|
||||
|
||||
qm set "$_vmid" --scsi0 "${_storage}:vm-${_vmid}-disk-0,discard=on,ssd=1" \
|
||||
>> "${CLUPILOT_WORK_DIR}/qm.log" 2>&1 || { log 'Platte ließ sich nicht einhängen'; return 1; }
|
||||
|
||||
# cloud-init: darüber schreibt die Pipeline beim Klonen die .env in den Gast.
|
||||
qm set "$_vmid" --ide2 "${_storage}:cloudinit" --boot order=scsi0 \
|
||||
>> "${CLUPILOT_WORK_DIR}/qm.log" 2>&1 || { log 'cloud-init-Laufwerk ließ sich nicht anlegen'; return 1; }
|
||||
|
||||
qm template "$_vmid" >> "${CLUPILOT_WORK_DIR}/qm.log" 2>&1 \
|
||||
|| { log 'qm template fehlgeschlagen'; return 1; }
|
||||
|
||||
return 0
|
||||
}
|
||||
|
||||
# Ist es wirklich eine Vorlage?
|
||||
#
|
||||
# `VerifyVmTemplate` prüft heute nur die EXISTENZ von 9000, nicht das Merkmal —
|
||||
# der Handoff hält das als eigenen Fund fest. Eine VM, die zufällig 9000 heißt,
|
||||
# bestünde diese Prüfung. `template: 1` besteht nur eine Vorlage.
|
||||
template_is_really_a_template() {
|
||||
qm config "$CLUPILOT_TEMPLATE_VMID" 2>/dev/null | grep -qE '^template:[[:space:]]*1[[:space:]]*$'
|
||||
}
|
||||
|
|
@ -0,0 +1,262 @@
|
|||
# shellcheck shell=sh
|
||||
#
|
||||
# Traefik: Binary plus systemd-Unit. **Kein Docker auf dem Hypervisor** (Handoff
|
||||
# §1) — nftables bleibt einziger Eigentümer der Host-Firewallregeln, und ein
|
||||
# Docker-Dienst brächte seine eigene Kette mit.
|
||||
#
|
||||
# ---------------------------------------------------------------------------
|
||||
# Die Namen, die passen müssen
|
||||
# ---------------------------------------------------------------------------
|
||||
#
|
||||
# `SshTraefikWriter::render()` gibt aus, was der Routen-Endpunkt später liefert,
|
||||
# und die statische Konfiguration hier muss dazu passen. Nachgelesen, nicht
|
||||
# geraten:
|
||||
#
|
||||
# entryPoints: ["websecure"]
|
||||
# tls.certResolver: "letsencrypt"
|
||||
# service -> loadBalancer.servers[].url: "http://<backend>:80"
|
||||
#
|
||||
# Heißt der Entrypoint hier anders, laufen die Router ins Leere und Traefik
|
||||
# meldet trotzdem einen sauberen Start.
|
||||
#
|
||||
# ---------------------------------------------------------------------------
|
||||
# Reihenfolge: der Token kommt erst später
|
||||
# ---------------------------------------------------------------------------
|
||||
#
|
||||
# Der `http`-Provider braucht den dauerhaften Host-Token, und den gibt es erst
|
||||
# mit `POST /host/register` in Abschnitt `registered`. Die statische
|
||||
# Konfiguration wird deshalb ZWEIMAL geschrieben: hier mit leerem Token, damit
|
||||
# der Dienst steht und 80/443 belegt sind, und in Task 9 noch einmal mit dem
|
||||
# echten. Erst dort lässt sich beweisen, dass eine Routentabelle wirklich
|
||||
# abgerufen wurde.
|
||||
|
||||
CLUPILOT_TRAEFIK_BIN='/usr/local/bin/traefik'
|
||||
CLUPILOT_TRAEFIK_DIR='/etc/traefik'
|
||||
CLUPILOT_TRAEFIK_TOKEN_FILE='/etc/traefik/host-token'
|
||||
CLUPILOT_TRAEFIK_API='http://127.0.0.1:8080'
|
||||
|
||||
# Neueste stabile v3 aus der Release-Liste.
|
||||
#
|
||||
# Nicht fest verdrahtet, aus demselben Grund wie bei der ISO: eine feste Nummer
|
||||
# ist in ein paar Monaten eine 404 mitten in einer Übernahme.
|
||||
discover_traefik_version() {
|
||||
_json="$(http_get 'https://api.github.com/repos/traefik/traefik/releases/latest' || true)"
|
||||
[ -n "$_json" ] || return 1
|
||||
|
||||
printf '%s' "$_json" \
|
||||
| sed -n 's/.*"tag_name"[[:space:]]*:[[:space:]]*"\(v3[0-9.]*\)".*/\1/p' \
|
||||
| head -1
|
||||
}
|
||||
|
||||
# Lädt das Binary und prüft es gegen die Prüfsummendatei der Ausgabe.
|
||||
#
|
||||
# Dieselbe Begründung wie bei der ISO: was auf 80 und 443 lauscht und die
|
||||
# Zertifikate aller Kunden hält, wird nicht ungeprüft aus dem Netz übernommen.
|
||||
install_traefik_binary() {
|
||||
_version="$1"
|
||||
_plain="${_version#v}"
|
||||
_base="https://github.com/traefik/traefik/releases/download/${_version}"
|
||||
_archive="traefik_${_version}_linux_amd64.tar.gz"
|
||||
_sums="traefik_${_version}_checksums.txt"
|
||||
|
||||
_tmp="${CLUPILOT_WORK_DIR}/traefik"
|
||||
rm -rf "$_tmp"
|
||||
mkdir -p "$_tmp"
|
||||
|
||||
if ! http_download "${_base}/${_archive}" "${_tmp}/${_archive}"; then
|
||||
log "Traefik ${_version} nicht ladbar"
|
||||
return 1
|
||||
fi
|
||||
|
||||
_sumfile="$(http_get "${_base}/${_sums}" || true)"
|
||||
if [ -z "$_sumfile" ]; then
|
||||
log "Prüfsummendatei ${_sums} nicht abrufbar"
|
||||
return 1
|
||||
fi
|
||||
|
||||
_expected="$(printf '%s\n' "$_sumfile" | awk -v n="$_archive" '$2 == n || $2 == "*" n { print $1; exit }')"
|
||||
if [ -z "$_expected" ]; then
|
||||
log "keine Prüfsumme für ${_archive}"
|
||||
return 1
|
||||
fi
|
||||
|
||||
_actual="$(sha256sum "${_tmp}/${_archive}" | awk '{ print $1 }')"
|
||||
if [ "$_actual" != "$_expected" ]; then
|
||||
log "Traefik-Prüfsumme stimmt nicht: erwartet ${_expected}, bekommen ${_actual}"
|
||||
return 1
|
||||
fi
|
||||
|
||||
tar xzf "${_tmp}/${_archive}" -C "$_tmp" traefik
|
||||
install -m 755 "${_tmp}/traefik" "$CLUPILOT_TRAEFIK_BIN"
|
||||
rm -rf "$_tmp"
|
||||
|
||||
log "Traefik ${_plain} installiert, Prüfsumme stimmt"
|
||||
return 0
|
||||
}
|
||||
|
||||
# Die statische Konfiguration.
|
||||
#
|
||||
# `token` darf leer sein — dann steht der Dienst, holt aber keine Tabelle. Das
|
||||
# ist der Zustand zwischen Abschnitt 7 und Abschnitt 9.
|
||||
write_traefik_static_config() {
|
||||
_token="${1:-}"
|
||||
_endpoint="${CLUPILOT_API}/host/routes"
|
||||
|
||||
mkdir -p "$CLUPILOT_TRAEFIK_DIR"
|
||||
chmod 755 "$CLUPILOT_TRAEFIK_DIR"
|
||||
|
||||
# acme.json hält die privaten Schlüssel aller Kundenzertifikate. Traefik
|
||||
# weigert sich, sie zu benutzen, wenn die Rechte weiter sind als 600 — und
|
||||
# das ist gut so.
|
||||
if [ ! -f "${CLUPILOT_TRAEFIK_DIR}/acme.json" ]; then
|
||||
: > "${CLUPILOT_TRAEFIK_DIR}/acme.json"
|
||||
fi
|
||||
chmod 600 "${CLUPILOT_TRAEFIK_DIR}/acme.json"
|
||||
|
||||
cat > "${CLUPILOT_TRAEFIK_DIR}/traefik.yml" <<EOF
|
||||
# Von CluPilots Bootstrap geschrieben. Nicht von Hand ändern — der nächste Lauf
|
||||
# überschreibt die Datei.
|
||||
|
||||
global:
|
||||
checkNewVersion: false
|
||||
sendAnonymousUsage: false
|
||||
|
||||
entryPoints:
|
||||
web:
|
||||
address: ":80"
|
||||
http:
|
||||
redirections:
|
||||
entryPoint:
|
||||
to: websecure
|
||||
scheme: https
|
||||
websecure:
|
||||
address: ":443"
|
||||
# Nur auf der Loopback-Adresse. Der Zustand dieses Dienstes geht niemanden
|
||||
# außerhalb der Maschine etwas an, und nftables ließe 8080 ohnehin nicht durch.
|
||||
traefik:
|
||||
address: "127.0.0.1:8080"
|
||||
|
||||
# Die Namen kommen aus SshTraefikWriter::render(): der Routen-Endpunkt liefert
|
||||
# entryPoints ["websecure"] und certResolver "letsencrypt". Wer sie hier ändert,
|
||||
# muss sie dort mitändern — sonst laufen die Router ins Leere und Traefik startet
|
||||
# trotzdem sauber.
|
||||
certificatesResolvers:
|
||||
letsencrypt:
|
||||
acme:
|
||||
email: "${CLUPILOT_ACME_EMAIL}"
|
||||
storage: "${CLUPILOT_TRAEFIK_DIR}/acme.json"
|
||||
httpChallenge:
|
||||
entryPoint: web
|
||||
|
||||
providers:
|
||||
http:
|
||||
endpoint: "${_endpoint}"
|
||||
pollInterval: "30s"
|
||||
pollTimeout: "10s"
|
||||
headers:
|
||||
Authorization: "Bearer ${_token}"
|
||||
|
||||
api:
|
||||
dashboard: false
|
||||
insecure: true
|
||||
|
||||
ping:
|
||||
entryPoint: traefik
|
||||
|
||||
log:
|
||||
level: INFO
|
||||
filePath: "/var/log/traefik.log"
|
||||
|
||||
accessLog:
|
||||
filePath: "/var/log/traefik-access.log"
|
||||
EOF
|
||||
chmod 640 "${CLUPILOT_TRAEFIK_DIR}/traefik.yml"
|
||||
|
||||
if [ -n "$_token" ]; then
|
||||
umask 077
|
||||
printf '%s\n' "$_token" > "$CLUPILOT_TRAEFIK_TOKEN_FILE"
|
||||
chmod 600 "$CLUPILOT_TRAEFIK_TOKEN_FILE"
|
||||
fi
|
||||
}
|
||||
|
||||
write_traefik_unit() {
|
||||
cat > /etc/systemd/system/traefik.service <<EOF
|
||||
[Unit]
|
||||
Description=Traefik (von CluPilot verwaltet)
|
||||
Documentation=https://doc.traefik.io/traefik/
|
||||
After=network-online.target
|
||||
Wants=network-online.target
|
||||
|
||||
[Service]
|
||||
Type=notify
|
||||
ExecStart=${CLUPILOT_TRAEFIK_BIN} --configFile=${CLUPILOT_TRAEFIK_DIR}/traefik.yml
|
||||
Restart=on-failure
|
||||
RestartSec=5
|
||||
|
||||
# Braucht 80 und 443 und sonst nichts. Ohne diese Zeile liefe ein Dienst als
|
||||
# root, der von außen erreichbar ist — die eine Stelle, an der das am wenigsten
|
||||
# sein darf.
|
||||
AmbientCapabilities=CAP_NET_BIND_SERVICE
|
||||
CapabilityBoundingSet=CAP_NET_BIND_SERVICE
|
||||
NoNewPrivileges=true
|
||||
ProtectSystem=strict
|
||||
ProtectHome=true
|
||||
PrivateTmp=true
|
||||
ReadWritePaths=${CLUPILOT_TRAEFIK_DIR} /var/log
|
||||
|
||||
[Install]
|
||||
WantedBy=multi-user.target
|
||||
EOF
|
||||
|
||||
systemctl daemon-reload
|
||||
}
|
||||
|
||||
traefik_answers_ping() {
|
||||
_tries="${1:-15}"
|
||||
while [ "$_tries" -gt 0 ]; do
|
||||
if http_get "${CLUPILOT_TRAEFIK_API}/ping" >/dev/null 2>&1; then
|
||||
return 0
|
||||
fi
|
||||
_tries=$((_tries - 1))
|
||||
sleep 2
|
||||
done
|
||||
return 1
|
||||
}
|
||||
|
||||
# Lauschen 80 und 443 wirklich?
|
||||
#
|
||||
# „Der Dienst läuft" und „der Port ist belegt" sind zwei Aussagen. Traefik
|
||||
# startet auch dann sauber, wenn ein Entrypoint wegen eines Tippfehlers gar
|
||||
# nicht angelegt wurde.
|
||||
traefik_ports_bound() {
|
||||
for _port in 80 443; do
|
||||
if ! ss -ltn "sport = :${_port}" 2>/dev/null | grep -q LISTEN; then
|
||||
log "Port ${_port} ist nicht belegt"
|
||||
return 1
|
||||
fi
|
||||
done
|
||||
return 0
|
||||
}
|
||||
|
||||
# Holt der Host seine Routentabelle wirklich?
|
||||
#
|
||||
# Direkt gefragt, mit demselben Token und derselben Adresse, die in der
|
||||
# statischen Konfiguration stehen. Über Traefiks eigene Router zu gehen wäre
|
||||
# hier kein Beweis: ein frischer Host hat noch keine Kunden, also ist die
|
||||
# Tabelle mit Recht leer, und „null Router" hieße dann sowohl „alles gut" als
|
||||
# auch „nichts abgerufen".
|
||||
host_routes_reachable() {
|
||||
_token="$1"
|
||||
[ -n "$_token" ] || return 1
|
||||
|
||||
if command -v curl >/dev/null 2>&1; then
|
||||
curl -sS -f --connect-timeout 5 --max-time 15 \
|
||||
-H "Authorization: Bearer ${_token}" \
|
||||
"${CLUPILOT_API}/host/routes" >/dev/null 2>&1
|
||||
return $?
|
||||
fi
|
||||
|
||||
wget -q -O /dev/null --timeout=15 \
|
||||
--header="Authorization: Bearer ${_token}" \
|
||||
"${CLUPILOT_API}/host/routes"
|
||||
}
|
||||
|
|
@ -0,0 +1,305 @@
|
|||
# Runbook: Wenn die Host-Übernahme stehenbleibt
|
||||
|
||||
Der Bootstrap läuft in neun Abschnitten. Bleibt einer davon aus, zeigt die
|
||||
Konsole **welcher** — nicht „irgendetwas ging schief". Dieses Runbook sagt je
|
||||
Abschnitt: woran man es merkt, wo man nachsieht, und was der übliche Grund ist.
|
||||
|
||||
---
|
||||
|
||||
## Die eine Regel, die über allen steht
|
||||
|
||||
**Eine halb installierte Maschine wird neu aufgesetzt, nicht nachgebessert.**
|
||||
|
||||
Nicht aus Bequemlichkeit. Der Bootstrap ist so gebaut, dass ein zweiter Lauf auf
|
||||
einer frisch aufgesetzten Maschine immer gleich ausgeht — ein Lauf auf einer
|
||||
Maschine, an der jemand von Hand etwas gerade gerückt hat, ist das nicht mehr.
|
||||
Wer nachbessert, tauscht ein wiederholbares Ergebnis gegen ein einmaliges, und
|
||||
merkt den Unterschied erst beim nächsten Host.
|
||||
|
||||
Der Weg zurück ist immer derselbe:
|
||||
|
||||
1. Im Adminbereich einen **neuen** Host anlegen. Der Einmal-Code ist verbraucht;
|
||||
die Seite zeigt ihn nicht wieder her, und das ist Absicht.
|
||||
2. Beim Anbieter das **Rettungssystem** starten.
|
||||
3. Die neue Zeile einfügen.
|
||||
|
||||
Der alte Host-Eintrag wird im Adminbereich entfernt. Solange er steht, hält er
|
||||
seine Tunneladresse belegt.
|
||||
|
||||
**Ausnahme:** Bleibt der Lauf in `rescue_checked` stehen, ist noch **nichts**
|
||||
geschrieben worden. Dort ist Nachbessern richtig — es ist der einzige Abschnitt,
|
||||
der die Platte nicht angefasst hat.
|
||||
|
||||
---
|
||||
|
||||
## Wo alles steht
|
||||
|
||||
| Was | Wo |
|
||||
|---|---|
|
||||
| Fortschritt, Zeile je Abschnitt | `/var/lib/clupilot/progress.jsonl` |
|
||||
| Was schon gesendet wurde (Zeilenzahl) | `/var/lib/clupilot/progress.sent` |
|
||||
| Laufprotokoll des Skripts | `/var/lib/clupilot/bootstrap.log` |
|
||||
| Aufrufargumente (nur root) | `/var/lib/clupilot/arguments` |
|
||||
| Arbeitsverzeichnis: ISO, Protokolle | `/var/tmp/clupilot-work/` |
|
||||
| Installer-Protokoll | `/var/tmp/clupilot-work/qemu.log` |
|
||||
| Antwortdatei des Installers | `/var/tmp/clupilot-work/answer.toml` |
|
||||
| Traefik | `/var/log/traefik.log`, `/etc/traefik/traefik.yml` |
|
||||
|
||||
Die Fortschrittsdatei **ist** der Zustand. Ein Abschnitt, der dort `done`
|
||||
gemeldet hat, läuft bei einem Wiederanlauf nicht erneut.
|
||||
|
||||
---
|
||||
|
||||
## Vor dem Neustart oder danach?
|
||||
|
||||
Das ist die erste Frage, und sie ist leicht zu beantworten:
|
||||
|
||||
```sh
|
||||
findmnt -no FSTYPE /
|
||||
```
|
||||
|
||||
`tmpfs`, `overlay` oder `ramfs` heißt: noch im Rettungssystem, der Neustart
|
||||
steht bevor. `zfs` heißt: die Maschine läuft schon auf dem installierten System,
|
||||
und der `first-boot`-Hook hat den Lauf wieder aufgenommen.
|
||||
|
||||
---
|
||||
|
||||
## Abschnitt für Abschnitt
|
||||
|
||||
### `rescue_checked` — die Eingangsprüfung
|
||||
|
||||
**Woran man es merkt:** Das Skript endet sofort mit einer Liste von Befunden.
|
||||
In der Konsole steht nichts, weil es vor dem Tunnel keinen Weg dorthin gibt.
|
||||
|
||||
**Übliche Gründe:**
|
||||
|
||||
- *„Das Wurzelverzeichnis liegt auf /dev/… also auf einer echten Partition."*
|
||||
Die Maschine läuft nicht im Rettungssystem. Beim Anbieter das Rettungssystem
|
||||
einschalten und **neu starten** — das Einschalten allein genügt nicht.
|
||||
- *„Die CPU meldet weder vmx noch svm."* Ein Cloud-Produkt ohne verschachtelte
|
||||
Virtualisierung (Hetzner CPX/CX, teils netcup). Nicht abstellbar; es braucht
|
||||
eine dedizierte Maschine.
|
||||
- *„/dev/kvm gibt es nicht."* Virtualisierung im BIOS aus, oder das
|
||||
Rettungssystem hat das Modul nicht geladen: `modprobe kvm_intel` bzw.
|
||||
`modprobe kvm_amd`, dann die Zeile noch einmal.
|
||||
- *„Es sind Partitionen echter Platten eingehängt."* Jemand hat nachgesehen.
|
||||
`umount` und noch einmal.
|
||||
- *„Die Uhr geht … Sekunden falsch."* Die Meldung enthält den fertigen Befehl
|
||||
zum Stellen. Ohne ihn scheitert später jede TLS-Prüfung mit einer Meldung über
|
||||
Zertifikate statt über die Uhrzeit.
|
||||
|
||||
**Hier ist Nachbessern richtig.** Es wurde noch nichts geschrieben.
|
||||
|
||||
---
|
||||
|
||||
### `debian_installed` — das Grundsystem
|
||||
|
||||
Heißt „Grundsystem geschrieben". Es entsteht **kein** Debian, das nachher jemand
|
||||
umbaut: die offizielle Proxmox-ISO wird mit einer Antwortdatei versehen und
|
||||
unter QEMU gegen die echten Platten gestartet.
|
||||
|
||||
**Woran man es merkt:** Der Lauf steht lange ohne Meldung; im Rettungssystem
|
||||
läuft ein `qemu-system-x86_64`.
|
||||
|
||||
**Wo man nachsieht:** `/var/tmp/clupilot-work/qemu.log`, und die Antwortdatei
|
||||
daneben. Läuft QEMU noch, kann man dem Installer zusehen — er hört auf
|
||||
`127.0.0.1:5900`:
|
||||
|
||||
```bash
|
||||
ssh -L 5900:127.0.0.1:5900 root@<öffentliche-ip>
|
||||
```
|
||||
|
||||
Dann mit einem VNC-Betrachter auf `localhost:5900`. Sieht man dort eine
|
||||
**Eingabemaske**, ist die Antwortdatei nicht angenommen worden — der Installer
|
||||
ist in den interaktiven Modus gefallen und wartet, bis die Stunde abläuft.
|
||||
|
||||
**Übliche Gründe:**
|
||||
|
||||
- `answer.toml` abgelehnt. Steht in `/var/tmp/clupilot-work/assistant.log`. Das
|
||||
Skript prüft sie mit `validate-answer`, bevor es die ISO backt, also ist das
|
||||
selten — und wenn, dann liegt es an einem Wert aus der Umgebung (Adresse,
|
||||
Gateway, MAC), den das Rettungssystem anders meldet als erwartet.
|
||||
- Prüfsumme der ISO stimmt nicht. Abgebrochene Übertragung. Zeile noch einmal.
|
||||
- Keine passende PVE-9-ISO gefunden. Die Liste unter
|
||||
`https://enterprise.proxmox.com/iso/` war nicht erreichbar.
|
||||
|
||||
---
|
||||
|
||||
### `rebooted` — die einzige unumkehrbare Stelle
|
||||
|
||||
**Woran man es merkt:** Die Maschine antwortet nach dem Neustart nicht mehr,
|
||||
oder sie antwortet, meldet aber nichts weiter.
|
||||
|
||||
**Übliche Gründe:**
|
||||
|
||||
- Die Maschine bootet noch vom Rettungssystem statt von der Platte. Beim
|
||||
Anbieter das Rettungssystem **abschalten** und neu starten.
|
||||
- Der `first-boot`-Hook lief nicht. Nachsehen mit
|
||||
`journalctl -u proxmox-first-boot` und `ls /var/lib/clupilot/`. Fehlt das
|
||||
Verzeichnis ganz, ist der Hook nicht in die ISO gekommen — dann neu aufsetzen.
|
||||
|
||||
**Kommt die Maschine gar nicht zurück:** Anbieterkonsole. Neu aufsetzen ist
|
||||
schneller als jede Suche.
|
||||
|
||||
---
|
||||
|
||||
### `proxmox_installed` — abnehmen und Quellen richten
|
||||
|
||||
**Woran man es merkt:** Meldung mit `failed` und einem der folgenden Sätze.
|
||||
|
||||
**Übliche Gründe:**
|
||||
|
||||
- *„pveversion gibt es nicht"* — die ISO hat kein Proxmox installiert. Neu
|
||||
aufsetzen.
|
||||
- *„läuft auf …, das ist kein PVE-Kernel"* — die Maschine ist in den
|
||||
Debian-Kernel gebootet. `uname -r` muss `-pve` enthalten.
|
||||
- *„erwartet wäre PVE 9"* — Abbild und Unterbau passen nicht zusammen. Mit dem
|
||||
richtigen Abbild neu aufsetzen; Paketquellen heilen das nicht.
|
||||
- *„apt-get update schlägt fehl"* — siehe `/var/tmp/clupilot-work/apt.log`. Fast
|
||||
immer eine Abonnement-Quelle, die wieder aufgetaucht ist:
|
||||
`ls /etc/apt/sources.list.d/`.
|
||||
|
||||
---
|
||||
|
||||
### `network_bridged` — der gefährlichste Abschnitt
|
||||
|
||||
**Woran man es merkt:** Entweder eine `failed`-Meldung, oder die Maschine ist
|
||||
für ein paar Minuten weg und kommt dann von selbst zurück.
|
||||
|
||||
**Kommt sie zurück, war das die Selbstrücknahme.** Das ist kein Fehler des
|
||||
Zeitgebers, sondern sein Zweck: die Brücke hätte den Host vom Netz genommen.
|
||||
Nachsehen mit `journalctl -t clupilot`.
|
||||
|
||||
**Übliche Gründe:**
|
||||
|
||||
- Falsche Anbieterform erkannt. Bei einer gerouteten Einzeladresse muss
|
||||
`pointopoint` in `/etc/network/interfaces` stehen; fehlt sie, findet der
|
||||
Kernel das Gateway nicht.
|
||||
- *„die Datacenter-Firewall ließ sich nicht einschalten"* — ohne sie sind die
|
||||
„nur 80/443"-Regeln **jeder** Kunden-VM wirkungslos. Prüfen mit
|
||||
`pvesh get /cluster/firewall/options`.
|
||||
|
||||
**Kommt die Maschine nicht zurück:** Anbieterkonsole, dort
|
||||
`tar xzf /var/lib/clupilot/interfaces.vor-der-bruecke.tar.gz -C /` und
|
||||
`ifreload -a`. Danach neu aufsetzen.
|
||||
|
||||
---
|
||||
|
||||
### `wireguard_joined` — ab hier ist CluPilot erreichbar
|
||||
|
||||
**Woran man es merkt:** Bis hierher stand in der Konsole „wartet auf den
|
||||
Tunnel". Bleibt es dabei, ist dieser Abschnitt der offene.
|
||||
|
||||
**Wo man nachsieht:** `wg show` auf dem Host. Steht dort ein Peer ohne
|
||||
`latest handshake`, steht die Datei und der Tunnel nicht.
|
||||
|
||||
**Übliche Gründe:**
|
||||
|
||||
- Der Hub kennt den Peer nicht. Sollte nicht vorkommen — CluPilot legt ihn beim
|
||||
Anlegen des Hosts an —, aber ein gelöschter Host-Eintrag nimmt ihn mit.
|
||||
- Der Hub-Endpunkt ist von dieser Maschine aus nicht erreichbar. UDP auf dem
|
||||
Port des Hubs prüfen.
|
||||
- *„konnte wg-quick@wg0 nicht freigeben"* — der Tunnel überlebte den nächsten
|
||||
Neustart nicht. Das ist ein harter Fehler, kein Schönheitsfehler.
|
||||
|
||||
---
|
||||
|
||||
### `traefik_running`
|
||||
|
||||
**Woran man es merkt:** `failed` mit einem der Sätze unten. Der Host ist
|
||||
erreichbar, also lässt sich alles direkt nachsehen.
|
||||
|
||||
**Übliche Gründe:**
|
||||
|
||||
- *„antwortet nicht am Ping-Endpunkt"* — `/var/log/traefik.log` und
|
||||
`systemctl status traefik`.
|
||||
- *„80 und 443 sind nicht belegt"* — Traefik läuft, ein Entrypoint fehlt.
|
||||
`ss -ltnp | grep -E ':80|:443'`. Belegt sie etwas anderes, gehört das nicht auf
|
||||
diese Maschine.
|
||||
- Prüfsumme des Binaries stimmt nicht. Zeile noch einmal.
|
||||
|
||||
Dass Traefik hier **noch keine Routentabelle** holt, ist richtig so: den Token
|
||||
dafür gibt es erst mit der Registrierung.
|
||||
|
||||
---
|
||||
|
||||
### `template_built` — die goldene Vorlage
|
||||
|
||||
**Woran man es merkt:** `failed`, meist mit einem Hinweis auf eine der drei
|
||||
Fallen.
|
||||
|
||||
**Wo man nachsieht:** `/var/tmp/clupilot-work/virt-customize.log` und
|
||||
`qm.log`.
|
||||
|
||||
**Übliche Gründe:**
|
||||
|
||||
- *„erfüllt Falle 2 nicht"* — das Cloud-Abbild hat LVM oder die Wurzel ist nicht
|
||||
die letzte Partition. Dann stimmt etwas am Abbild nicht, das dort liegt.
|
||||
- *„qemu-guest-agent ist NICHT im Abbild"* — `virt-customize` hat Erfolg
|
||||
gemeldet und apt hat das Paket still nicht installiert. Fast immer ein
|
||||
Netzproblem im Abbild-Umbau.
|
||||
- *„kein `user: www-data`"* — die Compose-Datei im Abbild ist nicht die aus
|
||||
`assets/`. Ohne diese Zeile schlagen **alle** `occ`-Aufrufe fehl.
|
||||
- *„meldet nicht template: 1"* — die VM existiert, ist aber keine Vorlage.
|
||||
`qm template 9000` von Hand und dann **trotzdem neu aufsetzen**: wenn dieser
|
||||
Schritt schiefging, ist unklar, was sonst noch.
|
||||
|
||||
---
|
||||
|
||||
### `registered` — die Übergabe
|
||||
|
||||
**Woran man es merkt:** Der Host bleibt in der Konsole auf `onboarding`.
|
||||
|
||||
**Übliche Gründe:**
|
||||
|
||||
- *„die Proxmox-Rolle ließ sich nicht angleichen"* — ohne `Sys.Modify` stirbt
|
||||
später **jede** Kundenbereitstellung am Backup-Schritt. Prüfen mit
|
||||
`pveum role list`.
|
||||
- *„hat nicht geantwortet"* — CluPilot ist über den Tunnel nicht erreichbar,
|
||||
obwohl der Handshake stand. Auf der CluPilot-Seite nachsehen, ob die
|
||||
Tunneladresse in `TRUSTED_RANGES` steht.
|
||||
- *„kam ohne host_token zurück"* — die Registrierung ist durchgelaufen, die
|
||||
Antwort war unvollständig. Auf der CluPilot-Seite nachsehen.
|
||||
- *„der Schlüsseltausch hat keinen Handshake ergeben"* — das Skript hat auf den
|
||||
alten Schlüssel zurückgestellt, der Host ist also **noch erreichbar**. Der Hub
|
||||
hat den neuen Peer möglicherweise schon aufgenommen und den alten entfernt;
|
||||
dann ist der Host beim nächsten Versuch weg. Neu aufsetzen.
|
||||
- *„die Routentabelle ist nicht abrufbar"* — der Token stimmt nicht oder
|
||||
`/host/routes` antwortet nicht. Direkt prüfen:
|
||||
```bash
|
||||
curl -H "Authorization: Bearer $(cat /etc/traefik/host-token)" http://<tunnel-ip>/host/routes
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Der Host ist nicht mehr erreichbar
|
||||
|
||||
Erst die Frage: **war die Firewall schon dran?** Sie wird als **allerletztes**
|
||||
angewendet. Steht in der Konsole `registered` noch nicht auf `done`, war sie es
|
||||
nicht — dann liegt es am Netz, nicht an den Regeln.
|
||||
|
||||
War sie dran, gibt es von der Anbieterkonsole aus genau einen Griff:
|
||||
|
||||
```bash
|
||||
/usr/local/sbin/clupilot-emergency-open-firewall.sh
|
||||
```
|
||||
|
||||
Der Host nimmt danach auf jedem Port wieder an. **Es gibt bewusst keine
|
||||
automatische Wiederöffnung** — kein Zeitgeber, kein „nach N Minuten ohne
|
||||
Handshake wieder auf". Eine Firewall, die sich unter Störung selbst öffnet, ist
|
||||
keine Firewall.
|
||||
|
||||
Zurück kommen die Regeln mit:
|
||||
|
||||
```bash
|
||||
nft -f /etc/nftables.conf && systemctl enable --now nftables
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Was dieses Runbook nicht sagt
|
||||
|
||||
Alles, was noch nicht auf echter Hardware gelaufen ist. Die Abschnitte oben sind
|
||||
aus dem Skript und aus den Fundstellen geschrieben, die ihm zugrunde liegen —
|
||||
nicht aus Vorfällen. Was beim ersten Durchlauf tatsächlich stehenbleibt, gehört
|
||||
hier ergänzt, mit dem Satz, der in der Konsole stand.
|
||||
|
|
@ -0,0 +1,521 @@
|
|||
# Host-Übernahme — Bootstrap-Skript, Umsetzungsplan
|
||||
|
||||
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
|
||||
|
||||
**Goal:** Ein Skript, das aus einer nackten Maschine im Rettungssystem einen fertigen Proxmox-Host macht — mit Traefik, WireGuard-Beitritt und goldener Nextcloud-Vorlage — und CluPilot nach jedem Abschnitt sagt, wie weit es ist.
|
||||
|
||||
**Architecture:** Ein einziges POSIX-Shell-Skript, das der Betreiber mit einer Zeile aus dem Adminbereich startet. Es läuft in Abschnitten; jeder meldet sich zurück, sobald ein Weg zu CluPilot existiert. Abschnitte vor dem Tunnelbeitritt werden lokal mitgeschrieben und nachgereicht. Das Skript ist so gebaut, dass ein zweiter Lauf auf einer frisch aufgesetzten Maschine immer gleich ausgeht.
|
||||
|
||||
**Tech Stack:** POSIX sh, Debian 13 (trixie), Proxmox VE 9, WireGuard, Traefik, `qemu-img`, cloud-init, Docker + Compose im Gast.
|
||||
|
||||
## Global Constraints
|
||||
|
||||
- **Vollfassung des Entwurfs:** `docs/superpowers/specs/2026-07-30-host-uebernahme-statt-installation-design.md`. Bei Widerspruch gewinnt die Spec.
|
||||
- **DIE WICHTIGSTE AUFLAGE: das Wissen liegt schon im Repository, erfinde es nicht neu.** Die heutigen Pipeline-Schritte enthalten alles, was hier gebraucht wird — und sie werden vom Plattform-Plan **gelöscht**. Lies sie, **bevor** das passiert:
|
||||
- `app/Provisioning/Steps/Host/InstallProxmoxVe.php` — die Codename-Tabelle (Debian 13/trixie ↔ PVE 9), die Paketquellen, die Reihenfolge. Der Handoff hält fest, dass ein fest verdrahtetes `bookworm` heute PVE-8-Quellen gegen Debian 13 gesetzt hätte.
|
||||
- `app/Provisioning/Steps/Host/RebootIntoPveKernel.php` — **der Neustart ist das
|
||||
einzige Unumkehrbare am ganzen Vorgang.** Die Datei beweist den Bootpfad,
|
||||
*bevor* sie neu startet, statt ihn aus „apt hat nicht gemeckert" zu
|
||||
schließen: `/boot/vmlinuz-*-pve` vorhanden, `/boot/initrd.img-*-pve`
|
||||
vorhanden (ein volles `/boot` hinterlässt genau die halbe Initramfs), und
|
||||
`update-grub` **an seinem Exit-Status** geprüft — den der Vorgänger verwarf.
|
||||
Alle drei scheitern hart statt zu wiederholen, weil keine sich von selbst
|
||||
behebt. Dazu die 15-Minuten-Frist, die ihre eigenen Marker löscht, damit ein
|
||||
Wiederholungslauf frisch neu startet statt sofort wieder in die abgelaufene
|
||||
Frist zu laufen. **Diese Datei stand nicht in der ersten Fassung dieser
|
||||
Liste, steht aber auf der Löschliste des Plattform-Plans.**
|
||||
- `app/Provisioning/Steps/Host/ConfigureProxmox.php` — die vollständige Diagnose zu `vmbr0`: warum Proxmox auf Debian keine anlegt, was eine falsche Brücke anrichtet, und die Datacenter-Firewall, ohne die die „nur 80/443"-Regeln der Kunden-VMs **wirkungslos** sind.
|
||||
- `app/Provisioning/Steps/Host/SecureHostFirewall.php` — die nftables-Regeln, samt der Korrektur, dass **nicht jedes ICMP** verworfen werden darf (sonst sind IPv6 und PMTUD gebrochen).
|
||||
- `app/Provisioning/Steps/Host/ConfigureWireguard.php` — wie `wg0` systemd-aktiviert wird. Der Handoff hält fest, dass ein `||`-Rückfall die fehlende Aktivierung verdeckte und der Neustart eine nicht mehr bootende Maschine hinterlassen konnte.
|
||||
- `app/Provisioning/Steps/Host/CreateAutomationToken.php` — die Proxmox-Rolle **inklusive `Sys.Modify`**, ohne das jede Kundenbereitstellung am Backup-Schritt stirbt.
|
||||
- `app/Services/Traefik/SshTraefikWriter.php` — `render()` gibt die Entrypoint- und certResolver-Namen aus, zu denen die statische Konfiguration passen muss. Der Handoff: *„hier zuerst nachlesen, nicht raten."*
|
||||
- **Die drei Vorlagen-Fallen aus dem Handoff, Block A**, sind nicht verhandelbar:
|
||||
1. `user: www-data` am `app`-Dienst der Compose-Datei — sonst schlagen **alle** `occ`-Aufrufe fehl.
|
||||
2. Die Root-Partition ist die **letzte** auf der Platte und liegt **nicht** auf LVM — `GrowGuestFilesystem` erkennt LVM absichtlich nicht und schriebe sonst ein Kontingent über ein Dateisystem, das nie gewachsen ist.
|
||||
3. `qemu-guest-agent` **muss** im Image sein — `WaitForGuestAgent` hängt daran.
|
||||
- **Abschnittsschlüssel**, exakt und in dieser Reihenfolge — die einzige Absprache mit dem Plattform-Plan:
|
||||
`rescue_checked`, `debian_installed`, `rebooted`, `proxmox_installed`, `network_bridged`, `wireguard_joined`, `traefik_running`, `template_built`, `registered`
|
||||
- **Kein Docker auf dem Hypervisor.** Traefik ist Binary plus systemd-Unit. nftables bleibt einziger Eigentümer der Host-Firewallregeln (Handoff §1).
|
||||
- **Commit-Disziplin:** `git add -- <pfade>`, `git commit -F - -- <pfade>`. Nie `-A`, `.`, `-a` oder nackt.
|
||||
- **Dieser Plan hat keine Testsuite.** Ein Skript, das ein Betriebssystem installiert, lässt sich nicht in SQLite beweisen. Jede Aufgabe endet stattdessen mit einem Lauf auf **echter Hardware** — und der Handoff sagt dazu das Richtige: *„Jeder Schritt, der scheitert, ist ein Fund — und der Server darf dabei kaputtgehen, Debian ist neu eingespielt in Minuten."*
|
||||
- **Nicht geeignet als Prüfstand:** Hetzner Cloud (CPX/CX) — keine verschachtelte Virtualisierung, also kein `/dev/kvm`, also startet kein Gast. Bei netcup produktabhängig. Entscheidend ist `grep -cE 'vmx|svm' /proc/cpuinfo && ls -l /dev/kvm`.
|
||||
|
||||
---
|
||||
|
||||
## Dateistruktur
|
||||
|
||||
| Datei | Verantwortung |
|
||||
|---|---|
|
||||
| `deploy/bootstrap/clupilot-bootstrap.sh` | Das Skript. Ein Einstiegspunkt, Abschnitte als Funktionen. |
|
||||
| `deploy/bootstrap/lib/report.sh` | Melden und Nachreichen. Kennt als Einziges den Weg zu CluPilot. |
|
||||
| `deploy/bootstrap/lib/proxmox.sh` | Die Installation (`debian_installed`, `rebooted`) und die Paketquellen (`proxmox_installed`) |
|
||||
| `deploy/bootstrap/lib/network.sh` | Brücke mit Selbstrücknahme, nftables |
|
||||
| `deploy/bootstrap/lib/traefik.sh` | Traefik: Binary, statische Konfiguration, Unit |
|
||||
| `deploy/bootstrap/lib/template.sh` | Die goldene Vorlage |
|
||||
| `deploy/bootstrap/lib/register.sh` | Rolle, Token, Registrierung, Schlüsseltausch |
|
||||
| `deploy/bootstrap/assets/docker-compose.yml` | Die Compose-Datei, die in die Vorlage kommt |
|
||||
| `docs/runbooks/host-bootstrap.md` | Was zu tun ist, wenn das Skript stehenbleibt |
|
||||
|
||||
Getrennte Dateien, weil `template.sh` und `network.sh` unabhängig voneinander
|
||||
geprüft und wiederholt werden — und weil ein Skript, das alles in einer Datei
|
||||
macht, beim Nachlesen im Fehlerfall unbrauchbar ist.
|
||||
|
||||
**Wie es auf die Maschine kommt** (stand in keiner der beiden Fassungen, ist
|
||||
aber entschieden): als **Archiv**. Die kopierte Zeile aus dem Adminbereich holt
|
||||
`bootstrap.tar.gz`, packt es nach `/opt/clupilot/bootstrap` aus und startet von
|
||||
dort — ungefähr so:
|
||||
|
||||
```sh
|
||||
curl -fsSL https://app.clupilot.com/bootstrap.tar.gz | tar xz -C /opt/clupilot \
|
||||
&& /opt/clupilot/bootstrap/clupilot-bootstrap.sh --code … --wg-private … …
|
||||
```
|
||||
|
||||
Ein `curl … | sh` schied aus: es kann keine `lib/` haben, und die getrennten
|
||||
Dateien oben sind kein Schmuck. Das Archiv ist eine **statische Datei**, kein
|
||||
Endpunkt, der Auskunft gibt — Spec §5 bleibt damit unangetastet. Das Ausliefern
|
||||
selbst gehört zum Plattform-Plan (Task 7 zeigt die Zeile); dieser Plan setzt nur
|
||||
voraus, dass das Skript samt `lib/` unter `/opt/clupilot/bootstrap` liegt.
|
||||
|
||||
---
|
||||
|
||||
### Task 1: Gerüst, Melden und Nachreichen
|
||||
|
||||
**Files:**
|
||||
- Create: `deploy/bootstrap/clupilot-bootstrap.sh`, `deploy/bootstrap/lib/report.sh`
|
||||
|
||||
**Interfaces:**
|
||||
- Produces: `report <section> <state> [message]` — schreibt lokal nach `/var/lib/clupilot/progress.jsonl` **mit Zeitstempel** und versucht zu senden. `flush_reports` — reicht alles Ungesendete nach.
|
||||
- Consumes: aus der Befehlszeile `--code`, `--wg-private`, `--wg-ip`, `--hub-pubkey`, `--hub-endpoint`, `--api`.
|
||||
|
||||
- [x] **Step 1: Das Gerüst schreiben**
|
||||
|
||||
```sh
|
||||
#!/bin/sh
|
||||
set -eu
|
||||
|
||||
# Läuft im Rettungssystem des Anbieters, mit genau den Werten, die der
|
||||
# Adminbereich in die kopierte Zeile geschrieben hat. Es holt nichts ab: alles,
|
||||
# was vor dem Tunnel gebraucht wird, steht bereits hier (Spec §5).
|
||||
```
|
||||
|
||||
`report` schreibt **immer zuerst lokal**, dann erst der Sendeversuch. Vor
|
||||
`wireguard_joined` gibt es keinen Weg zu CluPilot; die Zeilen sammeln sich und
|
||||
werden von `flush_reports` nachgereicht, **mit ihren eigenen Zeitstempeln**.
|
||||
Sonst sieht eine zwanzigminütige Installation in der Konsole aus wie eine
|
||||
Sekunde.
|
||||
|
||||
- [ ] **Step 2: Auf echter Hardware prüfen**
|
||||
|
||||
Rettungssystem starten, Skript mit erfundenen Werten aufrufen, prüfen dass
|
||||
`/var/lib/clupilot/progress.jsonl` entsteht und die Sendeversuche sauber
|
||||
scheitern statt das Skript abzubrechen.
|
||||
|
||||
- [x] **Step 3: Committen.** Nachricht: `Give the bootstrap a spine and a way to report`
|
||||
|
||||
---
|
||||
|
||||
### Task 2: `rescue_checked` — vor allem anderen nachsehen
|
||||
|
||||
- [x] **Step 1: Schreiben**
|
||||
|
||||
Prüft, bevor irgendetwas geschrieben wird:
|
||||
|
||||
- verschachtelte Virtualisierung: `grep -cE 'vmx|svm' /proc/cpuinfo` **und** `ls /dev/kvm`
|
||||
- Platte groß genug für Vorlage plus Kunden
|
||||
- Netz erreichbar, Uhr plausibel (ein falsches Datum lässt jede TLS-Prüfung scheitern)
|
||||
- die Maschine ist wirklich ein Rettungssystem und nicht versehentlich ein
|
||||
laufender Produktivserver — **der teuerste denkbare Fehlgriff dieses Skripts**
|
||||
|
||||
**Bricht ab, statt zu raten.** Ein Skript, das eine Platte überschreibt, hat
|
||||
genau hier seine einzige Gelegenheit, es nicht zu tun.
|
||||
|
||||
- [ ] **Step 2: Auf echter Hardware prüfen**, auch der Abbruchfall: einmal auf einer
|
||||
Maschine ohne `/dev/kvm` starten und sehen, dass es sich weigert.
|
||||
- [x] **Step 3: Committen.** `Refuse a machine that cannot do the job`
|
||||
|
||||
---
|
||||
|
||||
### Task 3: `debian_installed` und `rebooted`
|
||||
|
||||
- [x] **Step 1: Schreiben**
|
||||
|
||||
> **Korrigiert gegenüber der ersten Fassung.** Dort stand „Debian 13 ins
|
||||
> Zielsystem", und danach hätte Task 4 daraus einen Proxmox-Host gemacht. Der
|
||||
> Betreiber hat das bestritten, und er hat recht: aus dem Rettungssystem wird
|
||||
> **Proxmox direkt** installiert. Es gibt kein Debian-Zwischensystem, das
|
||||
> nachher jemand umbaut.
|
||||
>
|
||||
> **Die Abschnittsschlüssel bleiben trotzdem unverändert** — sie sind die
|
||||
> einzige Absprache mit dem Plattform-Plan und werden nicht wegen einer
|
||||
> Umbenennung angefasst. Sie passen auch weiterhin, denn Proxmox VE *ist*
|
||||
> Debian mit PVE-Kernel und -Paketen:
|
||||
>
|
||||
> - `debian_installed` = **Grundsystem geschrieben** (das Abbild liegt auf der
|
||||
> Platte). Die Beschriftung in der Konsole heißt entsprechend
|
||||
> „Grundsystem geschrieben", nicht „Debian installiert" — das ist Sache von
|
||||
> Task 7 des Plattform-Plans (`hosts.section.*`).
|
||||
> - `proxmox_installed` = **PVE läuft nachweislich** (Task 4).
|
||||
|
||||
> **Und ein zweites Mal korrigiert, an der Mechanik.** Hier stand kurzzeitig
|
||||
> `installimage` mit einem Proxmox-Abbild. **Das gibt es nicht:** Hetzners
|
||||
> `installimage` führt kein Proxmox, es gilt dort als nicht unterstützt. Wer
|
||||
> danach sucht, findet nur Debian — und landet wieder beim Zwischensystem, das
|
||||
> gerade abgeschafft wurde.
|
||||
|
||||
Der Weg ist die **offizielle Proxmox-ISO mit eingebetteter Antwortdatei**, seit
|
||||
PVE 8.2 der reguläre Weg für unbeaufsichtigte Installationen und von Hetzner
|
||||
selbst als Anleitung geführt:
|
||||
|
||||
1. **Bootmodus feststellen** (Legacy oder UEFI) — davon hängt der QEMU-Aufruf ab.
|
||||
2. `answer.toml` schreiben: `[global]` mit `fqdn`, `country`, `timezone`,
|
||||
`root-password-hashed` und `root-ssh-keys`; `[network]` mit `source`;
|
||||
`[disk-setup]` mit dem Dateisystem und der Plattenwahl.
|
||||
3. **`proxmox-auto-install-assistant prepare-iso`** backt die Antwortdatei in
|
||||
die ISO. Vorher `validate-answer` — eine ISO, die wegen eines Tippfehlers in
|
||||
den interaktiven Modus fällt, hängt danach unsichtbar in QEMU.
|
||||
4. Die ISO **unter QEMU** laufen lassen, die gegen die **echten Platten**
|
||||
schreibt. Kein VNC, kein Klick: der Eintrag „Automated Installation" wird
|
||||
nach zehn Sekunden von selbst gewählt.
|
||||
5. Neustart aus dem Rettungssystem heraus.
|
||||
|
||||
**Dateisystem: ZFS** (`filesystem = "zfs"`, `zfs.raid` passend zur Plattenzahl).
|
||||
Damit ist auch kein LVM im Spiel. `arc-max` gehört ausdrücklich in die
|
||||
Antwortdatei — die Vorgabe nimmt sich die Hälfte des Arbeitsspeichers, und den
|
||||
braucht ein Host, der Kunden trägt, für die Gäste.
|
||||
|
||||
> **Falle 2 gilt dem Gast, nicht dem Host — hier stand es falsch.** Die frühere
|
||||
> Begründung („gilt für den Host genauso, weil die Vorlage später von hier aus
|
||||
> gebaut wird") trägt nicht: `GrowGuestFilesystem` wächst ein *Gast*-Dateisystem,
|
||||
> und die Vorlage entsteht in **Task 8** aus einem Debian-Cloud-Image, nicht aus
|
||||
> dem Plattenlayout des Hosts. **Für die Vorlage bleibt Falle 2 unverändert
|
||||
> verbindlich.** Für den Host ist sie gegenstandslos; dass ZFS ohnehin ohne LVM
|
||||
> auskommt, ist ein Nebeneffekt und nicht der Grund.
|
||||
|
||||
**Anbieter:** heute nur Hetzner-dediziert. Ein zweiter Weg (netcup) wird erst
|
||||
geschrieben, wenn eine Maschine dasteht, auf der er abgenommen werden kann — ein
|
||||
ungeprüfter Rückfallpfad ist genau die Behauptung, die dieser Plan sonst überall
|
||||
vermeidet (R22). Der ISO-Weg ist dabei die bessere Ausgangslage als
|
||||
`installimage`: er hängt an nichts, was nur einem Anbieter gehört.
|
||||
|
||||
**Was der Installer verlangt und die Befehlszeile nicht mitbringt.** Die
|
||||
Antwortdatei braucht `fqdn` und ein Root-Kennwort. Keins von beidem steht in den
|
||||
sechs Angaben aus Task 1. Entschieden, bis jemand widerspricht:
|
||||
|
||||
- **`fqdn`** wird aus dem Hostnamen gebildet, den CluPilot ohnehin kennt. Bis
|
||||
der Adminbereich ihn mitgibt, wird er aus der Rückwärtsauflösung der
|
||||
öffentlichen IP genommen und, wenn auch die nichts hergibt, aus `--wg-ip`
|
||||
abgeleitet. Der Name wird in Task 9 ohnehin gegen das ersetzt, was CluPilot
|
||||
führt.
|
||||
- **Das Root-Kennwort wird zufällig erzeugt und weggeworfen.** Niemand braucht
|
||||
es: die Plattform meldet sich nicht mehr per SSH an (Spec §6), und der Plan
|
||||
sagt für den Fehlerfall ausdrücklich, dass eine halb installierte Maschine
|
||||
**neu aufgesetzt und nicht nachgebessert** wird (Task 10). Ein Kennwort
|
||||
aufzubewahren, das nur im Notfall gebraucht würde, den es laut Plan nicht
|
||||
gibt, schafft nur eine weitere Stelle, an der ein Geheimnis liegt.
|
||||
- **Optional `--root-ssh-key`.** Gibt der Adminbereich einen öffentlichen
|
||||
Schlüssel mit, landet er als `root-ssh-keys` in der Antwortdatei. Ohne die
|
||||
Angabe gibt es keinen Weg per SSH auf die Maschine, und das ist die Vorgabe.
|
||||
|
||||
Der Neustart ist die erste Stelle, an der das Skript die Kontrolle verliert.
|
||||
**Der Wiederanlauf ist kein handgeschriebener systemd-Dienst mehr** — die
|
||||
Antwortdatei hat dafür einen `[first-boot]`-Abschnitt. Mit
|
||||
`source = "from-iso"` liegt das Skript schon in der ISO und startet nach dem
|
||||
Hochfahren von selbst; das ist auch der einzige Weg, der vor dem Tunnel
|
||||
funktioniert, weil ein `from-url`-Hook CluPilot noch nicht erreichen könnte.
|
||||
|
||||
**Und genau das ist der Punkt, an dem „dieselbe Fortschrittsdatei" Arbeit ist und
|
||||
keine Feststellung.** Das Rettungssystem läuft im Arbeitsspeicher; sein
|
||||
Wurzelverzeichnis ist nach dem Neustart weg. Bevor neu gestartet wird, gehen
|
||||
deshalb **in das frisch installierte System hinüber**:
|
||||
|
||||
- `/var/lib/clupilot/progress.jsonl` — sonst gehen `rescue_checked` und
|
||||
`debian_installed` verloren.
|
||||
- das Skript selbst samt `lib/`,
|
||||
- die Aufrufargumente aus Task 1, in einer Datei, die nur `root` lesen kann —
|
||||
der WireGuard-Schlüssel steht darin.
|
||||
|
||||
Ohne diesen Handgriff meldet das Skript nach dem Neustart bei Null. Und weil
|
||||
`flush_reports` erst in Task 6 läuft, **also nach dem Neustart**, fiele der
|
||||
Verlust nirgends auf — die zwei Abschnitte kämen in der Konsole schlicht nie an.
|
||||
Genau das prüft Task 6 Step 2, wenn er **alle fünf** vorherigen Abschnitte
|
||||
verlangt.
|
||||
|
||||
Zum Neustart selbst: **lies `RebootIntoPveKernel.php`.** Der Bootpfad wird
|
||||
bewiesen, bevor neu gestartet wird, nicht danach gehofft.
|
||||
|
||||
- [ ] **Step 2: Auf echter Hardware prüfen** — und ausdrücklich, dass es nach dem
|
||||
Neustart von allein weiterläuft. **Dazu: nach dem Hochfahren in
|
||||
`/var/lib/clupilot/progress.jsonl` nachsehen, dass die Zeilen von *vor* dem
|
||||
Neustart noch dort stehen, mit ihren ursprünglichen Zeitstempeln.**
|
||||
- [x] **Step 3: Committen.** `Write the image and survive the first reboot`
|
||||
(war `Write Debian and survive the first reboot`, bevor klar war, dass kein
|
||||
Debian-Zwischensystem entsteht)
|
||||
|
||||
---
|
||||
|
||||
### Task 4: `proxmox_installed`
|
||||
|
||||
- [x] **Step 1: Schreiben**
|
||||
|
||||
> **Korrigiert mit Task 3.** PVE kommt jetzt schon mit dem Abbild. Dieser
|
||||
> Abschnitt **installiert** es also nicht mehr, er **nimmt es ab und bringt
|
||||
> seine Paketquellen in Ordnung** — und genau dafür wird dasselbe Wissen
|
||||
> gebraucht, nur an anderer Stelle.
|
||||
|
||||
**Lies zuerst `InstallProxmoxVe.php`.** Die Codename-Tabelle dort kennt Debian 13
|
||||
und PVE 9 und ist aus einem echten Fehler entstanden. Übernimm sie, statt die
|
||||
Paketquellen zu tippen — und übernimm auch, dass ein **unbekanntes Release
|
||||
abbricht** statt zu raten. Vier Dinge stehen dort, die hier alle gebraucht
|
||||
werden:
|
||||
|
||||
1. **Die Zuordnung Codename → Suite und Release-Schlüssel.** Ein Abbild, dessen
|
||||
PVE-Suite nicht zu seinem Debian passt, ist ein Fund und kein Schönheitsfehler
|
||||
— PVE gegen eine andere libc gebaut. Passt es nicht, **abbrechen**.
|
||||
2. **Ein unbekannter Unterbau bricht ab.** Nicht „trixie ist das neueste, also
|
||||
trixie". Das nächste Debian ist auch unbekannt, und dann sieht niemand hin.
|
||||
3. **Die Enterprise-Quelle muss weg.** Ein Proxmox-Abbild bringt
|
||||
`/etc/apt/sources.list.d/pve-enterprise.list` mit, und die scheitert ohne
|
||||
Abonnement bei **jedem** `apt-get update`. Das ist keine Schönheitskorrektur:
|
||||
danach schlägt jede Paketinstallation der folgenden Abschnitte fehl.
|
||||
4. **`Signed-By` auf die Proxmox-Quelle begrenzen.** Sonst bürgt der
|
||||
Proxmox-Schlüssel für alles, was später in `sources.list` auftaucht. Deshalb
|
||||
die `.sources`-Datei (deb822) und nicht die alte `.list`-Zeile — und die alte
|
||||
dabei entfernen, weil eine stehengebliebene `bookworm`-Zeile auf einer
|
||||
trixie-Maschine genau die Vermischung ist, die Punkt 1 verbietet.
|
||||
|
||||
- [ ] **Step 2: Auf echter Hardware prüfen.** `pveversion` antwortet, der PVE-Kernel
|
||||
läuft (`uname -r` enthält `-pve`), und **`apt-get update` läuft ohne Fehler
|
||||
durch** — das ist der Beweis, dass die Enterprise-Quelle wirklich weg ist und
|
||||
nicht nur überschrieben wurde.
|
||||
- [x] **Step 3: Committen.** `Point Proxmox at the sources this Debian actually has`
|
||||
(war `Install Proxmox …`, bevor das Abbild PVE schon mitbrachte)
|
||||
|
||||
---
|
||||
|
||||
### Task 5: `network_bridged` — der gefährlichste Abschnitt
|
||||
|
||||
- [x] **Step 1: Schreiben**
|
||||
|
||||
**Lies zuerst `ConfigureProxmox.php`.** Dort steht, warum Proxmox auf Debian keine
|
||||
`vmbr0` anlegt und was eine falsche Brücke anrichtet.
|
||||
|
||||
> **Zwei Korrekturen aus der Umsetzung.**
|
||||
>
|
||||
> **Die Brücke ist im Regelfall schon da.** Genau die Stelle, die man hier liest,
|
||||
> sagt es: Proxmox *auf Debian* legt keine `vmbr0` an — „only the ISO installer
|
||||
> writes that bridge into `/etc/network/interfaces`". Dieser Entwurf installiert
|
||||
> über die ISO. Der Abschnitt **prüft** deshalb (existiert sie, hat sie eine
|
||||
> Adresse, trägt sie die Vorgaberoute) und baut nur im Ausnahmefall — dann unter
|
||||
> der Selbstrücknahme. Dieselbe Datei begründet ausführlich, warum sie eine
|
||||
> Brücke über die primäre Netzkarte NICHT aus der Ferne baut.
|
||||
>
|
||||
> **Die nftables-Sperre gehört nicht hierher.** `SecureHostFirewall.php` lief im
|
||||
> alten Ablauf ZULETZT, ausdrücklich damit der Tunnel schon trägt, „before
|
||||
> SSH-to-the-world is ever closed". An dieser Stelle stünde sie **vor**
|
||||
> `wireguard_joined` und schlösse Port 22, bevor ein Handshake bewiesen ist —
|
||||
> die Reihenfolge, die einen Host dauerhaft unerreichbar macht. Sie steht
|
||||
> deshalb in **Task 9**. Die Selbstrücknahme bleibt hier und gilt der
|
||||
> Netzumstellung; die Firewall behält ihre manuelle Notöffnung, denn „a firewall
|
||||
> that reopens itself under failure is not a firewall".
|
||||
>
|
||||
> **Zu Punkt 4 unten, „von außen prüfen":** das geht vor dem Tunnelbeitritt
|
||||
> nicht, es gibt keinen Gegenüber. Geprüft wird von innen nach außen (Gateway
|
||||
> und ein Ziel dahinter), und das ist im Skript auch so benannt. Was es nicht
|
||||
> abdeckt, deckt der Zeitgeber ab — deshalb steht er da.
|
||||
|
||||
Das Muster aus Block B des Handoffs, unverändert, nur im Skript:
|
||||
|
||||
1. Aktuelle Netzkonfiguration sichern.
|
||||
2. **Systemd-Timer auf fünf Minuten**, der sie zurückspielt und neu startet.
|
||||
3. Brücke aktivieren.
|
||||
4. **Von außen** prüfen, ob der Host antwortet.
|
||||
5. Erst dann den Timer abbestellen.
|
||||
|
||||
Der Abschnitt muss **erkennen, was er vorfindet**: DHCP auf einer Schnittstelle
|
||||
(Cloud) gegen geroutete Einzel-IP mit Gateway außerhalb des Subnetzes
|
||||
(Hetzner-Dedicated) gegen netcup. Eine Brücke, die für den einen Fall richtig ist,
|
||||
nimmt den anderen vom Netz.
|
||||
|
||||
Dazu die **Datacenter-Firewall einschalten** — ohne sie sind die „nur 80/443"-Regeln
|
||||
der Kunden-VMs wirkungslos, und `ConfigureProxmox` hielt das als eigenen Fund fest.
|
||||
|
||||
- [ ] **Step 2: Auf echter Hardware prüfen — und die Selbstrücknahme absichtlich
|
||||
auslösen.** Eine Brücke bauen, die die Maschine vom Netz nimmt, und zusehen, wie
|
||||
sie von selbst zurückkommt. **Ohne diesen Versuch ist der Zeitgeber eine
|
||||
Behauptung.**
|
||||
- [x] **Step 3: Committen.** `Bridge the network, and take it back if the host goes quiet`
|
||||
|
||||
---
|
||||
|
||||
### Task 6: `wireguard_joined` — ab hier ist CluPilot erreichbar
|
||||
|
||||
- [x] **Step 1: Schreiben**
|
||||
|
||||
`wg0` aus den mitgegebenen Werten, **systemd-aktiviert** — lies
|
||||
`ConfigureWireguard.php` dazu: ein `||`-Rückfall verdeckte dort die fehlende
|
||||
Aktivierung, und der Handoff nennt als Folge eine Maschine, die den Neustart nicht
|
||||
überlebt.
|
||||
|
||||
Der Tunnel gilt erst mit **bewiesenem Handshake**, nicht mit geschriebener Datei.
|
||||
Auch das ist ein Fund aus dem Handoff: eine gespeicherte Tunnel-IP ohne Beweis
|
||||
machte jeden weiteren Verbindungsversuch unbrauchbar, und die Rettung war
|
||||
Datenbank-Handarbeit.
|
||||
|
||||
Sobald der Handshake steht: `flush_reports` — alles bis hierher wird nachgereicht.
|
||||
|
||||
- [ ] **Step 2: Auf echter Hardware prüfen.** In der Konsole müssen jetzt **alle
|
||||
fünf** bisherigen Abschnitte auftauchen, mit ihren echten Zeitstempeln.
|
||||
- [x] **Step 3: Committen.** `Join the tunnel, then tell the console everything so far`
|
||||
|
||||
---
|
||||
|
||||
### Task 7: `traefik_running`
|
||||
|
||||
- [x] **Step 1: Schreiben**
|
||||
|
||||
Binary plus systemd-Unit, **kein Docker**. Statische Konfiguration mit
|
||||
`websecure`-Entrypoint und `letsencrypt`-certResolver — **deren Namen zu dem passen
|
||||
müssen, was der Routen-Endpunkt ausliefert.** Lies dazu `SshTraefikWriter::render()`
|
||||
im Repository, solange es sie noch gibt.
|
||||
|
||||
Der `http`-Provider zeigt auf `GET /host/routes` über die **Tunnel-Adresse** von
|
||||
CluPilot, mit dem dauerhaften Host-Token aus der Registrierung.
|
||||
|
||||
**Prüfen, nicht nur installieren:** Dienst läuft, 80 und 443 sind belegt, der
|
||||
Ping-Endpunkt antwortet, und **die erste Routentabelle wurde tatsächlich geholt**.
|
||||
Ein Host, der Traefik installiert hat, aber nie eine Tabelle bekam, ist nicht
|
||||
fertig.
|
||||
|
||||
> **Eine Abhängigkeit, die dieser Plan nicht aufgelöst hatte.** Der
|
||||
> `http`-Provider braucht den **dauerhaften Host-Token**, und den gibt es erst
|
||||
> mit `POST /host/register` — also in Abschnitt `registered`, zwei Abschnitte
|
||||
> später. Der Beweis „die erste Tabelle wurde geholt" lässt sich hier deshalb
|
||||
> nicht führen.
|
||||
>
|
||||
> Aufgelöst, ohne die Abschnittsschlüssel anzufassen: die statische
|
||||
> Konfiguration wird **zweimal** geschrieben. Hier mit leerem Token — der Dienst
|
||||
> steht, hält 80 und 443 und antwortet am Ping —, in Task 9 noch einmal mit dem
|
||||
> echten, gefolgt von einem Neustart und dem Abruf. **Der Beweis entfällt nicht,
|
||||
> er wandert an die Stelle, an der er zu führen ist.**
|
||||
>
|
||||
> Der Abruf wird dort direkt gemacht, mit demselben Token und derselben Adresse
|
||||
> aus der Konfiguration — nicht über Traefiks eigene Router. Ein frischer Host
|
||||
> hat noch keine Kunden, seine Tabelle ist also mit Recht leer, und „null
|
||||
> Router" hieße sowohl „alles in Ordnung" als auch „nichts abgerufen".
|
||||
|
||||
- [ ] **Step 2: Auf echter Hardware prüfen**, einschließlich eines abgerufenen
|
||||
Routensatzes.
|
||||
- [x] **Step 3: Committen.** `Run Traefik as a service that fetches its own routes`
|
||||
|
||||
---
|
||||
|
||||
### Task 8: `template_built` — die goldene Vorlage
|
||||
|
||||
- [x] **Step 1: Schreiben**
|
||||
|
||||
Aus einem Debian-13-Cloud-Image, auf `template_vmid 9000`:
|
||||
|
||||
- `qemu-guest-agent` **im Image** (Falle 3)
|
||||
- Docker und Compose
|
||||
- `/opt/nextcloud/docker-compose.yml` aus `assets/`, das
|
||||
`NEXTCLOUD_ADMIN_USER`, `NEXTCLOUD_ADMIN_PASSWORD` und `MYSQL_PASSWORD` aus der
|
||||
`.env` liest und sich beim ersten Start selbst installiert
|
||||
- **`user: www-data` am `app`-Dienst** (Falle 1)
|
||||
- Root-Partition **letzte auf der Platte, kein LVM** (Falle 2)
|
||||
- als Proxmox-Vorlage konvertieren
|
||||
|
||||
**Die Vorlage bleibt kundenunabhängig.** Passwörter werden nie in ein Image
|
||||
gebacken — die Pipeline schreibt sie beim Klonen in die `.env` des Gastes.
|
||||
|
||||
Prüfen: `qm config 9000` meldet `template: 1`. Der Handoff hält als eigenen Fund
|
||||
fest, dass `VerifyVmTemplate` heute nur die **Existenz** prüft, nicht das Merkmal.
|
||||
|
||||
- [ ] **Step 2: Auf echter Hardware prüfen — und einmal wirklich klonen.** Eine VM
|
||||
aus 9000 erzeugen, starten, `occ status` als `www-data` aufrufen. **Ohne diesen
|
||||
Klon ist die Vorlage nicht bewiesen**, und der Handoff nennt genau diesen Fall:
|
||||
der erste bezahlte Auftrag starb sonst *nach* der Zahlung.
|
||||
- [x] **Step 3: Committen.** `Build the golden template so nobody has to remember how`
|
||||
|
||||
---
|
||||
|
||||
### Task 9: `registered` — Token übergeben
|
||||
|
||||
- [x] **Step 1: Schreiben**
|
||||
|
||||
Proxmox-Rolle und Token erzeugen — **`Sys.Modify` nicht vergessen**, siehe
|
||||
`CreateAutomationToken.php`; ohne das stirbt jede Kundenbereitstellung am
|
||||
Backup-Schritt.
|
||||
|
||||
Dann `POST /host/register` über die Tunnel-Adresse mit Token, **frisch erzeugtem
|
||||
WireGuard-Pubkey** (der aus der Befehlszeile wird damit wertlos), Fingerabdruck und
|
||||
Eckdaten. Die Antwort trägt das dauerhafte Host-Token für Traefik.
|
||||
|
||||
**Reihenfolge:** erst registrieren und die Antwort haben, dann den alten Schlüssel
|
||||
verwerfen. Die Gegenseite nimmt erst auf und entfernt dann — aber das Skript darf
|
||||
sich auch von seiner Seite nicht vorzeitig abschneiden.
|
||||
|
||||
**Und der Tausch braucht denselben Beweis wie der Beitritt.** Task 6 lässt den
|
||||
Tunnel erst mit bewiesenem Handshake gelten; hier wird genau dieser Tunnel unter
|
||||
laufendem Betrieb auf einen neuen Schlüssel umgestellt. Die Reihenfolge allein
|
||||
schützt davor nicht — sie sagt nur, wann verworfen wird, nicht ob das Neue
|
||||
trägt. Deshalb, in dieser Folge:
|
||||
|
||||
1. neues Schlüsselpaar erzeugen, registrieren, Antwort haben,
|
||||
2. `wg0` auf den neuen privaten Schlüssel umstellen,
|
||||
3. **erneuten Handshake beweisen** — dieselbe Prüfung wie in Task 6, nicht die
|
||||
geschriebene Datei,
|
||||
4. **erst dann** den alten Schlüssel verwerfen.
|
||||
|
||||
Kommt der Handshake nicht, wird auf den alten Schlüssel zurückgestellt und der
|
||||
Abschnitt scheitert laut. Ein Host, der sich im letzten Schritt selbst
|
||||
aussperrt, ist der eine Fall, den niemand aus der Ferne repariert — und er
|
||||
passiert am Ende eines Laufs, der bis dahin alles richtig gemacht hat.
|
||||
|
||||
Zum Schluss: `pveum acl modify … || true` und `pveum user add … || true`
|
||||
**nicht** übernehmen. Der Handoff nennt sie als offenen Punkt — sie verdecken
|
||||
Fehler, dieselbe Klasse wie der bereits behobene `role add`.
|
||||
|
||||
Das ist aber **keine Regel gegen `|| true` an sich**, und wer sie so liest,
|
||||
bricht Task 3. `RebootIntoPveKernel.php` toleriert
|
||||
`apt-get -y remove linux-image-amd64 || true` **mit Begründung**: ein Image, das
|
||||
das Metapaket nie hatte, lässt apt ohne Fehler in der Sache von Null verschieden
|
||||
enden — und ob der Debian-Kernel weg ist, ist ohnehin nicht die Frage, sondern
|
||||
ob ein Proxmox-Kernel bootet, was drei Zeilen später eigenständig geprüft wird.
|
||||
Die Regel lautet: **kein `|| true` über einem Befehl, dessen Fehlschlag etwas
|
||||
bedeutet** — und wo eines steht, steht der Grund daneben.
|
||||
|
||||
- [ ] **Step 2: Auf echter Hardware prüfen.** Der Host steht in der Konsole auf
|
||||
`active`, die sechs Schritte der Kette laufen durch, und die Bereitschaftsseite
|
||||
meldet `provisioning.usable_host` als erfüllt. **Und der Tunnel steht danach
|
||||
noch** — nach dem Schlüsseltausch einmal `wg show` ansehen und einen frischen
|
||||
Handshake mit dem *neuen* Schlüssel sehen, nicht den alten Zählerstand.
|
||||
- [x] **Step 3: Committen.** `Hand over the token and let the console take it from here`
|
||||
|
||||
---
|
||||
|
||||
### Task 10: Das Runbook
|
||||
|
||||
**Files:** `docs/runbooks/host-bootstrap.md`
|
||||
|
||||
Was zu tun ist, wenn ein Abschnitt stehenbleibt — je Abschnitt: woran man es
|
||||
merkt, wo man nachsieht, was der übliche Grund ist. Und ausdrücklich: **eine halb
|
||||
installierte Maschine wird neu aufgesetzt, nicht nachgebessert.** Ein neuer Code
|
||||
aus dem Adminbereich, Rettungssystem, noch einmal.
|
||||
|
||||
- [x] Schreiben, committen. Nachricht: `Write down what to do when the bootstrap stops`
|
||||
|
||||
---
|
||||
|
||||
## Der ganze Durchlauf
|
||||
|
||||
- [ ] Eine **frisch aufgesetzte** Maschine, Rettungssystem, eine Zeile einfügen, zusehen.
|
||||
- [ ] Der Host steht am Ende auf `active`, ohne dass jemand etwas abgetippt hat.
|
||||
- [ ] **Danach ein zweiter Lauf auf einer wieder frisch aufgesetzten Maschine.** Er
|
||||
muss gleich ausgehen. Ein Skript, das nur einmal funktioniert hat, hat nicht
|
||||
funktioniert.
|
||||
|
||||
## Was danach offen bleibt
|
||||
|
||||
- Der Plattform-Plan (`2026-07-30-host-uebernahme-plattform.md`), falls er noch läuft.
|
||||
- Die Schritte 5 bis 8 der Reihenfolge aus dem Handoff: Testpaket kaufen, eigene
|
||||
Domain, kündigen, widerrufen. Erst danach ist die Kette bewiesen.
|
||||
|
|
@ -0,0 +1,707 @@
|
|||
# Host-Übernahme — Plattformseite, Umsetzungsplan
|
||||
|
||||
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
|
||||
|
||||
**Goal:** CluPilot nimmt einen fertig installierten Proxmox-Host in Betrieb, ohne ihn je zu installieren und ohne dauerhaften Shell-Zugang — der Host meldet sich selbst, holt seine Routen selbst und wird nur noch geprüft.
|
||||
|
||||
**Architecture:** Beim Anlegen eines Hosts erzeugt die Konsole ein WireGuard-Schlüsselpaar und einen Einmal-Code und zeigt beides in einer kopierbaren Befehlszeile. Das Bootstrap-Skript (eigener Plan) tritt damit dem Tunnel bei und meldet sich über drei Endpunkte zurück, die **ausschließlich** aus dem WireGuard-Subnetz erreichbar sind. Traefik auf dem Host holt seine Routentabelle über denselben Weg ab, statt sie per SSH geschrieben zu bekommen. Die Host-Kette schrumpft von vierzehn auf sechs Schritte, die nur noch nachsehen.
|
||||
|
||||
**Tech Stack:** PHP 8.3, Laravel 13, Livewire 3, Pest 4, MariaDB, Tailwind v3, Docker Compose.
|
||||
|
||||
## Global Constraints
|
||||
|
||||
- **Vollfassung des Entwurfs:** `docs/superpowers/specs/2026-07-30-host-uebernahme-statt-installation-design.md`. Bei Widerspruch gewinnt die Spec.
|
||||
- **Die härteste Bedingung, aus §8a:** Es entsteht **kein einziger neuer Weg von außen** — weder auf den Host noch zu CluPilot. `register`, `progress` und `routes` liegen alle drei hinter der Tunnel-Beschränkung. Ein Task, der einen davon öffentlich erreichbar macht, ist gescheitert, auch wenn seine Tests grün sind.
|
||||
- **Commit-Disziplin, nicht verhandelbar.** Weitere Sitzungen arbeiten im selben Repository. **Immer** `git add -- <pfade>` und `git commit -F - -- <pfade>`. **Nie** `git add -A`, `git add .`, `git commit -a` oder ein nacktes `git commit`.
|
||||
- `pint` nur auf eigene Pfade, **nie** `--dirty`, keine Kosmetik an fremden Dateien.
|
||||
- **R18–R24 aus `CLAUDE.md`** gelten und werden per Test erzwungen. R22 besonders: eine Prüfrunde, eine Fix-Runde, dann parken.
|
||||
- **Pest-Fallen, beide heute erlebt:** `toThrow(EinInterface::class)` beweist nichts — immer eine konkrete Klasse. Und `toContain($a, $b)` ist **variadisch**, der zweite Parameter ist kein Meldungstext; dasselbe gilt für `toHaveKey()`.
|
||||
- **Testisolation:** die Suite läuft mit `CACHE_STORE=array`, der zwischen Testdateien nicht geleert wird. Ein Test, der von `Settings` abhängt, setzt den Wert selbst.
|
||||
- **Betreiber-Rollen** heißen exakt `Owner`, `Admin`, `Support`, `Billing`, `Read-only`, `Developer`. Fabrik: `Operator::factory()->role('Owner')->create()`. Testpasswort `passwort-fuer-tests`. Guard-Name `'operator'` — bei `Livewire::actingAs($op, 'operator')` **nicht vergessen**.
|
||||
- **Tests laufen nur im Container**, `docker compose` aus `/home/nexxo/clupilot`:
|
||||
`docker compose exec -T -w /var/www/html/.worktrees/host-bootstrap app php artisan test`
|
||||
- **Immer die volle Suite**, nie nur einen Filter. Eine Aufgabe hat heute mit einem Filter gearbeitet und einen Bruch übersehen, der erst zwei Aufgaben später auffiel.
|
||||
- **Kein `migrate` gegen die Entwicklungsdatenbank.** Die Container bedienen ein anderes Arbeitsverzeichnis; eine Migration von hier aus beschädigt die laufende Installation. Beweise führen die Tests auf SQLite im Speicher.
|
||||
|
||||
---
|
||||
|
||||
## Was schon da ist
|
||||
|
||||
| | |
|
||||
|---|---|
|
||||
| `App\Services\Wireguard\WireguardHub` | `allocateIp()`, `addPeer($pubkey, $ip)`, `removePeer($pubkey)`, `endpoint()`, `publicKey()`, `peers()` |
|
||||
| `config('admin_access.trusted_ranges')` | aus `TRUSTED_RANGES`, Vorgabe `10.66.0.0/24,127.0.0.1` |
|
||||
| `App\Http\Middleware\RestrictConsoleNetwork` | **das Vorbild** für die Tunnel-Beschränkung — nicht neu erfinden |
|
||||
| `App\Services\Secrets\SecretVault` | Tresor mit Modus-Plätzen; `stripe.secret` ist strikt |
|
||||
| `App\Support\Readiness` | fünf Prüfgruppen, Spread-Array, Wächter-Test über die Registry |
|
||||
| `hosts` | `uuid, name, dns_name, datacenter, public_ip, wg_ip, wg_pubkey, ssh_host_key, api_token_ref, total_gb, total_ram_mb, cpu_cores, pve_version, node, status, last_seen_at` |
|
||||
| `SshTraefikWriter::write($trafficHost, $subdomain, array $hostnames, $backend)` | die abzulösende Stelle |
|
||||
| `ConfigureDnsAndTls` | bildet `$hostnames = array_filter([$fqdn, $customDomain])` — beide Namen |
|
||||
|
||||
---
|
||||
|
||||
### Task 1: Einmal-Code und Schlüsselpaar beim Anlegen
|
||||
|
||||
**Files:**
|
||||
- Create: `database/migrations/2026_08_02_090000_give_a_host_its_enrolment.php`
|
||||
- Create: `app/Support/HostEnrolment.php`
|
||||
- Test: `tests/Feature/Host/HostEnrolmentTest.php`
|
||||
|
||||
**Interfaces:**
|
||||
- Consumes: `WireguardHub::allocateIp()`, `publicKey()`, `endpoint()`, `addPeer()`.
|
||||
- Produces: `HostEnrolment::issue(Host $host): string` — legt Code, Schlüsselpaar und Peer an und gibt den **Klartext-Code** genau einmal zurück. `HostEnrolment::claim(string $code): ?Host` — löst einen gültigen, unverbrauchten Code auf und markiert ihn als verbraucht. Spalten auf `hosts`: `enrolment_code_hash`, `enrolment_expires_at`, `enrolment_used_at`.
|
||||
|
||||
- [ ] **Step 1: Den fehlschlagenden Test schreiben**
|
||||
|
||||
```php
|
||||
<?php
|
||||
|
||||
use App\Models\Host;
|
||||
use App\Support\HostEnrolment;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
|
||||
/**
|
||||
* 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();
|
||||
});
|
||||
|
||||
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();
|
||||
|
||||
expect(app(\App\Services\Wireguard\WireguardHub::class)->peers())
|
||||
->toContain($host->wg_pubkey);
|
||||
});
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Test laufen lassen, Fehlschlag bestätigen**
|
||||
|
||||
Run: `cd /home/nexxo/clupilot && docker compose exec -T -w /var/www/html/.worktrees/host-bootstrap app php artisan test --filter=HostEnrolmentTest`
|
||||
Expected: FAIL mit `Class "App\Support\HostEnrolment" not found`
|
||||
|
||||
- [ ] **Step 3: Migration und Klasse schreiben**
|
||||
|
||||
Die Migration legt drei Spalten an `hosts` an — Hash, Ablauf, Verbrauch. Der
|
||||
private Schlüssel wird **nicht** gespeichert: er wandert einmal in die
|
||||
Befehlszeile und ist nach dem Tausch (Task 3) wertlos.
|
||||
|
||||
```php
|
||||
public function issue(Host $host): string
|
||||
{
|
||||
$hub = app(WireguardHub::class);
|
||||
|
||||
// Das Paar entsteht HIER und nicht auf dem Host. Erzeugte der Host es
|
||||
// selbst, müsste er seinen öffentlichen Schlüssel melden, bevor der Tunnel
|
||||
// steht — und dafür bräuchte es einen öffentlichen Endpunkt. Der Preis ist
|
||||
// ein Schlüssel, der Minuten lebt (Task 3 tauscht ihn), der Gewinn ist eine
|
||||
// Netzgrenze, die unverändert bleibt.
|
||||
[$privateKey, $publicKey] = self::keypair();
|
||||
$ip = $hub->allocateIp();
|
||||
$hub->addPeer($publicKey, $ip);
|
||||
|
||||
$code = Str::random(32);
|
||||
|
||||
$host->update([
|
||||
'wg_ip' => $ip,
|
||||
'wg_pubkey' => $publicKey,
|
||||
'enrolment_code_hash' => hash('sha256', $code),
|
||||
'enrolment_expires_at' => now()->addDay(),
|
||||
'enrolment_used_at' => null,
|
||||
]);
|
||||
|
||||
// Der private Schlüssel wird bewusst nicht abgelegt. Er geht in die
|
||||
// Befehlszeile und sonst nirgendwohin.
|
||||
self::$issuedPrivateKey = $privateKey;
|
||||
|
||||
return $code;
|
||||
}
|
||||
```
|
||||
|
||||
`claim()` sucht über den Hash, prüft `enrolment_expires_at > now()` und
|
||||
`enrolment_used_at === null`, setzt `enrolment_used_at` und gibt den Host zurück.
|
||||
|
||||
**Hash statt `Hash::make`:** ein `sha256` über einen 32-Zeichen-Zufallswert reicht
|
||||
hier und ist suchbar. Ein bcrypt-Hash wäre nicht in einer `where`-Bedingung
|
||||
auflösbar, ohne alle Zeilen durchzuprobieren.
|
||||
|
||||
- [ ] **Step 4: Test laufen lassen, Erfolg bestätigen**
|
||||
|
||||
Run: wie Step 2
|
||||
Expected: PASS, 5 Tests
|
||||
|
||||
- [ ] **Step 5: Volle Suite**
|
||||
|
||||
Run: `cd /home/nexxo/clupilot && docker compose exec -T -w /var/www/html/.worktrees/host-bootstrap app php artisan test`
|
||||
Expected: PASS
|
||||
|
||||
- [ ] **Step 6: Committen**
|
||||
|
||||
```bash
|
||||
git add -- database/migrations/2026_08_02_090000_give_a_host_its_enrolment.php app/Support/HostEnrolment.php tests/Feature/Host/HostEnrolmentTest.php
|
||||
git commit -F - -- database/migrations/2026_08_02_090000_give_a_host_its_enrolment.php app/Support/HostEnrolment.php tests/Feature/Host/HostEnrolmentTest.php <<'EOF'
|
||||
Let the console admit a host to the tunnel before it exists
|
||||
|
||||
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
|
||||
EOF
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 2: Die Tunnel-Beschränkung — der wichtigste Test dieses Plans
|
||||
|
||||
**Files:**
|
||||
- Create: `app/Http/Middleware/RestrictTunnelOnly.php`
|
||||
- Modify: `bootstrap/app.php` (Alias registrieren)
|
||||
- Test: `tests/Feature/Host/TunnelOnlyTest.php`
|
||||
|
||||
**Interfaces:**
|
||||
- Consumes: `config('admin_access.trusted_ranges')`, das Muster von `RestrictConsoleNetwork`.
|
||||
- Produces: Middleware-Alias `tunnel-only`.
|
||||
|
||||
**Lies zuerst `app/Http/Middleware/RestrictConsoleNetwork.php`.** Es löst dieselbe
|
||||
Frage bereits — CIDR-Vergleich gegen `trusted_ranges` — und dieser Task soll ihm
|
||||
folgen, nicht eine zweite Formulierung danebenstellen. Wenn sich die Logik
|
||||
herausziehen lässt, statt sie zu verdoppeln, tu das.
|
||||
|
||||
- [ ] **Step 1: Den fehlschlagenden Test schreiben**
|
||||
|
||||
```php
|
||||
<?php
|
||||
|
||||
use App\Models\Host;
|
||||
|
||||
/**
|
||||
* Die härteste Bedingung der Spec (§8a): es entsteht kein einziger neuer Weg von
|
||||
* außen. Dieser Test ist der Grund, warum der Entwurf so aussieht, wie er
|
||||
* aussieht — er hält fest, dass alle drei Host-Endpunkte unsichtbar bleiben.
|
||||
*/
|
||||
beforeEach(function () {
|
||||
config()->set('admin_access.trusted_ranges', ['10.66.0.0/24']);
|
||||
});
|
||||
|
||||
dataset('host endpoints', [
|
||||
'register' => ['post', '/host/register'],
|
||||
'progress' => ['post', '/host/progress'],
|
||||
'routes' => ['get', '/host/routes'],
|
||||
]);
|
||||
|
||||
it('answers 404 from outside the tunnel', function (string $method, string $path) {
|
||||
// 404, nicht 403: eine Adresse, die es für Fremde nicht gibt, verrät auch
|
||||
// nicht, dass es sie gibt. Dasselbe Muster wie RestrictAdminHost.
|
||||
$this->withServerVariables(['REMOTE_ADDR' => '203.0.113.9'])
|
||||
->call($method, $path)
|
||||
->assertNotFound();
|
||||
})->with('host endpoints');
|
||||
|
||||
it('is reachable from inside the tunnel', function (string $method, string $path) {
|
||||
// Erreichbar heißt nicht erlaubt — ohne gültigen Ausweis folgt weiter unten
|
||||
// eine Abweisung. Hier zählt nur, dass die Beschränkung nicht schon greift.
|
||||
$this->withServerVariables(['REMOTE_ADDR' => '10.66.0.11'])
|
||||
->call($method, $path)
|
||||
->assertStatus(fn (int $status) => $status !== 404);
|
||||
})->with('host endpoints');
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Test laufen lassen, Fehlschlag bestätigen**
|
||||
|
||||
Expected: FAIL — die Routen gibt es noch nicht
|
||||
|
||||
- [ ] **Step 3: Middleware und Routen-Gerüst schreiben**
|
||||
|
||||
`RestrictTunnelOnly` prüft `$request->ip()` gegen `admin_access.trusted_ranges`
|
||||
und wirft sonst `abort(404)`. Die drei Routen entstehen als Gerüst, das noch
|
||||
nichts tut außer zu antworten — gefüllt werden sie in Task 3 bis 5.
|
||||
|
||||
- [ ] **Step 4: Test laufen lassen, Erfolg bestätigen**
|
||||
|
||||
Expected: PASS, 6 Tests
|
||||
|
||||
- [ ] **Step 5: Volle Suite, dann committen**
|
||||
|
||||
Nachricht: `Make the host endpoints invisible from outside the tunnel`
|
||||
|
||||
---
|
||||
|
||||
### Task 3: `POST /host/register` — Token in den Tresor, Schlüssel tauschen
|
||||
|
||||
**Files:**
|
||||
- Create: `app/Http/Controllers/HostRegistrationController.php`
|
||||
- Modify: `app/Services/Secrets/SecretVault.php` (Registry-Eintrag je Host)
|
||||
- Test: `tests/Feature/Host/HostRegistrationTest.php`
|
||||
|
||||
**Interfaces:**
|
||||
- Consumes: `HostEnrolment::claim()` (Task 1), Tunnel-Middleware (Task 2), `WireguardHub::addPeer/removePeer`.
|
||||
- Produces: `hosts.api_token_ref` gefüllt, `hosts.wg_pubkey` auf den frischen Schlüssel, Antwort enthält das **dauerhafte Host-Token** für den Routen-Abruf.
|
||||
|
||||
- [ ] **Step 1: Den fehlschlagenden Test schreiben**
|
||||
|
||||
```php
|
||||
<?php
|
||||
|
||||
use App\Models\Host;
|
||||
use App\Services\Wireguard\WireguardHub;
|
||||
use App\Support\HostEnrolment;
|
||||
|
||||
beforeEach(function () {
|
||||
config()->set('admin_access.trusted_ranges', ['10.66.0.0/24']);
|
||||
$this->host = Host::factory()->create(['status' => 'onboarding']);
|
||||
$this->code = HostEnrolment::issue($this->host);
|
||||
});
|
||||
|
||||
function registerAs(string $code, array $payload = []): \Illuminate\Testing\TestResponse
|
||||
{
|
||||
return test()->withServerVariables(['REMOTE_ADDR' => '10.66.0.11'])
|
||||
->postJson('/host/register', array_merge([
|
||||
'code' => $code,
|
||||
'api_token' => 'root@pam!clupilot=abc-123',
|
||||
'wg_pubkey' => 'FRESHKEYFRESHKEYFRESHKEYFRESHKEYFRESHKEY01=',
|
||||
'node' => 'pve-fsn-1',
|
||||
'pve_version' => '9.0-1',
|
||||
'total_gb' => 1000,
|
||||
'total_ram_mb' => 65536,
|
||||
'cpu_cores' => 16,
|
||||
'ssh_host_key' => 'SHA256:abc',
|
||||
], $payload));
|
||||
}
|
||||
|
||||
it('puts the proxmox token in the vault and never in the response', function () {
|
||||
$response = registerAs($this->code);
|
||||
|
||||
$response->assertOk();
|
||||
expect($response->json())->not->toContain('root@pam!clupilot=abc-123');
|
||||
expect($this->host->fresh()->api_token_ref)->not->toBeNull();
|
||||
});
|
||||
|
||||
/**
|
||||
* Die Reihenfolge ist die ganze Aussage: erst aufnehmen, dann entfernen.
|
||||
* Andersherum schneidet sich der Host im selben Aufruf den Ast ab, auf dem er
|
||||
* sitzt, und die Antwort erreicht ihn nie.
|
||||
*/
|
||||
it('admits the new key before dropping the old one', function () {
|
||||
$old = $this->host->fresh()->wg_pubkey;
|
||||
|
||||
registerAs($this->code)->assertOk();
|
||||
|
||||
$peers = app(WireguardHub::class)->peers();
|
||||
expect($peers)->toContain('FRESHKEYFRESHKEYFRESHKEYFRESHKEYFRESHKEY01=')
|
||||
->and($peers)->not->toContain($old);
|
||||
expect($this->host->fresh()->wg_pubkey)->toBe('FRESHKEYFRESHKEYFRESHKEYFRESHKEYFRESHKEY01=');
|
||||
});
|
||||
|
||||
it('refuses a code that belongs to another host', function () {
|
||||
$other = Host::factory()->create();
|
||||
$otherCode = HostEnrolment::issue($other);
|
||||
|
||||
registerAs($otherCode)->assertOk();
|
||||
|
||||
// Der fremde Host wurde eingerichtet, DIESER nicht.
|
||||
expect($this->host->fresh()->api_token_ref)->toBeNull();
|
||||
});
|
||||
|
||||
it('refuses a spent code', function () {
|
||||
registerAs($this->code)->assertOk();
|
||||
registerAs($this->code)->assertStatus(422);
|
||||
});
|
||||
|
||||
it('hands back a durable host token for the route pull', function () {
|
||||
expect(registerAs($this->code)->json('host_token'))->toBeString()->not->toBeEmpty();
|
||||
});
|
||||
```
|
||||
|
||||
- [ ] **Step 2 bis 6:** Fehlschlag bestätigen, schreiben, bestätigen, volle Suite, committen.
|
||||
|
||||
**Beim Schreiben beachten:** Der Proxmox-Token gehört in den Tresor (verschlüsselt
|
||||
mit `SECRETS_KEY`), nicht in eine Spalte. `hosts.api_token_ref` hält nur den
|
||||
Verweis — so war es schon vor diesem Plan, und der Handoff nennt die Verknüpfung
|
||||
mit `SECRETS_KEY` ausdrücklich als das teuerste Einzelstück.
|
||||
|
||||
Nachricht: `Take the host's token through the tunnel and swap its key`
|
||||
|
||||
---
|
||||
|
||||
### Task 4: `POST /host/progress` — mit Nachreichen
|
||||
|
||||
**Files:**
|
||||
- Create: `app/Http/Controllers/HostProgressController.php`
|
||||
- Create: `database/migrations/2026_08_02_100000_record_what_a_host_reported.php`
|
||||
- Test: `tests/Feature/Host/HostProgressTest.php`
|
||||
|
||||
**Interfaces:**
|
||||
- Produces: Tabelle `host_progress_events` (`host_id`, `section`, `state`, `message`, `occurred_at`), die Abschnittsliste aus §7 der Spec.
|
||||
|
||||
Die Abschnitte, in dieser Reihenfolge und mit genau diesen Schlüsseln — sie sind
|
||||
die **einzige Absprache** zwischen diesem Plan und dem Skript-Plan:
|
||||
|
||||
`rescue_checked`, `debian_installed`, `rebooted`, `proxmox_installed`,
|
||||
`network_bridged`, `wireguard_joined`, `traefik_running`, `template_built`,
|
||||
`registered`
|
||||
|
||||
- [ ] **Step 1: Den fehlschlagenden Test schreiben**
|
||||
|
||||
Der wichtigste Fall ist das **Nachreichen**: alles vor `wireguard_joined` kann der
|
||||
Host nicht melden, weil es bis dahin keinen Weg zu CluPilot gibt. Er schreibt es
|
||||
lokal mit und reicht es beim ersten erreichbaren Aufruf nach.
|
||||
|
||||
```php
|
||||
it('keeps the reported timestamps, not the arrival time', function () {
|
||||
// Sonst sieht eine zwanzigminütige Installation in der Konsole aus wie eine
|
||||
// Sekunde, und man kann nicht erkennen, welcher Abschnitt lange gedauert hat.
|
||||
$reported = now()->subMinutes(18);
|
||||
|
||||
postProgress($this->code, [
|
||||
['section' => 'rescue_checked', 'state' => 'done', 'occurred_at' => $reported->toIso8601String()],
|
||||
['section' => 'debian_installed', 'state' => 'done', 'occurred_at' => $reported->addMinutes(6)->toIso8601String()],
|
||||
])->assertOk();
|
||||
|
||||
expect($this->host->progressEvents()->first()->occurred_at->toIso8601String())
|
||||
->toBe($reported->copy()->subMinutes(6)->toIso8601String());
|
||||
});
|
||||
|
||||
it('refuses a section it does not know', function () {
|
||||
// Ein Abschnittsname aus dem Netz. Ohne diese Prüfung füllt jemand die
|
||||
// Fortschrittsanzeige mit erfundenen Zeilen.
|
||||
postProgress($this->code, [['section' => 'erfunden', 'state' => 'done']])
|
||||
->assertStatus(422);
|
||||
});
|
||||
```
|
||||
|
||||
- [ ] **Steps 2 bis 6** wie gehabt. Nachricht: `Let the host tell the console how far it got`
|
||||
|
||||
---
|
||||
|
||||
### Task 5: `GET /host/routes` — Traefik holt sich seine Tabelle
|
||||
|
||||
**Files:**
|
||||
- Create: `app/Http/Controllers/HostRoutesController.php`
|
||||
- Create: `app/Services/Traefik/RouteTable.php`
|
||||
- Test: `tests/Feature/Host/HostRoutesTest.php`
|
||||
|
||||
**Interfaces:**
|
||||
- Consumes: das dauerhafte Host-Token aus Task 3, `Instance` mit `subdomain`, `custom_domain`, `routed_hostnames`.
|
||||
- Produces: `RouteTable::forHost(Host $host): array` — Traefiks dynamische Konfiguration für genau diesen Host.
|
||||
|
||||
- [ ] **Step 1: Den fehlschlagenden Test schreiben**
|
||||
|
||||
```php
|
||||
it('serves only the instances of this host', function () {
|
||||
// Ein entwendetes Token zeigt die Routen genau eines Hosts, nicht die aller.
|
||||
$mine = Instance::factory()->create(['host_id' => $this->host->id, 'subdomain' => 'meine']);
|
||||
$theirs = Instance::factory()->create(['subdomain' => 'fremde']);
|
||||
|
||||
$body = json_encode(fetchRoutes($this->hostToken)->json());
|
||||
|
||||
expect($body)->toContain('meine')->not->toContain('fremde');
|
||||
});
|
||||
|
||||
it('serves both the subdomain and a proven custom domain', function () {
|
||||
// Dazunehmen, nicht ersetzen — dieselbe Regel wie ConfigureDnsAndTls.
|
||||
Instance::factory()->create([
|
||||
'host_id' => $this->host->id,
|
||||
'subdomain' => 'mueller',
|
||||
'custom_domain' => 'cloud.mueller-gmbh.de',
|
||||
'domain_verified_at' => now(),
|
||||
]);
|
||||
|
||||
$body = json_encode(fetchRoutes($this->hostToken)->json());
|
||||
|
||||
expect($body)->toContain('mueller.')->and($body)->toContain('cloud.mueller-gmbh.de');
|
||||
});
|
||||
|
||||
it('leaves out a custom domain that was never proven', function () {
|
||||
Instance::factory()->create([
|
||||
'host_id' => $this->host->id,
|
||||
'custom_domain' => 'nicht-bewiesen.de',
|
||||
'domain_verified_at' => null,
|
||||
]);
|
||||
|
||||
expect(json_encode(fetchRoutes($this->hostToken)->json()))
|
||||
->not->toContain('nicht-bewiesen.de');
|
||||
});
|
||||
```
|
||||
|
||||
**Beim Schreiben:** Die Entrypoint- und certResolver-Namen müssen zu dem passen,
|
||||
was `SshTraefikWriter::render()` heute ausgibt. Der Handoff warnt in Block C
|
||||
ausdrücklich: *„hier zuerst nachlesen, nicht raten."* Das gilt unverändert.
|
||||
|
||||
- [ ] **Steps 2 bis 6.** Nachricht: `Serve a host the routes it is supposed to answer for`
|
||||
|
||||
---
|
||||
|
||||
### Task 6: `SshTraefikWriter` ablösen
|
||||
|
||||
**Files:**
|
||||
- Modify: `app/Services/Traefik/` (neuer `TraefikWriter`, der in die Datenbank schreibt)
|
||||
- Delete: `app/Services/Traefik/SshTraefikWriter.php`
|
||||
- Test: `tests/Feature/Host/NoShellForRoutesTest.php`
|
||||
|
||||
**Interfaces:**
|
||||
- Die Aufrufer (`ConfigureDnsAndTls`, `RunAcceptanceChecks`, `EndInstanceService`) ändern sich **nicht**. Sie rufen weiter `TraefikWriter::write(...)`; nur was dahinter passiert, ändert sich.
|
||||
|
||||
- [ ] **Step 1: Den fehlschlagenden Test schreiben**
|
||||
|
||||
```php
|
||||
/**
|
||||
* Der Beweis, dass die Plattform keinen Shell-Zugang mehr auf Kundenhosts
|
||||
* braucht. Solange irgendwo eine RemoteShell für Routen aufgemacht wird, ist der
|
||||
* Entwurf nicht eingelöst — egal wie grün alles andere ist.
|
||||
*/
|
||||
it('opens no shell when an address is configured', function () {
|
||||
$s = fakeServices();
|
||||
$instance = Instance::factory()->create(['host_id' => $this->host->id]);
|
||||
|
||||
app(ConfigureDnsAndTls::class)->execute(runFor($instance));
|
||||
|
||||
expect($s['shell']->connectionsWith('key'))->toBeEmpty();
|
||||
});
|
||||
|
||||
it('has no SshTraefikWriter left in the repository', function () {
|
||||
expect(file_exists(base_path('app/Services/Traefik/SshTraefikWriter.php')))->toBeFalse();
|
||||
});
|
||||
```
|
||||
|
||||
- [ ] **Steps 2 bis 6.** Nachricht: `Stop reaching into customer hosts to write a route`
|
||||
|
||||
---
|
||||
|
||||
### Task 7: Adminbereich — Befehlszeile und Fortschritt
|
||||
|
||||
**Files:**
|
||||
- Modify: `app/Livewire/Admin/HostCreate.php`, `HostDetail.php` und deren Blades
|
||||
- Test: `tests/Feature/Admin/HostEnrolmentPageTest.php`
|
||||
|
||||
- [ ] **Step 1: Den fehlschlagenden Test schreiben**
|
||||
|
||||
```php
|
||||
it('shows the command exactly once, right after creating the host', function () {
|
||||
// Der Code steht nur als Hash in der Datenbank. Wer die Seite neu lädt,
|
||||
// bekommt ihn nicht wieder — er legt einen neuen an.
|
||||
$page = Livewire::actingAs(Operator::factory()->role('Owner')->create(), 'operator')
|
||||
->test(HostCreate::class)
|
||||
->set('name', 'pve-fsn-2')
|
||||
->set('datacenter', 'fsn')
|
||||
->set('publicIp', '198.51.45.9')
|
||||
->call('create');
|
||||
|
||||
$page->assertSee('curl');
|
||||
|
||||
Livewire::actingAs(Operator::factory()->role('Owner')->create(), 'operator')
|
||||
->test(HostDetail::class, ['uuid' => Host::latest('id')->first()->uuid])
|
||||
->assertDontSee('curl');
|
||||
});
|
||||
|
||||
it('shows which section the host is stuck in', function () {
|
||||
// „Irgendwas ging schief" ist keine Auskunft. Welcher Abschnitt offen ist,
|
||||
// ist eine.
|
||||
$this->host->progressEvents()->create([
|
||||
'section' => 'proxmox_installed', 'state' => 'done', 'occurred_at' => now()->subMinutes(30),
|
||||
]);
|
||||
|
||||
Livewire::actingAs(Operator::factory()->role('Owner')->create(), 'operator')
|
||||
->test(HostDetail::class, ['uuid' => $this->host->uuid])
|
||||
->assertSee(__('hosts.section.network_bridged'));
|
||||
});
|
||||
```
|
||||
|
||||
**R18, R19, R23, R24 gelten** — Blades werden angefasst. Die vier Wächter-Tests am
|
||||
Ende ausdrücklich mitlaufen lassen. Zeiten der Fortschrittsmeldungen gehen durch
|
||||
`->local()`.
|
||||
|
||||
- [ ] **Steps 2 bis 6.** Nachricht: `Give the operator one line to copy and a progress to watch`
|
||||
|
||||
---
|
||||
|
||||
### Task 8: Die Subdomain gehört dem Kunden
|
||||
|
||||
**Files:**
|
||||
- Modify: `app/Livewire/Order.php` bzw. die Kasse, `app/Provisioning/Steps/Customer/ReserveResources.php`
|
||||
- Create: `app/Rules/AvailableSubdomain.php`
|
||||
- Test: `tests/Feature/SubdomainChoiceTest.php`
|
||||
|
||||
**Interfaces:**
|
||||
- Heute: `ReserveResources::uniqueSubdomain()` bildet `Str::slug($customer->name).'-'.Str::random(5)`. Künftig kommt die Subdomain **aus dem Auftrag**; die Ableitung bleibt nur als Rückfall für einen leeren Wunsch.
|
||||
|
||||
- [ ] **Step 1: Den fehlschlagenden Test schreiben**
|
||||
|
||||
```php
|
||||
it('refuses a name the platform needs itself', function (string $name) {
|
||||
expect(validator(['subdomain' => $name], ['subdomain' => new AvailableSubdomain])->passes())
|
||||
->toBeFalse();
|
||||
})->with(['www', 'mail', 'admin', 'api', 'app', 'ws', 'status', 'vpn']);
|
||||
|
||||
it('refuses a name another customer already has, whatever the case', function () {
|
||||
Instance::factory()->create(['subdomain' => 'mueller']);
|
||||
|
||||
expect(validator(['subdomain' => 'MUELLER'], ['subdomain' => new AvailableSubdomain])->passes())
|
||||
->toBeFalse();
|
||||
});
|
||||
|
||||
it('falls back to a neutral name when the field is left empty', function () {
|
||||
// Die Vorgabe darf NICHT der Nachname sein: jede Subdomain landet über das
|
||||
// Zertifikat dauerhaft in den öffentlichen Certificate-Transparency-Logs.
|
||||
$order = orderWithoutSubdomainWish(customerNamed('Anna Müller'));
|
||||
|
||||
$subdomain = app(ReserveResources::class)->execute(runFor($order))->context('subdomain');
|
||||
|
||||
expect($subdomain)->not->toContain('mueller');
|
||||
});
|
||||
|
||||
it('keeps the name the customer chose', function () {
|
||||
$order = orderWishing('meine-wolke');
|
||||
|
||||
expect(app(ReserveResources::class)->execute(runFor($order))->context('subdomain'))
|
||||
->toBe('meine-wolke');
|
||||
});
|
||||
```
|
||||
|
||||
- [ ] **Steps 2 bis 6.** Der Hinweis neben dem Feld muss sagen, dass die Adresse
|
||||
öffentlich und dauerhaft ist. Nachricht: `Let the customer pick the address they will keep`
|
||||
|
||||
---
|
||||
|
||||
### Task 9: Die Kette schrumpfen
|
||||
|
||||
**Files:**
|
||||
- Modify: `config/provisioning.php`
|
||||
- Delete: `app/Provisioning/Steps/Host/{EstablishSshTrust,PrepareBaseSystem,ConfigureWireguard,InstallProxmoxVe,RebootIntoPveKernel,ConfigureProxmox,CreateAutomationToken}.php` und ihre Tests
|
||||
- Modify: `app/Provisioning/Steps/Host/SecureHostFirewall.php` — vom Schreiben zum Prüfen
|
||||
- Test: `tests/Feature/Provisioning/HostPipelineShapeTest.php`
|
||||
|
||||
> **HALT — die einzige echte Kopplung zwischen den zwei Plänen.**
|
||||
>
|
||||
> Diese sieben Dateien sind die **Quelle** für den Skript-Plan. Sie enthalten
|
||||
> Wissen, das aus echten Ausfällen entstanden ist: die Codename-Tabelle für
|
||||
> Debian 13/PVE 9, die vollständige `vmbr0`-Diagnose, die nftables-Regeln samt
|
||||
> der ICMP-Korrektur, die systemd-Aktivierung von `wg0`, und die Proxmox-Rolle
|
||||
> **inklusive `Sys.Modify`**, ohne das jede Kundenbereitstellung am
|
||||
> Backup-Schritt stirbt.
|
||||
>
|
||||
> **Führe diesen Task erst aus, wenn Task 4 bis 9 des Skript-Plans geschrieben
|
||||
> sind** — oder vergewissere dich, dass der Skript-Strang sie gelesen hat. Aus
|
||||
> `git log` sind sie zwar wiederherstellbar, aber niemand liest die Historie,
|
||||
> wenn er nicht weiß, dass dort etwas fehlt. Genau so gehen Erkenntnisse
|
||||
> verloren, die einmal einen Ausfall gekostet haben.
|
||||
|
||||
- [ ] **Step 1: Den fehlschlagenden Test schreiben**
|
||||
|
||||
```php
|
||||
it('has six steps that only look, never change', function () {
|
||||
expect(config('provisioning.pipelines.host'))->toBe([
|
||||
Host\ValidateHostInput::class,
|
||||
Host\VerifyProxmoxApi::class,
|
||||
Host\VerifyVmTemplate::class,
|
||||
Host\RegisterHostDns::class,
|
||||
Host\RegisterCapacity::class,
|
||||
Host\CompleteHostOnboarding::class,
|
||||
]);
|
||||
});
|
||||
|
||||
it('opens no shell anywhere in the host pipeline', function () {
|
||||
// Der Beweis für §1 der Spec: der einzige dauerhafte Zugang ist der
|
||||
// Proxmox-Token.
|
||||
$s = fakeServices();
|
||||
|
||||
foreach (config('provisioning.pipelines.host') as $step) {
|
||||
app($step)->execute($this->run);
|
||||
}
|
||||
|
||||
expect($s['shell']->connectionsWith('key'))->toBeEmpty()
|
||||
->and($s['shell']->connectionsWith('password'))->toBeEmpty();
|
||||
});
|
||||
```
|
||||
|
||||
**Achtung:** `SecureHostFirewall` bleibt, wird aber zur Prüfung. Ob es dafür noch
|
||||
in der Kette steht oder in die Bereitschaftsprüfungen wandert, entscheidet der
|
||||
Implementierer — begründet im Bericht.
|
||||
|
||||
- [ ] **Steps 2 bis 6.** Nachricht: `Shrink the host pipeline to six steps that only look`
|
||||
|
||||
---
|
||||
|
||||
### Task 10: Bereitschaftsprüfungen nachziehen
|
||||
|
||||
**Files:**
|
||||
- Modify: `app/Support/Readiness/OnboardingChecks.php`, `ProvisioningChecks.php`
|
||||
- Modify: `lang/de/readiness.php`, `lang/en/readiness.php`
|
||||
- Test: `tests/Feature/Readiness/OnboardingChecksTest.php`, `ProvisioningChecksTest.php`
|
||||
|
||||
Zu ändern, aus §10 der Spec:
|
||||
|
||||
- `onboarding.ssh_private_key` — **nicht mehr blockierend.** Die Plattform braucht
|
||||
keinen SSH-Schlüssel mehr. Der Tresor-Eintrag bleibt (Notfallzugang), sein
|
||||
Fehlen hält nichts auf.
|
||||
- `provisioning.traefik_path` — **entfällt.** Es gibt kein Verzeichnis mehr, in das
|
||||
CluPilot schreibt.
|
||||
- **Neu:** mindestens ein Host holt seine Routen tatsächlich ab (letzter Abruf
|
||||
jünger als das Intervall). Ein Host, der das nicht mehr tut, serviert
|
||||
stillschweigend einen veralteten Stand.
|
||||
|
||||
**Der Wächter-Test aus dem Betriebsmodus-Vorhaben erzwingt, dass jeder
|
||||
Tresor-Eintrag auf der Bereitschaftsseite auftaucht.** Wer hier einen Eintrag
|
||||
entfernt oder umbenennt, zieht ihn nach.
|
||||
|
||||
- [ ] **Steps 1 bis 6.** Nachricht: `Teach the readiness page that nobody writes to a host any more`
|
||||
|
||||
---
|
||||
|
||||
## Abschluss
|
||||
|
||||
- [ ] Volle Suite grün.
|
||||
- [ ] `VERSION` **nicht** anheben, **kein** Tag. Das passiert erst, wenn auch der Skript-Plan durch ist und beide auf echter Hardware zusammen liefen.
|
||||
- [ ] Branch pushen:
|
||||
|
||||
```bash
|
||||
git push "https://x-access-token:$TOKEN@git.bave.dev/boban/CluPilotCloud.git" feature/host-bootstrap
|
||||
```
|
||||
|
||||
## Was danach offen bleibt
|
||||
|
||||
- **Der Skript-Plan** (`2026-07-30-host-uebernahme-bootstrap-skript.md`) — läuft parallel, trifft sich mit diesem nur an den Abschnittsschlüsseln aus Task 4.
|
||||
- **Die Abnahme auf echter Hardware.** Sie ist die einzige, die zählt.
|
||||
- Die zwei Seed-Hosts aus `DatabaseSeeder` entfernen.
|
||||
- Nextcloud zurücksetzen und die Störungsmeldung — beschlossen, eigene Entwürfe, §14 der Spec.
|
||||
|
|
@ -0,0 +1,482 @@
|
|||
# Spec — Der Host installiert sich selbst, CluPilot übernimmt ihn nur
|
||||
|
||||
**Datum:** 2026-07-30
|
||||
**Status:** entworfen, noch nicht umgesetzt
|
||||
**Basis:** `feature/betriebsmodus` (Betriebsmodus + Bereitschaftsseite)
|
||||
**Vorgänger-Kontext:** `docs/handoffs/2026-07-30-real-run-handoff.md` §3, Blöcke A–C
|
||||
|
||||
---
|
||||
|
||||
## 1. Ziel
|
||||
|
||||
Die Verantwortungsgrenze verschiebt sich. **CluPilot installiert nichts mehr.** Ein
|
||||
Bootstrap-Skript macht aus einer nackten Maschine einen fertigen Proxmox-Host mit
|
||||
Traefik, WireGuard-Beitritt und goldener Nextcloud-Vorlage. CluPilot übernimmt ihn
|
||||
danach und benutzt ihn.
|
||||
|
||||
Der einzige dauerhafte Zugang, den die Plattform zu einem Host behält, ist der
|
||||
**Proxmox-API-Token**. Kein SSH, keine root-Shell, nichts, was eine Shell braucht.
|
||||
|
||||
### Warum
|
||||
|
||||
Der Betreiber will „so wenig Fehler wie möglich". Die drei teuersten Fehlerquellen
|
||||
des heutigen Entwurfs sind alle Installationsarbeit über SSH:
|
||||
|
||||
- **`vmbr0` vollautomatisch** — im Handoff selbst als „riskantester Code des
|
||||
Vorhabens" bezeichnet. Eine Brücke, die für einen Anbieter richtig ist, nimmt bei
|
||||
einem anderen die Maschine vom Netz. `ConfigureProxmox` bricht heute bewusst mit
|
||||
einer Anleitung ab, statt es zu versuchen.
|
||||
- **Traefik als Systemdienst über SSH installieren**, samt Prüfung, dass er lauscht.
|
||||
- **Die goldene Vorlage als Runbook.** Ein Dokument für einen Menschen, mit genau
|
||||
den Fallen, die der Handoff aufzählt: `user: www-data` vergessen, LVM statt einer
|
||||
letzten Partition, `qemu-guest-agent` nicht im Image.
|
||||
|
||||
Alle drei verschwinden als Plattformarbeit, wenn die Maschine fertig ankommt.
|
||||
|
||||
### Nicht Teil dieses Vorhabens
|
||||
|
||||
- **Teil C des Betriebsmodus-Vorhabens** (Testdaten markieren, eigene Belegserie,
|
||||
Aufräumen inkl. VM und DNS). Weiterhin beschlossen, weiterhin eigener Entwurf.
|
||||
- Das Bestellen des Servers und das Starten des Rettungssystems. Das passiert im
|
||||
Kundenbereich von Hetzner oder netcup und ist von hier aus nicht erreichbar.
|
||||
- Migration bestehender Hosts. Es gibt keine echten; die zwei in der Datenbank sind
|
||||
Seed-Daten aus `DatabaseSeeder` und werden entfernt, nicht überführt.
|
||||
|
||||
---
|
||||
|
||||
## 2. Ausgangsbefund — gemessen, nicht gelesen
|
||||
|
||||
Am 2026-07-30 auf dem Stand von `feature/betriebsmodus` nachgemessen.
|
||||
|
||||
| Befund | Beleg |
|
||||
|---|---|
|
||||
| Die Host-Kette hat **14 Schritte** | `config/provisioning.php`, `pipelines.host` |
|
||||
| Sie bleibt beim siebten stehen | `ConfigureProxmox` liefert `fail`, wenn `ip link show vmbr0` nichts findet |
|
||||
| **Kein Traefik-Schritt** in der Kette | Block C nie gebaut |
|
||||
| Routen gehen **per SSH als root** auf den Host | `SshTraefikWriter::write()` → `connectWithKey($trafficHost, 'root', …)` |
|
||||
| Und zwar **nicht einmalig** | Aufrufer: `ConfigureDnsAndTls`, `RunAcceptanceChecks`, `EndInstanceService` |
|
||||
| Die Bereitschaftsseite meldet 6 blockierende Lücken | `Readiness::blocking()`, davon 2 echt |
|
||||
|
||||
Der vierte und fünfte Befund sind der Grund, warum „Zugang nur für die
|
||||
VM-Erstellung" heute **nicht** stimmt: das Routing braucht eine dauerhafte
|
||||
root-Shell auf jedem Kundenhost.
|
||||
|
||||
---
|
||||
|
||||
## 3. Entscheidungen aus dem Brainstorming (verbindlich)
|
||||
|
||||
| Frage | Entscheidung |
|
||||
|---|---|
|
||||
| Wer installiert? | **Ein Bootstrap-Skript auf der Maschine.** CluPilot nie. |
|
||||
| Wie kommt eine Kundenroute auf den Host? | **Der Host holt sie sich.** Traefik-HTTP-Provider fragt einen CluPilot-Endpunkt im Tunnel ab. |
|
||||
| Was hinterlässt das Skript? | **Alles, inklusive der goldenen Vorlage** auf `template_vmid 9000`. |
|
||||
| Wie finden Host und Konsole zueinander? | **Einmal-Code aus dem Adminbereich**, alles Weitere im Tunnel. |
|
||||
| Was sieht die Konsole währenddessen? | **Schritt für Schritt**, gemeldet vom Host. |
|
||||
| Dauerhafter Zugang der Plattform | **Nur der Proxmox-Token.** |
|
||||
|
||||
---
|
||||
|
||||
## 4. Der Ablauf, wie ihn der Betreiber erlebt
|
||||
|
||||
1. Server beim Anbieter bestellen, **Rettungssystem starten** (Kundenbereich des
|
||||
Anbieters).
|
||||
2. Im Adminbereich **Host anlegen**: Name, Rechenzentrum, öffentliche IP. Die
|
||||
Konsole zeigt **eine Zeile zum Kopieren** mit dem Einmal-Code darin.
|
||||
3. Diese Zeile **einmal** im Rettungssystem einfügen.
|
||||
4. Zusehen. Der Fortschritt läuft in der Konsole mit.
|
||||
5. Der Host steht auf `active`, sobald er sich gemeldet und die Prüfungen bestanden
|
||||
hat.
|
||||
|
||||
Zwei Stellen, an denen der Betreiber etwas tut. Kein Wert wird abgetippt.
|
||||
|
||||
---
|
||||
|
||||
## 5. Kein öffentlicher Endpunkt — die Befehlszeile trägt alles
|
||||
|
||||
### Das Henne-Ei-Problem
|
||||
|
||||
Alles soll im Tunnel passieren, aber um dem Tunnel beizutreten, braucht das Skript
|
||||
die WireGuard-Angaben. Ein erster Entwurf löste das mit einem öffentlichen
|
||||
`POST /enrol`. **Das war unnötig**, und der Betreiber hat es zu Recht bestritten.
|
||||
|
||||
Die drei Werte, die das Skript vor dem Tunnel braucht — Hub-Schlüssel,
|
||||
Hub-Adresse, zugeteilte Tunnel-IP — kennt die Konsole beim Anlegen des Hosts alle.
|
||||
Sie kann sie **in die kopierte Befehlszeile schreiben**. Es gibt nichts abzurufen.
|
||||
|
||||
### Der eine Haken, und wie er aufgelöst wird
|
||||
|
||||
Damit der Hub den Host hereinlässt, muss er dessen **öffentlichen** Schlüssel
|
||||
kennen. Erzeugte der Host ihn selbst — wie `ConfigureWireguard` es heute tut —,
|
||||
müsste er ihn melden, **bevor** der Tunnel steht. Genau dafür wäre ein öffentlicher
|
||||
Endpunkt nötig gewesen.
|
||||
|
||||
Also erzeugt **CluPilot** das Schlüsselpaar beim Anlegen des Hosts und gibt es mit.
|
||||
Der Hub kennt den Peer damit von Anfang an, und es gibt keinen Rückkanal vor dem
|
||||
Tunnel.
|
||||
|
||||
### Und danach wird getauscht
|
||||
|
||||
Sobald der Tunnel steht, erzeugt der Host mit `wg genkey` einen **frischen**
|
||||
Schlüssel, meldet den öffentlichen Teil **durch den Tunnel** (§6), und CluPilot
|
||||
tauscht den Peer aus. Der Schlüssel, der je in einer Befehlszeile und einer
|
||||
Zwischenablage stand, ist danach wertlos.
|
||||
|
||||
Das ist der Grund, warum diese Lösung der öffentlichen Tür vorzuziehen ist: der
|
||||
Preis ist ein Schlüssel mit einer Lebensdauer von Minuten, der Gewinn ist, dass die
|
||||
Netzgrenze **unverändert** bleibt. Es kommt kein einziger neuer Weg von außen
|
||||
hinzu.
|
||||
|
||||
### Der Einmal-Code
|
||||
|
||||
Bleibt trotzdem, aber er ist jetzt nur noch ein **Ausweis für den Rückweg**, nicht
|
||||
der Schlüssel zu einer Auskunft: 32 Zeichen aus `random_bytes`, in der Datenbank
|
||||
**nur als Hash**, mit `expires_at` (**24 Stunden**, großzügig — er öffnet nichts
|
||||
nach außen) und `used_at`. Das Skript legt ihn den Aufrufen aus §6 und §7 bei,
|
||||
damit ein Gerät im Tunnel nicht für einen fremden Host sprechen kann.
|
||||
|
||||
---
|
||||
|
||||
## 6. Der Rückweg: Token durch den Tunnel
|
||||
|
||||
Nachdem das Skript dem Tunnel beigetreten ist, meldet es sich über die
|
||||
**Tunnel-Adresse** von CluPilot — nicht über die öffentliche.
|
||||
|
||||
`POST /host/register` (nur aus dem WireGuard-Subnetz erreichbar, `TRUSTED_RANGES`)
|
||||
mit dem Einmal-Code als Ausweis und:
|
||||
|
||||
- dem Proxmox-API-Token, den das Skript auf der Maschine erzeugt hat
|
||||
- dem **frisch erzeugten WireGuard-Pubkey** (§5): CluPilot tauscht den Peer aus und
|
||||
verwirft den, der in der Befehlszeile stand
|
||||
- dem SSH-Host-Schlüssel-Fingerabdruck (nur zur Anzeige; die Plattform verbindet
|
||||
sich nicht mehr)
|
||||
- Knotenname, `pve_version`, Plattengröße, RAM, Kerne
|
||||
|
||||
**Reihenfolge beim Schlüsseltausch:** erst den neuen Peer aufnehmen, dann den alten
|
||||
entfernen. Andersherum schneidet sich der Host im selben Aufruf den Ast ab, auf dem
|
||||
er sitzt — und die Antwort erreicht ihn nie.
|
||||
|
||||
CluPilot legt den Token in den Tresor (`SecretVault`, verschlüsselt mit
|
||||
`SECRETS_KEY`) und schreibt `hosts.api_token_ref`. **Der Betreiber sieht den Token
|
||||
nie.**
|
||||
|
||||
Warum durch den Tunnel: ein Proxmox-Token, der über einen öffentlichen Endpunkt
|
||||
gereicht wird, liegt für die Dauer der Anfrage in fremden Protokollen und
|
||||
Zwischenspeichern. Der Tunnel ist ohnehin da, wenn dieser Aufruf passiert.
|
||||
|
||||
---
|
||||
|
||||
## 7. Fortschritt: der Host erzählt, die Konsole zeigt
|
||||
|
||||
`POST /host/progress` (ebenfalls nur aus dem Tunnel, mit dem Einmal-Code als
|
||||
Ausweis) meldet je Abschnitt einen Schlüssel und einen Zustand.
|
||||
|
||||
Die Abschnitte **vor** `wireguard_joined` kann der Host naturgemäß noch nicht
|
||||
melden — es gibt bis dahin keinen Weg zu CluPilot. Sie werden lokal mitgeschrieben
|
||||
und beim ersten erreichbaren Aufruf **nachgereicht**, mit ihren Zeitstempeln. Die
|
||||
Konsole zeigt bis dahin „wartet auf den Tunnel" und, nach einer Frist, dass sie
|
||||
lange wartet.
|
||||
|
||||
Die Abschnitte des Skripts, in dieser Reihenfolge:
|
||||
|
||||
| Schlüssel | Was passiert |
|
||||
|---|---|
|
||||
| `rescue_checked` | Virtualisierung vorhanden (`vmx`/`svm`), Platte groß genug, Netz da |
|
||||
| `debian_installed` | Grundsystem geschrieben, Partition **als letzte, ohne LVM** |
|
||||
| `rebooted` | Maschine ist aus dem Rettungssystem heraus neu gestartet |
|
||||
| `proxmox_installed` | PVE-Pakete drauf, Kernel aktiv |
|
||||
| `network_bridged` | `vmbr0` steht, Maschine antwortet noch |
|
||||
| `wireguard_joined` | Handshake mit dem Hub bewiesen |
|
||||
| `traefik_running` | Dienst läuft, lauscht auf 80/443, holt seine Routen ab |
|
||||
| `template_built` | Vorlage auf 9000, als Template konvertiert |
|
||||
| `registered` | Token übergeben, Host meldet sich fertig |
|
||||
|
||||
Die Konsole zeigt dieselbe Fortschrittsdarstellung wie heute. Bleibt eine Meldung
|
||||
länger als eine gesetzte Frist aus, steht dort **welcher Abschnitt** offen ist —
|
||||
nicht „irgendetwas ging schief".
|
||||
|
||||
**`network_bridged` ist der gefährliche Abschnitt** und behält deshalb die
|
||||
Selbstrücknahme aus Block B des Handoffs, nur im Skript statt in der Pipeline:
|
||||
Netzkonfiguration sichern, Zeitgeber auf fünf Minuten der sie zurückspielt, Brücke
|
||||
aktivieren, **von außen** prüfen ob die Maschine noch antwortet, erst dann den
|
||||
Zeitgeber abbestellen. Eine Maschine, die dabei vom Netz geht, kommt von selbst
|
||||
zurück.
|
||||
|
||||
---
|
||||
|
||||
## 8. Das Routing dreht die Richtung um
|
||||
|
||||
Heute schreibt CluPilot eine YAML-Datei per SSH in Traefiks dynamisches
|
||||
Verzeichnis. Künftig liefert CluPilot sie aus und Traefik holt sie ab.
|
||||
|
||||
- Neuer Endpunkt `GET /host/routes` (nur aus dem Tunnel), der **alle** Routen dieses
|
||||
Hosts als eine Traefik-Konfiguration ausliefert.
|
||||
- Er braucht ein **dauerhaftes Host-Token**, nicht den Fortschritts-Token: der
|
||||
Fortschritts-Token gilt für die Einrichtung und ist danach wertlos, während die
|
||||
Routen für die Lebensdauer des Hosts abgerufen werden. CluPilot gibt es bei
|
||||
`POST /host/register` (§6) zurück, das Skript legt es in Traefiks statische
|
||||
Konfiguration. Es liegt wie jedes Zugangsdatum verschlüsselt im Tresor und ist
|
||||
je Host verschieden — ein entwendetes Token zeigt die Routen genau eines Hosts,
|
||||
nicht die aller.
|
||||
- Traefiks statische Konfiguration bekommt einen `http`-Provider auf diese URL, mit
|
||||
Abrufintervall.
|
||||
- `SshTraefikWriter` und die `RemoteShell`-Abhängigkeit der Routen-Schreiber
|
||||
entfallen. `TraefikWriter` bleibt als Schnittstelle; die Aufrufer
|
||||
(`ConfigureDnsAndTls`, `RunAcceptanceChecks`, `EndInstanceService`) ändern sich
|
||||
**nicht** — sie schreiben weiter in die Datenbank, nur liest jetzt der Host.
|
||||
|
||||
**Der Nebengewinn ist größer als die Ersparnis an Zugang:** heute ist ein
|
||||
fehlgeschlagener Schreibvorgang verloren und niemand merkt es. Künftig holt sich
|
||||
ein Host, der eine Stunde nicht erreichbar war, seinen Stand beim nächsten Abruf
|
||||
von selbst wieder. Der Zustand konvergiert, statt zu driften.
|
||||
|
||||
**Namen müssen passen:** Der Handoff warnt in Block C ausdrücklich, dass
|
||||
Entrypoint- und certResolver-Namen zu dem passen müssen, was `SshTraefikWriter::render()`
|
||||
schon ausgibt — „hier zuerst nachlesen, nicht raten". Das gilt unverändert für die
|
||||
statische Konfiguration, die das Skript schreibt.
|
||||
|
||||
---
|
||||
|
||||
## 8a. Das Netzbild, ausdrücklich
|
||||
|
||||
Vom Betreiber festgelegt, und der Grund für mehrere Entscheidungen oben.
|
||||
|
||||
| Auf dem Host | |
|
||||
|---|---|
|
||||
| **80/443** | öffentlich. Die Kunden-Clouds müssen erreichbar sein. |
|
||||
| **22 (SSH)** | **nur im Tunnel.** Aus dem Netz nicht sichtbar. |
|
||||
| **8006 (Proxmox)** | **nur im Tunnel.** Aus dem Netz nicht sichtbar. |
|
||||
| alles übrige | zu |
|
||||
|
||||
| Bei CluPilot | |
|
||||
|---|---|
|
||||
| öffentlich | **nur, was heute schon offen ist** — Website und Portal |
|
||||
| `register`, `progress`, `routes` | ausschließlich aus dem WireGuard-Subnetz |
|
||||
|
||||
**Es entsteht kein einziger neuer Weg von außen.** Das ist die härteste Bedingung
|
||||
dieser Spec, und §11 Test 1 hält sie fest.
|
||||
|
||||
Der Preis, bewusst getragen: fällt die Steuerungs-VM aus, holt kein Host seine
|
||||
Routentabelle. Die Kunden merken davon nichts — Traefik beantwortet Anfragen aus
|
||||
seiner lokalen Tabelle weiter. Verzögert wird nur eine **Änderung** (eine frisch
|
||||
nachgewiesene Kundendomain, eine Kündigung), und die holt der Host beim nächsten
|
||||
Abruf **von selbst** nach. Das ist besser als heute, wo ein fehlgeschlagener
|
||||
Schreibvorgang verloren ist und niemand es bemerkt.
|
||||
|
||||
Zur Klarstellung, weil es im Gespräch zweimal durcheinanderging: WireGuard hat
|
||||
keine Verbindung, die abreißen kann — es ist zustandsloses UDP und nimmt den
|
||||
Verkehr von selbst wieder auf. Was ausfallen kann, ist ein **Ende**: die
|
||||
Steuerungs-VM oder der Host. Das repariert kein Protokoll, und es gilt für jede
|
||||
Variante gleichermaßen.
|
||||
|
||||
---
|
||||
|
||||
## 8b. Die Subdomain gehört dem Kunden
|
||||
|
||||
Heute leitet `ReserveResources::uniqueSubdomain()` sie aus dem Kundennamen ab
|
||||
(`Str::slug($customer->name).'-'.Str::random(5)`). Der Kunde wird nie gefragt.
|
||||
|
||||
**Künftig wählt er sie an der Kasse**, mit drei Vorschlägen aus seinem eigenen
|
||||
Namen und der Möglichkeit, etwas anderes einzugeben. Leeres Feld → ein
|
||||
unauffälliger Zufallsname.
|
||||
|
||||
### Warum an der Kasse und nicht später
|
||||
|
||||
Die Subdomain ist die **Kennung** der Instanz: `instances.subdomain`, der A-Record
|
||||
in unserer Zone, der interne Hostname, das erste Zertifikat, ein Eintrag in
|
||||
Nextclouds `trusted_domains`. Sie nachträglich zu ändern heißt, alles davon
|
||||
gleichzeitig umzuhängen — eine Migration mit Ausfall.
|
||||
|
||||
**Nicht zu verwechseln mit der eigenen Domain des Kunden.** Die kommt später
|
||||
**dazu** und ersetzt nichts: `ConfigureDnsAndTls` bildet
|
||||
`$hostnames = array_filter([$fqdn, $customDomain])` und serviert beide. Dazunehmen
|
||||
ja, umbenennen nein.
|
||||
|
||||
### Warum ein Hinweis danebensteht
|
||||
|
||||
Eine Subdomain ist **dauerhaft öffentlich** — nicht nur im DNS. Jedes
|
||||
Let's-Encrypt-Zertifikat wird in die Certificate-Transparency-Protokolle
|
||||
eingetragen; die sind öffentlich, durchsuchbar und werden nie gelöscht.
|
||||
`mueller.clupilot.cloud` verrät damit für immer, dass ein Müller hier Kunde ist.
|
||||
Einer GmbH ist das gleich, einer Arztpraxis womöglich nicht. Der Kunde soll das
|
||||
wissen, bevor er wählt — deshalb der Satz neben dem Feld, und deshalb ein
|
||||
neutraler Zufallsname als Vorgabe für alle, die nichts eintragen.
|
||||
|
||||
### Regeln
|
||||
|
||||
- **Sperrliste**, mindestens `www`, `mail`, `admin`, `api`, `app`, `ws`, `status`,
|
||||
`vpn` — sonst nimmt sich jemand einen Namen, den die Plattform selbst braucht.
|
||||
- **Eindeutig über alle Kunden**, ohne Rücksicht auf Groß- und Kleinschreibung.
|
||||
- **Reserviert beim Absenden**, nicht erst wenn die VM steht: zwei gleichzeitige
|
||||
Bestellungen dürfen nicht nach demselben Namen greifen.
|
||||
- Länge und erlaubte Zeichen nach DNS-Regeln, keine führenden oder abschließenden
|
||||
Bindestriche.
|
||||
|
||||
---
|
||||
|
||||
## 9. Die Host-Kette schrumpft
|
||||
|
||||
| Heute | Künftig |
|
||||
|---|---|
|
||||
| `ValidateHostInput` | bleibt |
|
||||
| `EstablishSshTrust` | **entfällt** |
|
||||
| `PrepareBaseSystem` | **entfällt** |
|
||||
| `ConfigureWireguard` | **entfällt** (das Skript tritt bei; die Kette prüft nur den Handshake) |
|
||||
| `InstallProxmoxVe` | **entfällt** |
|
||||
| `RebootIntoPveKernel` | **entfällt** |
|
||||
| `ConfigureProxmox` | **entfällt** — Block B erledigt sich |
|
||||
| `CreateAutomationToken` | **entfällt** (das Skript erzeugt ihn) |
|
||||
| `VerifyProxmoxApi` | bleibt, wird zentral |
|
||||
| `VerifyVmTemplate` | bleibt |
|
||||
| `RegisterHostDns` | bleibt |
|
||||
| `RegisterCapacity` | bleibt |
|
||||
| `SecureHostFirewall` | **entfällt** als Schreiben, bleibt als **Prüfung** |
|
||||
| `CompleteHostOnboarding` | bleibt |
|
||||
|
||||
Sechs Schritte statt vierzehn, und die verbleibenden **sehen nach, statt zu
|
||||
verändern**. Ein Host, der die Kette nicht besteht, wird nicht repariert — er wird
|
||||
neu aufgesetzt, was jetzt ein Befehl ist.
|
||||
|
||||
---
|
||||
|
||||
## 10. Was an der Bereitschaftsseite nachzuziehen ist
|
||||
|
||||
Das Betriebsmodus-Vorhaben hat Prüfungen gebaut, die von der alten Welt ausgehen:
|
||||
|
||||
- `onboarding.ssh_private_key` — **entfällt als blockierend.** Die Plattform
|
||||
braucht keinen SSH-Schlüssel mehr. Der Tresor-Eintrag bleibt (Altbestand,
|
||||
Notfallzugang), aber sein Fehlen hält nichts mehr auf.
|
||||
- `provisioning.traefik_path` — **entfällt.** Es gibt kein Verzeichnis mehr, in das
|
||||
CluPilot schreibt.
|
||||
- Neu: **der Routen-Endpunkt ist erreichbar** und mindestens ein Host holt ihn
|
||||
tatsächlich ab (letzter Abruf jünger als das Intervall). Ein Host, der seine
|
||||
Routen nicht mehr holt, serviert stillschweigend einen veralteten Stand — genau
|
||||
die Sorte stiller Fehler, gegen die die Seite gebaut ist.
|
||||
|
||||
Der Wächter-Test aus Aufgabe 12 erzwingt, dass jeder Tresor-Eintrag auf der Seite
|
||||
auftaucht; Änderungen an der Registry ziehen ihn nach.
|
||||
|
||||
---
|
||||
|
||||
## 11. Tests
|
||||
|
||||
Ein Skript, das ein Betriebssystem installiert, lässt sich nicht in SQLite prüfen.
|
||||
Die Beweislast teilt sich:
|
||||
|
||||
**Was die Suite prüfen kann und muss:**
|
||||
|
||||
1. **Es entsteht kein öffentlich erreichbarer Weg.** Ein Test hält fest, dass
|
||||
`register`, `progress` und `routes` alle drei hinter der Tunnel-Beschränkung
|
||||
liegen — der wichtigste Test dieser Spec, und der Grund, warum der Entwurf so
|
||||
aussieht.
|
||||
2. Ein Einmal-Code gilt **einmal**, verfällt nach 24 Stunden und liegt **nur als
|
||||
Hash** in der Datenbank.
|
||||
3. Ein Aufruf mit dem Code eines **fremden** Hosts wird abgewiesen — sonst spricht
|
||||
ein Gerät im Tunnel für eine Maschine, die ihm nicht gehört.
|
||||
4. Der Proxmox-Token landet im Tresor und **nie** in einer Antwort, einem Protokoll
|
||||
oder einer Ansicht.
|
||||
5. **Der Schlüsseltausch nimmt erst auf, dann weg.** Ein Test, der die Reihenfolge
|
||||
umdreht, muss zeigen, dass der Host dabei aus dem Tunnel fällt — sonst ist die
|
||||
Reihenfolge nur ein Kommentar.
|
||||
6. Der Schlüssel aus der Befehlszeile ist nach dem Tausch **nicht mehr gültig**.
|
||||
7. `GET /host/routes` liefert genau die Routen dieses Hosts und keine fremden.
|
||||
8. Die geschrumpfte Kette hat die sechs Schritte in der festgelegten Reihenfolge.
|
||||
9. Nachgereichte Fortschrittsmeldungen behalten **ihre** Zeitstempel, nicht den des
|
||||
Eingangs — sonst sieht eine zwanzigminütige Installation aus wie eine Sekunde.
|
||||
|
||||
**Was nur auf echter Hardware zu beweisen ist:** alles im Skript. Der Handoff sagt
|
||||
dazu das Richtige — *„Jeder Schritt, der scheitert, ist ein Fund — und der Server
|
||||
darf dabei kaputtgehen, Debian ist neu eingespielt in Minuten."* Der dedizierte
|
||||
Testserver ist der Prüfstand, und das Skript ist so gebaut, dass ein zweiter Lauf
|
||||
auf einer frisch aufgesetzten Maschine immer gleich ausgeht.
|
||||
|
||||
---
|
||||
|
||||
## 12. Reihenfolge
|
||||
|
||||
1. Einmal-Code (Hash, Ablauf, Verbrauch) und die Schlüsselpaar-Erzeugung beim
|
||||
Anlegen des Hosts — mit den Sicherheitstests aus §11.
|
||||
2. Tunnel-Endpunkte `register` und `progress`, Token in den Tresor, Schlüsseltausch
|
||||
in der richtigen Reihenfolge.
|
||||
3. Adminbereich: Host anlegen zeigt die Befehlszeile, Fortschritt läuft mit.
|
||||
4. Routen-Endpunkt und Umstellung von `SshTraefikWriter` auf den Abruf.
|
||||
5. Die Kette schrumpfen, Bereitschaftsprüfungen nachziehen.
|
||||
6. Das Bootstrap-Skript, Abschnitt für Abschnitt, mit Rückmeldung nach jedem.
|
||||
7. Auf echter Hardware durchlaufen lassen.
|
||||
|
||||
Schritt 1 bis 5 ist prüfbare Plattformarbeit. Schritt 6 ist das Skript. Schritt 7
|
||||
ist die Abnahme, und sie ist die einzige, die zählt.
|
||||
|
||||
---
|
||||
|
||||
## 13. Risiken
|
||||
|
||||
- **Schlüsselmaterial in der Zwischenablage.** Der WireGuard-Schlüssel, mit dem der
|
||||
Host dem Tunnel beitritt, steht in der kopierten Befehlszeile und damit kurz in
|
||||
der Zwischenablage und im Verlauf des Rettungssystems. Entschärft dadurch, dass
|
||||
er Minuten lebt und nach dem Tausch (§5) wertlos ist — und dass das
|
||||
Rettungssystem beim ersten Neustart ohnehin verworfen wird. Wer den Tausch
|
||||
weglässt, hat diesen Punkt dauerhaft; deshalb ist er nicht optional.
|
||||
- **Der Fortschritt hängt am Host.** Ein Skript, das stirbt, bevor es meldet,
|
||||
hinterlässt eine Konsole, die auf etwas wartet, das nie kommt. Deshalb je
|
||||
Abschnitt eine Frist und eine sichtbare Aussage, welcher offen ist.
|
||||
- **Das Skript ist nicht wie die übrige Codebasis prüfbar.** Das ist ein echter
|
||||
Rückschritt in der Beweisbarkeit und der Preis dafür, dass die riskanteste
|
||||
Arbeit die Plattform verlässt. Er ist es wert, aber er ist nicht null.
|
||||
- **Traefik holt sich Routen — auch veraltete.** Fällt CluPilot aus, servieren die
|
||||
Hosts weiter, was sie zuletzt geholt haben. Das ist die richtige Richtung, muss
|
||||
aber bewusst so sein und gehört auf die Bereitschaftsseite (§10).
|
||||
|
||||
---
|
||||
|
||||
## 14. Zwei beschlossene Vorhaben, die hier NICHT drin sind
|
||||
|
||||
Beide im selben Gespräch entschieden, beide bekommen einen eigenen Entwurf. Sie
|
||||
stehen hier, damit die Entscheidungen nicht verlorengehen.
|
||||
|
||||
### Nextcloud zurücksetzen (Kundenfunktion)
|
||||
|
||||
Der Kunde kann seine Nextcloud auf Werkszustand zurücksetzen — wenn er etwas
|
||||
zerschossen hat oder neu anfangen will. Technisch derselbe Ablauf wie eine
|
||||
Erstbereitstellung, nur für einen Kunden, der schon bezahlt hat.
|
||||
|
||||
Beschlossen: **vorher ein Sicherungspunkt, 14 Tage aufbewahrt**, mit einer
|
||||
Wiederherstellung, die der Kunde selbst auslösen kann. Nur der Kontoinhaber, nicht
|
||||
jeder Platz. Subdomain zur Bestätigung abtippen statt nur klicken — „neu starten"
|
||||
und „zurücksetzen" lesen sich zu ähnlich. Sperrfrist zwischen zwei Versuchen.
|
||||
Bestätigung im Modal nach R23.
|
||||
|
||||
**Die Adresse bleibt:** dieselbe Subdomain, dasselbe Zertifikat, eine hinterlegte
|
||||
eigene Domain bleibt hinterlegt. Der Kunde bekommt eine leere Nextcloud unter
|
||||
seiner gewohnten Adresse, keine neue Adresse.
|
||||
|
||||
### Störungsmeldung an den Betreiber
|
||||
|
||||
**Gemessen, dass es das heute nicht gibt:** `SyncMonitoringStatus` läuft alle fünf
|
||||
Minuten und schreibt den Zustand mit, legt aber **keinen Vorfall an** und schickt
|
||||
**keine Mail**. In `app/Mail/` gibt es keine einzige Störungsmeldung — alle
|
||||
vorhandenen Mails gehen an Kunden. Eine Kunden-VM kann also ausfallen, ohne dass
|
||||
jemand es erfährt.
|
||||
|
||||
Beschlossen: **zwei Fehlmessungen in Folge** (der Zeitplan misst fünfminütig, das
|
||||
sind die gewünschten zehn Minuten) lösen eine Störung aus. Ein einzelner Aussetzer
|
||||
nicht.
|
||||
|
||||
**Fällt ein ganzer Host aus, kommt EINE Meldung** — „Host pve-fsn-1 antwortet
|
||||
nicht, 40 Instanzen betroffen" — statt vierzig einzelner. Vierzig Mails sagen
|
||||
nicht, dass es ein Host ist; eine sagt es sofort. Einzelne VMs melden sich
|
||||
weiterhin einzeln.
|
||||
|
||||
Damit es Signal bleibt und nicht Lärm: keine Meldung während eines angekündigten
|
||||
Wartungsfensters, keine für gekündigte oder abgeschaltete Instanzen, **eine Mail je
|
||||
Störung** statt alle fünf Minuten eine neue, und eine Entwarnung, wenn es zurück
|
||||
ist.
|
||||
|
||||
---
|
||||
|
||||
## 15. Folgepunkte
|
||||
|
||||
- Die zwei Seed-Hosts aus `DatabaseSeeder` entfernen. Sie stehen auf `active`,
|
||||
haben keinen Token und tragen Adressen aus RFC 5737.
|
||||
- Teil C des Betriebsmodus-Vorhabens (Testdaten und Aufräumen).
|
||||
- Die geparkten Befunde aus `.superpowers/sdd/…/progress.md`, insbesondere der
|
||||
zweite `source()`-Leser in `integrations.blade.php`.
|
||||
|
|
@ -32,6 +32,7 @@ return [
|
|||
'revenue' => 'Umsatz',
|
||||
'mail' => 'E-Mail',
|
||||
'integrations' => 'Integrationen',
|
||||
'readiness' => 'Bereitschaft',
|
||||
'settings' => 'Einstellungen',
|
||||
'two_factor_setup' => 'Zwei-Faktor-Anmeldung',
|
||||
],
|
||||
|
|
@ -98,6 +99,7 @@ return [
|
|||
'host_error' => 'Host :host meldet einen Fehler.',
|
||||
'host_silent' => 'Host :host hat sich seit über :minutes Minuten nicht gemeldet.',
|
||||
'monitoring_down' => ':n überwachte Instanz(en) nicht erreichbar.',
|
||||
'readiness_blocking' => '{1} :n Punkt auf der Bereitschaftsseite blockiert den Livebetrieb.|[2,*] :n Punkte auf der Bereitschaftsseite blockieren den Livebetrieb.',
|
||||
],
|
||||
|
||||
'customers_sub' => 'Alle Kunden und ihre Pakete.',
|
||||
|
|
|
|||
|
|
@ -131,6 +131,10 @@ return [
|
|||
'pending_change_note' => 'Wechsel auf :plan zum :date vorgemerkt. Bis dahin ändert sich nichts an Ihrem Paket.',
|
||||
'purchased' => 'Kauf vorgemerkt — wir schalten ihn nach der Zahlung frei.',
|
||||
'mock_note' => 'Zahlung & Bereitstellung folgen nach Anbindung des Zahlungsanbieters.',
|
||||
// Bewusst ohne Grund: weder "Stripe-Schlüssel fehlt" noch der aktive
|
||||
// Betriebsmodus gehören dem Kunden mitgeteilt — das ist eine
|
||||
// Betreiberangelegenheit und steht auf der Bereitschaftsseite.
|
||||
'stripe_not_configured' => 'Die Bezahlung ist derzeit nicht eingerichtet. Bitte versuchen Sie es später erneut.',
|
||||
|
||||
'plan' => [
|
||||
'start' => 'Start',
|
||||
|
|
|
|||
|
|
@ -134,4 +134,46 @@ return [
|
|||
'secure_host_firewall' => 'Host-Firewall absichern',
|
||||
'complete_host_onboarding' => 'Onboarding abschließen',
|
||||
],
|
||||
'takeover' => [
|
||||
'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.',
|
||||
|
||||
'badge_before' => 'vorher',
|
||||
'badge_now' => 'jetzt',
|
||||
'badge_later' => 'danach',
|
||||
|
||||
'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.',
|
||||
|
||||
'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.',
|
||||
|
||||
'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.',
|
||||
|
||||
's4_title' => 'Die Befehlszeile kopieren',
|
||||
's4_body' => 'Sie erscheint direkt 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.',
|
||||
|
||||
'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.',
|
||||
|
||||
'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' => '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.',
|
||||
|
||||
'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.',
|
||||
],
|
||||
];
|
||||
|
|
|
|||
|
|
@ -4,6 +4,20 @@ return [
|
|||
'title' => 'Integrationen',
|
||||
'subtitle' => 'Angebundene Dienste, gruppiert nach Zweck — Zugangsdaten und Einstellungen nebeneinander, nicht auf getrennten Seiten nach Speicherort.',
|
||||
|
||||
// Der Umschalter — über allen Zugangsdaten, weil er regiert, welcher
|
||||
// Platz je Eintrag gilt.
|
||||
'mode_title' => 'Betriebsmodus',
|
||||
'mode_body' => 'Entscheidet, welcher Platz je Zugangsdatum unten gilt. Ist der Testplatz leer, gilt im Testbetrieb der Live-Platz — außer beim Stripe-Schlüssel, der in keine Richtung zurückfällt.',
|
||||
'mode_switch_to_test' => 'Zu Testbetrieb wechseln',
|
||||
'mode_switch_to_live' => 'Zu Livebetrieb wechseln',
|
||||
'mode_switched' => 'Betriebsmodus umgelegt: :mode.',
|
||||
'mode_switch_title_test' => 'Wirklich zu Testbetrieb wechseln?',
|
||||
'mode_switch_body_test' => 'Ab sofort gilt für jedes Zugangsdatum der Testplatz — oder, wo dieser leer ist, der Live-Platz. Zahlungen laufen weiter über den aktiven Stripe-Schlüssel des jeweiligen Modus.',
|
||||
'mode_switch_confirm_test' => 'Umlegen',
|
||||
'mode_switch_title_live' => 'Wirklich zu Livebetrieb wechseln?',
|
||||
'mode_switch_body_live' => 'Ab sofort fließt echtes Geld: jede Bestellung wird über den Live-Stripe-Schlüssel abgerechnet, nicht mehr über den Testschlüssel. Das ist keine Umschaltfläche zum Danebenklicken.',
|
||||
'mode_switch_confirm_live' => 'Umlegen',
|
||||
|
||||
'payments_title' => 'Zahlungen (Stripe)',
|
||||
'payments_body' => 'Der Secret Key, mit dem Zahlungen entgegengenommen und Webhooks geprüft werden.',
|
||||
|
||||
|
|
@ -107,4 +121,4 @@ return [
|
|||
'platform' => 'Plattform',
|
||||
'env' => 'Umgebung (.env)',
|
||||
],
|
||||
];
|
||||
];
|
||||
|
|
|
|||
|
|
@ -0,0 +1,117 @@
|
|||
<?php
|
||||
|
||||
/*
|
||||
* Die Bereitschaftsprüfung (App\Support\Readiness). Jeder Satz unter
|
||||
* `*_breaks` beantwortet nicht „was fehlt", sondern „was passiert, wenn es
|
||||
* fehlt bleibt" — siehe den Kopfkommentar von App\Support\Readiness\Check.
|
||||
*/
|
||||
return [
|
||||
'page_title' => 'Betriebsbereitschaft',
|
||||
'page_sub' => 'Jedes Pflichtfeld dieser Installation an einer Stelle — bis oben „Bereit für Livebetrieb" steht, ist hier noch etwas offen.',
|
||||
'ready_test' => 'Bereit für Testbetrieb',
|
||||
'ready_live' => 'Bereit für Livebetrieb',
|
||||
'not_ready' => '{1} Noch nicht bereit — :n blockierender Punkt offen|[2,*] Noch nicht bereit — :n blockierende Punkte offen',
|
||||
|
||||
'group' => [
|
||||
'billing' => 'Abrechnung',
|
||||
'onboarding' => 'Host-Onboarding',
|
||||
'provisioning' => 'Bereitstellung',
|
||||
'delivery' => 'Zustellung',
|
||||
'operation' => 'Betrieb',
|
||||
],
|
||||
|
||||
'status' => [
|
||||
'satisfied' => 'Erfüllt',
|
||||
'blocking' => 'Blockierend',
|
||||
'warning' => 'Warnung',
|
||||
],
|
||||
|
||||
'run_check' => 'Prüfen',
|
||||
'check_ok' => 'Geprüft: in Ordnung',
|
||||
'check_failed' => 'Geprüft: nicht in Ordnung',
|
||||
'check_key_mode' => 'Der Schlüssel gehört zum Konto für :mode.',
|
||||
'check_key_mode_conflict' => 'Diese Installation läuft aber im :mode — der Schlüssel liegt im falschen Platz.',
|
||||
'fix_link' => 'Beheben',
|
||||
'last_heartbeat' => 'Letzter Herzschlag: :when',
|
||||
|
||||
'mode' => [
|
||||
'test' => 'Testbetrieb',
|
||||
'live' => 'Livebetrieb',
|
||||
],
|
||||
|
||||
'billing' => [
|
||||
'stripe_secret' => 'Stripe-Schlüssel (:mode)',
|
||||
'stripe_secret_breaks' => 'Ohne ihn nimmt die Kasse keine Bestellung an — es entsteht gar kein Auftrag.',
|
||||
// Zwei getrennte Sätze, weil die Folgen entgegengesetzt sind: einmal
|
||||
// fließt echtes Geld, das nicht fließen sollte, einmal fließt keines,
|
||||
// das fließen sollte. Ein gemeinsamer Satz müsste beides andeuten und
|
||||
// sagte am Ende keins von beidem.
|
||||
'stripe_secret_test_slot_breaks' => 'Im Testplatz liegt ein Live-Schlüssel. Jede Bestellung bucht damit echtes Geld ab, während diese Seite und die Konsole „Testbetrieb" anzeigen — ein Probekauf ist dann ein echter Kauf, mit echter Karte und echter Abbuchung.',
|
||||
'stripe_secret_live_slot_breaks' => 'Im Live-Platz liegt ein Testschlüssel. Der Kunde durchläuft die Kasse, es wird nie Geld eingezogen, und die Bestellung sieht trotzdem bezahlt aus — die Bereitstellung läuft, der Beleg wird ausgestellt, und auf dem Konto kommt nichts an.',
|
||||
'webhook_secret' => 'Stripe-Signaturschlüssel',
|
||||
'webhook_secret_breaks' => 'Ohne ihn wird eine Zahlung nie verbucht: der Kunde zahlt, und nichts passiert.',
|
||||
'company_details' => 'Firmendaten vollständig',
|
||||
'company_details_breaks' => 'IssueInvoice verweigert die Ausstellung. Die Bereitstellung läuft trotzdem durch — der Kunde bekommt eine laufende Cloud ohne Beleg.',
|
||||
'invoice_series' => 'Rechnungsserie je Belegart',
|
||||
'invoice_series_breaks' => 'IssueInvoice bricht beim Ausstellen ab, sobald eine Serie fehlt oder deaktiviert ist. Die Bereitstellung läuft trotzdem durch — der Kunde bekommt eine laufende Cloud ohne Beleg, und der Fehler landet nur im Log.',
|
||||
'catalogue_synced' => 'Stripe-Katalog abgeglichen',
|
||||
'catalogue_synced_breaks' => 'Ein geprüfter EU-Firmenkunde kann nicht bestellen, weil sein Netto-Preis bei Stripe fehlt.',
|
||||
// Sagt, was passiert, und was dagegen hilft — inklusive der
|
||||
// unbequemen Hälfte: `stripe:sync-catalogue` überspringt jede Zeile,
|
||||
// die schon eine ID trägt, repariert das also NICHT von selbst. Ein
|
||||
// Satz, der nur „bitte neu abgleichen" sagte, schickte den Betreiber
|
||||
// auf einen Befehl, der ihm „already in step" antwortet.
|
||||
// Der dritte Zustand, und ausdrücklich OHNE Leeranweisung: er entsteht
|
||||
// ohne jeden Kontowechsel, und die Objekte gehören zum geltenden Konto.
|
||||
// Wer hier zu löschen anfinge, löschte einen Katalog, an dem laufende
|
||||
// Verträge abgerechnet werden.
|
||||
'catalogue_origin_breaks' => 'Zu den gespeicherten Stripe-IDs ist nicht festgehalten, in welchem Konto sie entstanden sind — etwa weil ein Abgleich mitten im Anlegen abgebrochen ist oder weil eine Bestellung sich ihren fehlenden Preis selbst geholt hat. Solange das offen ist, kann diese Seite nicht sagen, ob die Kasse mit ihnen verkaufen kann. Ein Lauf von stripe:sync-catalogue setzt auf dem Vorhandenen auf, legt nur das Fehlende nach und hält die Herkunft fest — gelöscht werden muss dafür nichts.',
|
||||
'catalogue_account_breaks' => 'Die gespeicherten Stripe-Produkt- und Preis-IDs wurden in einem anderen Konto angelegt als dem, das jetzt gilt (:mode) — Stripe-Objekte gehören dem Konto, das sie ausgestellt hat. Jede Bestellung scheitert damit an der Kasse mit „No such price": kein Auftrag, keine Zahlung. Ein neuer Lauf von stripe:sync-catalogue allein genügt nicht, er überspringt jede Zeile, die schon eine ID trägt; erst nach dem Leeren von plan_families.stripe_product_id und plan_prices.stripe_price_id sowie der Register stripe_plan_prices und stripe_addon_prices legt er den Katalog im aktiven Konto neu an.',
|
||||
],
|
||||
|
||||
'onboarding' => [
|
||||
'ssh_key' => 'SSH-Schlüssel für Hosts',
|
||||
'ssh_key_breaks' => 'EstablishSshTrust legt nur den öffentlichen Teil auf dem Host ab; jeder Schritt danach — und jede Traefik-Änderung auf einem bereits laufenden Host — verbindet sich mit diesem privaten Schlüssel. Ohne ihn bricht das Onboarding im zweiten Schritt ab, und kein Routing-Update erreicht mehr einen Host, der längst live ist.',
|
||||
'secrets_key' => 'Tresor-Schlüssel (SECRETS_KEY)',
|
||||
'secrets_key_breaks' => 'Ohne ihn kann kein Geheimnis in dieser Installation gespeichert oder gelesen werden — nicht der Stripe-Schlüssel, nicht der DNS-Token, kein Host-Token. Die Konsole zeigt jedes dieser Felder als leer, egal was je eingetragen wurde.',
|
||||
'vpn_config_key' => 'VPN-Konfigurationsschlüssel (VPN_CONFIG_KEY)',
|
||||
'vpn_config_key_breaks' => 'Ohne ihn verweigert ConfigVault das Speichern jeder VPN-Konfiguration, statt sie im Klartext abzulegen — ein ausgestellter Zugang lässt sich dann nirgends mehr nachlesen.',
|
||||
'wg_hub' => 'WireGuard-Hub vollständig konfiguriert',
|
||||
'wg_hub_breaks' => 'Zwei von drei Werten sind kein halber Tunnel, sondern gar keiner: ConfigureWireguard schreibt die Peer-Konfiguration aus allen dreien auf einmal. Fehlt einer, bekommt der neue Host keinen Tunnel zur Verwaltung, und niemand kann ihn erreichen.',
|
||||
'datacenter' => 'Mindestens ein Rechenzentrum',
|
||||
'datacenter_breaks' => 'Ohne Rechenzentrum hat das Host-Anlegen-Formular nichts zur Auswahl, und ValidateHostInput bricht als allererster Schritt der Kette ab — noch bevor überhaupt eine SSH-Verbindung versucht wird.',
|
||||
],
|
||||
|
||||
'provisioning' => [
|
||||
'dns_token' => 'Hetzner-DNS-Token',
|
||||
'dns_token_breaks' => 'Ohne ihn scheitert ConfigureDnsAndTls am A-Eintrag für die neue Instanz — mitten in der Bereitstellung, nachdem die Maschine schon läuft und der Kunde schon bezahlt hat.',
|
||||
'dns_zone' => 'DNS-Zone',
|
||||
'dns_zone_breaks' => 'Leer, und HttpHetznerDnsClient sucht bei Hetzner nach einer Zone ohne Namen: zoneId() findet keine, wirft „DNS zone not found", und ConfigureDnsAndTls scheitert damit an genau der Stelle, an der auch der fehlende DNS-Token zuschlägt — mitten in der Bereitstellung, nachdem der Kunde schon bezahlt hat.',
|
||||
'traefik_path' => 'Traefik-Konfigurationspfad',
|
||||
'traefik_path_breaks' => 'Leer, und SshTraefikWriter schreibt die Routendatei an die Wurzel des Dateisystems statt in Traefiks eigenes Verzeichnis. Der Schritt meldet trotzdem Erfolg — Traefik sieht die neue Route nur nie, und die Cloud des Kunden bleibt über ihre Adresse unerreichbar.',
|
||||
'usable_host' => 'Mindestens ein einsetzbarer Host',
|
||||
'usable_host_breaks' => 'Ein aktiver Host ohne lesbaren Token wird von ReserveResources trotzdem für eine neue Bestellung ausgewählt. Der Auftrag stirbt danach in CloneVirtualMachine — nachdem der Kunde schon bezahlt hat.',
|
||||
'vm_template' => 'VM-Vorlage je veröffentlichter Version',
|
||||
'vm_template_breaks' => 'Eine veröffentlichte Version ohne template_vmid lässt CloneVirtualMachine sofort mit „template_missing" scheitern — für jede Bestellung dieses Plans, nachdem der Kunde schon bezahlt hat.',
|
||||
'monitoring_token' => 'Überwachungs-Token',
|
||||
'monitoring_token_breaks' => 'Ohne ihn scheitert RegisterMonitoring. Ist `monitoring.required` nicht gesetzt (die Vorgabe), liefert die Bereitstellung trotzdem aus — nur ohne Überwachung, bis der Schlüssel nachgetragen wird. Ist es gesetzt, bricht der Lauf stattdessen ab, und die Bereitstellung hält an, bis der Schlüssel da ist.',
|
||||
],
|
||||
|
||||
'delivery' => [
|
||||
'mailer_not_log' => 'Mailtransport stellt tatsächlich zu',
|
||||
'mailer_not_log_breaks' => 'Bei mail.default=log, =array oder ganz ohne gesetzten Standard läuft die Maschine, der Beleg wird ausgestellt, und die Mail, die dem Kunden seine fertige Cloud ankündigt, verlässt den Server nie — sie landet in einer Datei oder verschwindet spurlos. Der Kunde erfährt nie, dass er bezahlt hat und etwas läuft.',
|
||||
'mailbox' => 'Mindestens eine Mailbox',
|
||||
'mailbox_breaks' => 'Ohne Mailbox wirft der Versand aus CompleteProvisioning eine Ausnahme — der letzte Schritt der Bereitstellung schlägt fehl und wiederholt sich, obwohl die Maschine längst läuft und der Kunde schon bezahlt hat.',
|
||||
'mail_templates' => 'Mindestens eine Antwortvorlage',
|
||||
'mail_templates_breaks' => 'Auf der Kundenseite bleibt die Vorlagen-Auswahl leer, wo sie sonst anklickbare Antworten anbietet — der Support-Mitarbeiter merkt es sofort beim Öffnen, findet aber nichts zum Einfügen und tippt jede Antwort neu. Zwei Kunden mit derselben Frage bekommen dadurch leicht zwei unterschiedliche Auskünfte, ohne dass irgendwo ein Fehler erscheint.',
|
||||
'inbound_password' => 'Passwort für eingehende Mail',
|
||||
'inbound_password_breaks' => 'Ohne es liefert der Mail-Abruf jedes Mal eine leere Liste. Der Zeitplan läuft unauffällig weiter, aber keine Kundenantwort erreicht je die Inbox der Konsole, und nirgends erscheint ein Fehler.',
|
||||
],
|
||||
|
||||
'operation' => [
|
||||
'scheduler' => 'Zeitplaner läuft',
|
||||
'scheduler_breaks' => 'Ohne ihn läuft kein einziger Zeitplan aus routes/console.php — nicht die Rechnungsarchivierung, nicht die Vertragsprüfung, nicht dieser Herzschlag selbst. Eine bezahlte Bestellung wartet auf TickProvisioning, das nie angestoßen wird, und die Konsole zeigt dieselbe Ruhe wie an einem Tag ohne Bestellungen.',
|
||||
'queue_provisioning' => 'Bereitstellungs-Worker läuft',
|
||||
'queue_provisioning_breaks' => 'Der Zeitplaner kann laufen und Aufträge auf die provisioning-Warteschlange stellen, ohne dass sie je jemand abholt. Eine bezahlte Bestellung bleibt dann liegen — kein Fehler, keine Meldung, einfach nichts, bis jemand von Hand nachsieht.',
|
||||
],
|
||||
];
|
||||
|
|
@ -22,7 +22,23 @@ return [
|
|||
'unlocked_note' => 'Entsperrt. Änderungen wirken sofort auf den laufenden Betrieb.',
|
||||
'lock_again' => 'Wieder sperren',
|
||||
|
||||
// Welcher der zwei Plätze dieses Eintrags jetzt gilt — Speichern UND
|
||||
// Lesen, weil save()/render() ohne Modus-Argument auf
|
||||
// OperatingMode::current() auflösen. Benennt den PLATZ, nicht den Modus:
|
||||
// „Testbetrieb"/„Livebetrieb" steht schon in der Plakette oben auf der
|
||||
// Seite, hier geht es darum, in welche der zwei Zeilen ein neuer Wert
|
||||
// tatsächlich landet.
|
||||
'slot' => [
|
||||
'test' => 'Testplatz aktiv',
|
||||
'live' => 'Live-Platz aktiv',
|
||||
],
|
||||
|
||||
'source_stored' => 'Hier hinterlegt',
|
||||
// Im Testbetrieb gilt der Live-Wert, wenn der Testplatz leer ist — genau
|
||||
// das, was SecretVault::get() tut. Ohne eigene Beschriftung stand hier
|
||||
// „Aus der Serverdatei" über einem Wert aus der Datenbank, und wer die
|
||||
// .env daraufhin änderte, änderte nichts.
|
||||
'source_stored_live' => 'Aus dem Live-Platz',
|
||||
'source_environment' => 'Aus der Serverdatei',
|
||||
'source_none' => 'Nicht gesetzt',
|
||||
'stored_value' => 'Hinterlegt',
|
||||
|
|
|
|||
|
|
@ -32,6 +32,7 @@ return [
|
|||
'revenue' => 'Revenue',
|
||||
'mail' => 'Email',
|
||||
'integrations' => 'Integrations',
|
||||
'readiness' => 'Readiness',
|
||||
'settings' => 'Settings',
|
||||
'two_factor_setup' => 'Two-factor login',
|
||||
],
|
||||
|
|
@ -98,6 +99,7 @@ return [
|
|||
'host_error' => 'Host :host reports an error.',
|
||||
'host_silent' => 'Host :host has not checked in for over :minutes minutes.',
|
||||
'monitoring_down' => ':n monitored instance(s) unreachable.',
|
||||
'readiness_blocking' => '{1} :n item on the readiness page blocks going live.|[2,*] :n items on the readiness page block going live.',
|
||||
],
|
||||
|
||||
'customers_sub' => 'All customers and their plans.',
|
||||
|
|
|
|||
|
|
@ -131,6 +131,10 @@ return [
|
|||
'pending_change_note' => 'A move to :plan is booked for :date. Nothing about your package changes until then.',
|
||||
'purchased' => 'Purchase noted — we activate it after payment.',
|
||||
'mock_note' => 'Payment & fulfillment follow once the payment provider is connected.',
|
||||
// Deliberately without a reason: neither "the Stripe key is missing" nor
|
||||
// which operating mode is active is the customer's to know — that is an
|
||||
// operator matter and belongs on the readiness page instead.
|
||||
'stripe_not_configured' => 'Payment is not set up at the moment. Please try again later.',
|
||||
|
||||
'plan' => [
|
||||
'start' => 'Start',
|
||||
|
|
|
|||
|
|
@ -134,4 +134,46 @@ return [
|
|||
'secure_host_firewall' => 'Secure host firewall',
|
||||
'complete_host_onboarding' => 'Complete onboarding',
|
||||
],
|
||||
'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.',
|
||||
|
||||
'badge_before' => 'first',
|
||||
'badge_now' => 'now',
|
||||
'badge_later' => 'then',
|
||||
|
||||
'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.',
|
||||
|
||||
'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.',
|
||||
|
||||
'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.',
|
||||
|
||||
's4_title' => 'Copy the command line',
|
||||
's4_body' => 'It appears right 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.',
|
||||
|
||||
'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.',
|
||||
|
||||
'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 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.',
|
||||
|
||||
'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.',
|
||||
],
|
||||
];
|
||||
|
|
|
|||
|
|
@ -4,6 +4,20 @@ return [
|
|||
'title' => 'Integrations',
|
||||
'subtitle' => 'Connected services, grouped by purpose — credentials and settings side by side, not on separate pages by storage mechanism.',
|
||||
|
||||
// The switch — above every credential, because it governs which slot of
|
||||
// each entry below applies.
|
||||
'mode_title' => 'Operating mode',
|
||||
'mode_body' => 'Decides which slot of each credential below applies. In test mode, an empty test slot falls back to the live slot — except the Stripe key, which does not fall back either way.',
|
||||
'mode_switch_to_test' => 'Switch to test mode',
|
||||
'mode_switch_to_live' => 'Switch to live mode',
|
||||
'mode_switched' => 'Operating mode switched: :mode.',
|
||||
'mode_switch_title_test' => 'Really switch to test mode?',
|
||||
'mode_switch_body_test' => 'From now on, every credential uses its test slot — or, where that one is empty, the live slot. Payments continue to go through whichever Stripe key belongs to the active mode.',
|
||||
'mode_switch_confirm_test' => 'Switch',
|
||||
'mode_switch_title_live' => 'Really switch to live mode?',
|
||||
'mode_switch_body_live' => 'From now on, real money moves: every order is charged through the live Stripe key, not the test one. This is not a switch to click by accident.',
|
||||
'mode_switch_confirm_live' => 'Switch',
|
||||
|
||||
'payments_title' => 'Payments (Stripe)',
|
||||
'payments_body' => 'The secret key used to accept payments and verify webhooks.',
|
||||
|
||||
|
|
@ -107,4 +121,4 @@ return [
|
|||
'platform' => 'Platform',
|
||||
'env' => 'Environment (.env)',
|
||||
],
|
||||
];
|
||||
];
|
||||
|
|
|
|||
|
|
@ -0,0 +1,112 @@
|
|||
<?php
|
||||
|
||||
/*
|
||||
* The readiness check (App\Support\Readiness). Every `*_breaks` sentence
|
||||
* answers not "what is missing" but "what happens if it stays missing" — see
|
||||
* the header comment of App\Support\Readiness\Check.
|
||||
*/
|
||||
return [
|
||||
'page_title' => 'Readiness',
|
||||
'page_sub' => 'Every required field of this installation in one place — until it says "Ready for live mode" at the top, something here is still open.',
|
||||
'ready_test' => 'Ready for test mode',
|
||||
'ready_live' => 'Ready for live mode',
|
||||
'not_ready' => '{1} Not ready yet — :n blocking item open|[2,*] Not ready yet — :n blocking items open',
|
||||
|
||||
'group' => [
|
||||
'billing' => 'Billing',
|
||||
'onboarding' => 'Host onboarding',
|
||||
'provisioning' => 'Provisioning',
|
||||
'delivery' => 'Delivery',
|
||||
'operation' => 'Operation',
|
||||
],
|
||||
|
||||
'status' => [
|
||||
'satisfied' => 'Satisfied',
|
||||
'blocking' => 'Blocking',
|
||||
'warning' => 'Warning',
|
||||
],
|
||||
|
||||
'run_check' => 'Check',
|
||||
'check_ok' => 'Checked: OK',
|
||||
'check_failed' => 'Checked: not OK',
|
||||
'check_key_mode' => 'This key belongs to the :mode account.',
|
||||
'check_key_mode_conflict' => 'This installation is running in :mode — the key is in the wrong slot.',
|
||||
'fix_link' => 'Fix',
|
||||
'last_heartbeat' => 'Last heartbeat: :when',
|
||||
|
||||
'mode' => [
|
||||
'test' => 'Test mode',
|
||||
'live' => 'Live mode',
|
||||
],
|
||||
|
||||
'billing' => [
|
||||
'stripe_secret' => 'Stripe key (:mode)',
|
||||
'stripe_secret_breaks' => 'Without it the checkout accepts no order at all — no order is ever created.',
|
||||
// Two separate sentences, because the consequences are opposite ones:
|
||||
// real money moving that should not, and no money moving that should.
|
||||
'stripe_secret_test_slot_breaks' => 'The test slot holds a live key. Every order charges real money while this page and the console say "test mode" — a trial purchase is a real purchase, on a real card.',
|
||||
'stripe_secret_live_slot_breaks' => 'The live slot holds a test key. The customer completes checkout, no money is ever taken, and the order still looks paid — provisioning runs, the invoice is issued, and nothing arrives in the account.',
|
||||
'webhook_secret' => 'Stripe signing secret',
|
||||
'webhook_secret_breaks' => 'Without it a payment is never recorded: the customer pays, and nothing happens.',
|
||||
'company_details' => 'Company details complete',
|
||||
'company_details_breaks' => 'IssueInvoice refuses to issue the invoice. Provisioning still goes through — the customer gets a running cloud with no invoice.',
|
||||
'invoice_series' => 'Invoice series for every document kind',
|
||||
'invoice_series_breaks' => 'IssueInvoice aborts while issuing whenever a series is missing or switched off. Provisioning still goes through — the customer gets a running cloud with no invoice, and the failure is only written to the log.',
|
||||
'catalogue_synced' => 'Stripe catalogue in sync',
|
||||
'catalogue_synced_breaks' => 'A verified EU business customer cannot check out, because their net price is missing at Stripe.',
|
||||
// Says what happens and what actually helps, including the awkward
|
||||
// half: stripe:sync-catalogue skips every row that already carries an
|
||||
// id, so it does not repair this on its own.
|
||||
// The third state, deliberately WITHOUT a clear-it-out instruction: it
|
||||
// arises without any account change, and the objects belong to the
|
||||
// account in force.
|
||||
'catalogue_origin_breaks' => 'Nothing records which account the stored Stripe ids were created in — a sync may have died part-way through, or an order may have minted its own missing price. Until that is settled this page cannot say whether the checkout can sell with them. Running stripe:sync-catalogue takes up what is already there, creates only what is missing, and records the account — nothing has to be deleted for it.',
|
||||
'catalogue_account_breaks' => 'The stored Stripe product and price ids were created in a different account from the one now in force (:mode) — a Stripe object belongs to the account that issued it. Every order then fails at the checkout with "No such price": no order, no payment. Re-running stripe:sync-catalogue is not enough on its own, because it skips every row that already carries an id; only after clearing plan_families.stripe_product_id and plan_prices.stripe_price_id as well as the stripe_plan_prices and stripe_addon_prices registers does it recreate the catalogue in the active account.',
|
||||
],
|
||||
|
||||
'onboarding' => [
|
||||
'ssh_key' => 'SSH key for hosts',
|
||||
'ssh_key_breaks' => 'EstablishSshTrust only deploys the public half onto the host; every step after it — and every Traefik change on a host already live — connects with this private key. Without it, onboarding stalls at the second step, and no routing update reaches a host that is already running.',
|
||||
'secrets_key' => 'Vault key (SECRETS_KEY)',
|
||||
'secrets_key_breaks' => 'Without it, nothing can be stored or read in this installation — not the Stripe key, not the DNS token, no host token. The console shows every one of these fields as empty, no matter what was ever entered.',
|
||||
'vpn_config_key' => 'VPN config key (VPN_CONFIG_KEY)',
|
||||
'vpn_config_key_breaks' => 'Without it, ConfigVault refuses to store any VPN config rather than write it in the clear — an issued access can then not be looked up anywhere again.',
|
||||
'wg_hub' => 'WireGuard hub fully configured',
|
||||
'wg_hub_breaks' => 'Two of three values are not half a tunnel, they are none: ConfigureWireguard writes the peer config from all three at once. Missing one leaves the new host with no management tunnel, and nobody can reach it.',
|
||||
'datacenter' => 'At least one datacenter',
|
||||
'datacenter_breaks' => 'Without a datacenter the host-create form has nothing to offer, and ValidateHostInput aborts as the very first step of the chain — before an SSH connection is even attempted.',
|
||||
],
|
||||
|
||||
'provisioning' => [
|
||||
'dns_token' => 'Hetzner DNS token',
|
||||
'dns_token_breaks' => 'Without it, ConfigureDnsAndTls fails to create the A record for the new instance — in the middle of provisioning, after the machine is already running and the customer has already paid.',
|
||||
'dns_zone' => 'DNS zone',
|
||||
'dns_zone_breaks' => 'Empty, and HttpHetznerDnsClient asks Hetzner for a zone with no name: zoneId() finds none, throws "DNS zone not found", and ConfigureDnsAndTls fails at exactly the point the missing DNS token already breaks — in the middle of provisioning, after the customer has already paid.',
|
||||
'traefik_path' => 'Traefik config path',
|
||||
'traefik_path_breaks' => 'Empty, and SshTraefikWriter writes the route file at the filesystem root instead of Traefik\'s own directory. The step still reports success — Traefik simply never sees the new route, and the customer\'s cloud stays unreachable at its address.',
|
||||
'usable_host' => 'At least one usable host',
|
||||
'usable_host_breaks' => 'An active host with no readable token is still picked by ReserveResources for a new order. The order then dies in CloneVirtualMachine — after the customer has already paid.',
|
||||
'vm_template' => 'VM template for every published version',
|
||||
'vm_template_breaks' => 'A published version with no template_vmid makes CloneVirtualMachine fail instantly with "template_missing" — for every order of that plan, after the customer has already paid.',
|
||||
'monitoring_token' => 'Monitoring token',
|
||||
'monitoring_token_breaks' => 'Without it, RegisterMonitoring fails. With `monitoring.required` unset (the default), the cloud still gets delivered, just without monitoring, until the key is added. With it set, the run aborts instead, and delivery stalls until the key is there.',
|
||||
],
|
||||
|
||||
'delivery' => [
|
||||
'mailer_not_log' => 'Mail transport actually delivers',
|
||||
'mailer_not_log_breaks' => 'With mail.default=log, =array, or no default configured at all, the machine runs, the invoice is issued, and the mail announcing the finished cloud never leaves the server — it lands in a file or vanishes without a trace. The customer never learns they paid for something that is running.',
|
||||
'mailbox' => 'At least one mailbox',
|
||||
'mailbox_breaks' => 'With no mailbox, sending from CompleteProvisioning throws — the last step of provisioning fails and keeps retrying, even though the machine is already running and the customer has already paid.',
|
||||
'mail_templates' => 'At least one reply template',
|
||||
'mail_templates_breaks' => 'On the customer page, the template picker sits empty where it would otherwise offer ready answers to click — the support agent notices immediately on opening it, finds nothing to insert, and types every reply from scratch. Two customers asking the same question can then easily get two different answers, with no error appearing anywhere.',
|
||||
'inbound_password' => 'Inbound mail password',
|
||||
'inbound_password_breaks' => 'Without it, fetching mail returns an empty list every time. The schedule keeps running quietly, but no customer reply ever reaches the console inbox, and no error appears anywhere.',
|
||||
],
|
||||
|
||||
'operation' => [
|
||||
'scheduler' => 'Scheduler is running',
|
||||
'scheduler_breaks' => 'Without it, not a single schedule in routes/console.php runs — not invoice archiving, not the contract check, not this heartbeat itself. A paid order waits on TickProvisioning, which is never invoked, and the console shows the same calm as a day with no orders at all.',
|
||||
'queue_provisioning' => 'Provisioning worker is running',
|
||||
'queue_provisioning_breaks' => 'The scheduler can be running and putting jobs on the provisioning queue without anyone ever picking them up. A paid order then just sits there — no error, no notice, simply nothing, until someone happens to look.',
|
||||
],
|
||||
];
|
||||
|
|
@ -22,7 +22,22 @@ return [
|
|||
'unlocked_note' => 'Unlocked. Changes take effect on the running system immediately.',
|
||||
'lock_again' => 'Lock again',
|
||||
|
||||
// Which of the two slots this entry resolves to right now — for both
|
||||
// saving and reading, since save()/render() resolve against
|
||||
// OperatingMode::current() without a mode argument. Names the SLOT, not
|
||||
// the mode: "Test mode"/"Live mode" already sits in the badge at the top
|
||||
// of the page; this is about which of the two rows a new value actually
|
||||
// lands in.
|
||||
'slot' => [
|
||||
'test' => 'Test slot active',
|
||||
'live' => 'Live slot active',
|
||||
],
|
||||
|
||||
'source_stored' => 'Stored here',
|
||||
// In test mode the live value applies when the test slot is empty —
|
||||
// exactly what SecretVault::get() does. Without a label of its own this
|
||||
// read "From the server file" over a value coming from the database.
|
||||
'source_stored_live' => 'From the live slot',
|
||||
'source_environment' => 'From the server file',
|
||||
'source_none' => 'Not set',
|
||||
'stored_value' => 'Stored',
|
||||
|
|
|
|||
27
phpunit.xml
27
phpunit.xml
|
|
@ -30,11 +30,32 @@
|
|||
<!-- Fixed test key: storing a VPN config must be exercised, and a
|
||||
random one would make a decryption failure irreproducible. -->
|
||||
<env name="VPN_CONFIG_KEY" value="Y2x1cGlsb3QtdGVzdC1vbmx5LXZwbi1rZXktMzJieXQ=" force="true"/>
|
||||
<!-- Blank so the suite never picks up the operator's real webhook secret
|
||||
<!-- Blank so the suite never picks up the operator's real webhook secrets
|
||||
from .env: with one set, every unsigned test payload is rejected as
|
||||
an invalid signature. The two tests that DO exercise verification
|
||||
set the secret themselves. -->
|
||||
an invalid signature. The tests that DO exercise verification set
|
||||
the secret(s) themselves. _TEST is the live secret's sibling
|
||||
(StripeWebhookSecret picks between them by operating mode) — same
|
||||
variable class, same risk, so it gets the same treatment even
|
||||
though no current test happens to need it. -->
|
||||
<env name="STRIPE_WEBHOOK_SECRET" value="" force="true"/>
|
||||
<env name="STRIPE_WEBHOOK_SECRET_TEST" value="" force="true"/>
|
||||
<!-- Blank so the suite never reads this operator's REAL Hetzner DNS token
|
||||
from .env. This one is load-bearing in a way the two above are not:
|
||||
DnsTokenCheck proves write access by actually WRITING a TXT record
|
||||
to the configured zone and deleting it again — a suite that fell
|
||||
through to the real token would write to and delete from the real
|
||||
clupilot.cloud zone on every test run. Every test that exercises
|
||||
DnsTokenCheck uses Http::fake() and supplies its own candidate
|
||||
token instead. -->
|
||||
<env name="HETZNER_DNS_TOKEN" value="" force="true"/>
|
||||
<!-- Pinned so the suite stops depending on the operator's .env. It did:
|
||||
HostStepsTest asserted `fsn-01.node.clupilot.com` and passed only
|
||||
because this server's .env carried the wrong zone. Correcting the
|
||||
.env turned a green test red without a line of production code
|
||||
changing — a test that reads the developer's machine proves
|
||||
nothing about the code. The value is the one the code itself
|
||||
defaults to. -->
|
||||
<env name="CLUPILOT_DNS_ZONE" value="clupilot.cloud" force="true"/>
|
||||
<env name="APP_MAINTENANCE_DRIVER" value="file"/>
|
||||
<env name="BCRYPT_ROUNDS" value="4" force="true"/>
|
||||
<env name="BROADCAST_CONNECTION" value="null" force="true"/>
|
||||
|
|
|
|||
|
|
@ -1,4 +1,4 @@
|
|||
@props(['entry', 'unlocked'])
|
||||
@props(['entry', 'unlocked', 'mode'])
|
||||
{{--
|
||||
One vault entry — outline, new-value field, test/save/forget — reusable
|
||||
across every integration section on App\Livewire\Admin\Integrations. Kept
|
||||
|
|
@ -25,13 +25,33 @@
|
|||
Serverdatei" it is the answer to "which line?". --}}
|
||||
<p class="mt-0.5 font-mono text-xs text-muted">{{ $entry['envKey'] }}</p>
|
||||
</div>
|
||||
<span class="inline-flex items-center gap-1.5 rounded-pill border px-2.5 py-0.5 text-xs font-medium
|
||||
{{ $entry['source'] === 'stored' ? 'border-success-border bg-success-bg text-success'
|
||||
: ($entry['source'] === 'environment' ? 'border-info-border bg-info-bg text-info'
|
||||
: 'border-warning-border bg-warning-bg text-warning') }}">
|
||||
<span class="size-1.5 rounded-pill bg-current" aria-hidden="true"></span>
|
||||
{{ __('secrets.source_'.$entry['source']) }}
|
||||
</span>
|
||||
<div class="flex flex-wrap items-center gap-2">
|
||||
{{-- Which of the two slots THIS card reads and writes right now —
|
||||
fix round, Important: save()/render() resolve without a mode
|
||||
argument, so they always act on OperatingMode::current()'s
|
||||
slot, but nothing on the card said so. The badge in the
|
||||
header above (Integrations::mode_title) is not enough: it is
|
||||
not visible at the card an operator is about to type into,
|
||||
and repeating "Testbetrieb"/"Livebetrieb" there would only
|
||||
restate the MODE, not name the PLATZ a value lands in. Same
|
||||
colours as that header badge, for one visual language. --}}
|
||||
<span class="inline-flex items-center gap-1.5 rounded-pill border px-2.5 py-0.5 text-xs font-medium
|
||||
{{ $mode->isTest() ? 'border-warning-border bg-warning-bg text-warning' : 'border-success-border bg-success-bg text-success' }}">
|
||||
{{ __('secrets.slot.'.$mode->value) }}
|
||||
</span>
|
||||
{{-- Aufgezählt nach den zwei Enden statt nach jedem Wert einzeln:
|
||||
grün heißt „liegt in DIESEM Platz", gelb heißt „nirgends", und
|
||||
alles dazwischen (aus der Serverdatei, aus dem Live-Platz) ist
|
||||
blau — in Kraft, aber woanders her. So bekommt ein künftiger
|
||||
vierter Wert nicht still die Farbe von „Nicht gesetzt". --}}
|
||||
<span class="inline-flex items-center gap-1.5 rounded-pill border px-2.5 py-0.5 text-xs font-medium
|
||||
{{ $entry['source'] === 'stored' ? 'border-success-border bg-success-bg text-success'
|
||||
: ($entry['source'] === 'none' ? 'border-warning-border bg-warning-bg text-warning'
|
||||
: 'border-info-border bg-info-bg text-info') }}">
|
||||
<span class="size-1.5 rounded-pill bg-current" aria-hidden="true"></span>
|
||||
{{ __('secrets.source_'.$entry['source']) }}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@if (! $unlocked)
|
||||
|
|
|
|||
|
|
@ -0,0 +1,109 @@
|
|||
@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 1–3
|
||||
// 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>
|
||||
|
|
@ -43,6 +43,7 @@
|
|||
'globe' => '<circle cx="12" cy="12" r="10"/><path d="M12 2a14.5 14.5 0 0 0 0 20 14.5 14.5 0 0 0 0-20"/><path d="M2 12h20"/>',
|
||||
'send' => '<path d="M14.536 21.686a.5.5 0 0 0 .937-.024l6.5-19a.496.496 0 0 0-.635-.635l-19 6.5a.5.5 0 0 0-.024.937l7.93 3.18a2 2 0 0 1 1.112 1.11z"/><path d="m21.854 2.147-10.94 10.939"/>',
|
||||
'plug' => '<path d="M12 22v-5"/><path d="M9 8V2"/><path d="M15 8V2"/><path d="M18 8v5a4 4 0 0 1-4 4h-4a4 4 0 0 1-4-4V8Z"/>',
|
||||
'beaker' => '<path d="M4.5 3h15"/><path d="M6 3v16a2 2 0 0 0 2 2h8a2 2 0 0 0 2-2V3"/><path d="M6 14h12"/>',
|
||||
];
|
||||
$body = $icons[$name] ?? '';
|
||||
|
||||
|
|
|
|||
|
|
@ -42,6 +42,16 @@
|
|||
|
||||
<div class="flex min-w-0 flex-1 flex-col lg:h-screen lg:overflow-hidden">
|
||||
<x-shell.topbar :crumbs="array_values(array_filter([__('admin.console'), $title ?? \App\Support\Navigation::currentLabel(console: true)]))">
|
||||
{{-- "Bin ich im Test?" darf keine Frage sein, die man erst
|
||||
durch Navigieren zur Integrationsseite beantwortet — die
|
||||
Plakette steht im Kopf jeder Konsolenseite, solange der
|
||||
Modus test ist, und verschwindet von selbst im Livebetrieb. --}}
|
||||
@if (\App\Support\OperatingMode::current()->isTest())
|
||||
<span class="inline-flex items-center gap-1.5 rounded-full bg-amber-100 px-2.5 py-1 text-xs font-medium text-amber-900 dark:bg-amber-900/40 dark:text-amber-200">
|
||||
<x-ui.icon name="beaker" class="size-4" />
|
||||
{{ __('readiness.mode.test') }}
|
||||
</span>
|
||||
@endif
|
||||
@auth
|
||||
<div class="relative" x-data="{ open: false }" @keydown.escape="open = false">
|
||||
<button type="button" class="flex min-h-11 items-center gap-2.5 rounded px-2 hover:bg-surface-hover" @click="open = !open" :aria-expanded="open" aria-haspopup="menu">
|
||||
|
|
|
|||
|
|
@ -0,0 +1,24 @@
|
|||
{{--
|
||||
Zweizeilige Rückfrage, kein Eingabefeld — braucht nach R24 kein
|
||||
<x-ui.modal>. Live bekommt eine eigene, wärmere Warnfarbe und einen
|
||||
zweiten Satz: das ist der Moment, ab dem echtes Geld bewegt wird, und das
|
||||
darf nicht dieselbe beiläufige Bestätigung sein wie der Wechsel zurück in
|
||||
den Testbetrieb.
|
||||
--}}
|
||||
<div class="rounded-lg bg-surface p-6">
|
||||
<div class="flex items-start gap-3">
|
||||
<span class="grid size-10 shrink-0 place-items-center rounded-lg {{ $mode === 'live' ? 'bg-warning-bg text-warning' : 'bg-accent-subtle text-accent-text' }}">
|
||||
<x-ui.icon name="{{ $mode === 'live' ? 'alert-triangle' : 'beaker' }}" class="size-5" />
|
||||
</span>
|
||||
<div class="min-w-0">
|
||||
<h3 class="text-base font-semibold text-ink">{{ __('integrations.mode_switch_title_'.$mode) }}</h3>
|
||||
<p class="mt-1 text-sm text-muted">{{ __('integrations.mode_switch_body_'.$mode) }}</p>
|
||||
</div>
|
||||
</div>
|
||||
<div class="mt-6 flex justify-end gap-3">
|
||||
<x-ui.button variant="secondary" x-on:click="Livewire.dispatch('closeModal')">{{ __('common.cancel') }}</x-ui.button>
|
||||
<x-ui.button variant="primary" wire:click="confirm" wire:loading.attr="disabled">
|
||||
{{ __('integrations.mode_switch_confirm_'.$mode) }}
|
||||
</x-ui.button>
|
||||
</div>
|
||||
</div>
|
||||
|
|
@ -1,43 +1,72 @@
|
|||
<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>
|
||||
|
||||
@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
|
||||
|
||||
{{-- 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" />
|
||||
|
||||
<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. 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>
|
||||
</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>
|
||||
|
||||
<x-admin.takeover-guide :command="$command" :archive-url="$archiveUrl" :host-uuid="$createdUuid" class="animate-rise" />
|
||||
@endif
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -9,6 +9,21 @@
|
|||
</a>
|
||||
</div>
|
||||
|
||||
{{-- Die Anleitung gehört hierher, nicht nur auf die Anlegen-Seite: ihre
|
||||
ersten zwei Schritte passieren beim Anbieter, und wer erst auf „Host
|
||||
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]">
|
||||
<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>
|
||||
<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" />
|
||||
</details>
|
||||
|
||||
{{-- Filter bar — scales to many hosts (search + datacenter + status). --}}
|
||||
<div class="flex flex-wrap items-center gap-2 animate-rise [animation-delay:40ms]">
|
||||
<div class="relative min-w-48 flex-1">
|
||||
|
|
|
|||
|
|
@ -62,6 +62,36 @@
|
|||
@endif
|
||||
|
||||
@if ($tab === 'services')
|
||||
{{-- The switch — above every vault entry, because it governs them: which
|
||||
slot of each credential below is in force. Same lock as the vault
|
||||
itself (secrets.manage + confirmed password): the mode a stranger
|
||||
could type into a form field is only ever applied through
|
||||
switchMode()'s own guard, but the button that offers it in the first
|
||||
place stays behind the same unlock as everything else on this page. --}}
|
||||
@if ($canSecrets)
|
||||
<div class="flex flex-wrap items-center justify-between gap-3 rounded-lg border border-line bg-surface p-6 shadow-xs animate-rise">
|
||||
<div>
|
||||
<h2 class="font-semibold text-ink">{{ __('integrations.mode_title') }}</h2>
|
||||
<p class="mt-1 max-w-xl text-sm text-muted">{{ __('integrations.mode_body') }}</p>
|
||||
</div>
|
||||
|
||||
<div class="flex items-center gap-3">
|
||||
<span class="inline-flex items-center gap-1.5 rounded-pill border px-2.5 py-0.5 text-xs font-medium
|
||||
{{ $mode->isTest() ? 'border-warning-border bg-warning-bg text-warning' : 'border-success-border bg-success-bg text-success' }}">
|
||||
<span class="size-1.5 rounded-pill bg-current" aria-hidden="true"></span>
|
||||
{{ __('readiness.mode.'.$mode->value) }}
|
||||
</span>
|
||||
|
||||
@if ($unlocked)
|
||||
<x-ui.button variant="secondary"
|
||||
x-on:click="$dispatch('openModal', { component: 'admin.confirm-switch-mode', arguments: { mode: '{{ $mode->isTest() ? 'live' : 'test' }}' } })">
|
||||
{{ $mode->isTest() ? __('integrations.mode_switch_to_live') : __('integrations.mode_switch_to_test') }}
|
||||
</x-ui.button>
|
||||
@endif
|
||||
</div>
|
||||
</div>
|
||||
@endif
|
||||
|
||||
{{-- Two columns from lg up. Each card is a form of two or three fields —
|
||||
stacked, they left half the window empty and pushed monitoring below
|
||||
the fold. --}}
|
||||
|
|
@ -73,7 +103,7 @@
|
|||
<h2 class="font-semibold text-ink">{{ __('integrations.payments_title') }}</h2>
|
||||
<p class="mt-1 text-sm text-muted">{{ __('integrations.payments_body') }}</p>
|
||||
</div>
|
||||
<x-admin.secret-field :entry="$entries['stripe.secret']" :unlocked="$unlocked" />
|
||||
<x-admin.secret-field :entry="$entries['stripe.secret']" :unlocked="$unlocked" :mode="$mode" />
|
||||
@if ($unlocked)
|
||||
<x-ui.alert variant="info">{{ __('secrets.webhook_secret_note') }}</x-ui.alert>
|
||||
@endif
|
||||
|
|
@ -97,7 +127,7 @@
|
|||
|
||||
@if ($canSecrets)
|
||||
@if ($canInfra)<hr class="border-line" />@endif
|
||||
<x-admin.secret-field :entry="$entries['dns.token']" :unlocked="$unlocked" />
|
||||
<x-admin.secret-field :entry="$entries['dns.token']" :unlocked="$unlocked" :mode="$mode" />
|
||||
@endif
|
||||
</div>
|
||||
@endif
|
||||
|
|
@ -123,7 +153,7 @@
|
|||
|
||||
@if ($canSecrets)
|
||||
@if ($canInfra)<hr class="border-line" />@endif
|
||||
<x-admin.secret-field :entry="$entries['inbound_mail.password']" :unlocked="$unlocked" />
|
||||
<x-admin.secret-field :entry="$entries['inbound_mail.password']" :unlocked="$unlocked" :mode="$mode" />
|
||||
@endif
|
||||
|
||||
{{-- The one thing an operator wants after typing all of it: did any
|
||||
|
|
@ -171,7 +201,7 @@
|
|||
|
||||
@if ($canSecrets)
|
||||
@if ($canInfra)<hr class="border-line" />@endif
|
||||
<x-admin.secret-field :entry="$entries['monitoring.token']" :unlocked="$unlocked" />
|
||||
<x-admin.secret-field :entry="$entries['monitoring.token']" :unlocked="$unlocked" :mode="$mode" />
|
||||
@endif
|
||||
</div>
|
||||
@endif
|
||||
|
|
@ -217,7 +247,7 @@
|
|||
|
||||
@if ($canSecrets)
|
||||
@if ($canInfra)<hr class="border-line" />@endif
|
||||
<x-admin.secret-field :entry="$entries['ssh.private_key']" :unlocked="$unlocked" />
|
||||
<x-admin.secret-field :entry="$entries['ssh.private_key']" :unlocked="$unlocked" :mode="$mode" />
|
||||
@endif
|
||||
</div>
|
||||
@endif
|
||||
|
|
|
|||
|
|
@ -0,0 +1,110 @@
|
|||
<div class="space-y-5">
|
||||
<div class="animate-rise">
|
||||
<h1 class="text-2xl font-bold tracking-tight text-ink">{{ __('readiness.page_title') }}</h1>
|
||||
<p class="mt-1 text-sm text-muted">{{ __('readiness.page_sub') }}</p>
|
||||
</div>
|
||||
|
||||
{{-- The one line that matters. Everything below is detail. --}}
|
||||
<div class="flex items-center gap-3 rounded-lg border p-5 shadow-xs animate-rise
|
||||
{{ $ready ? 'border-success-border bg-success-bg' : 'border-warning-border bg-warning-bg' }}">
|
||||
<x-ui.icon :name="$ready ? 'shield-check' : 'alert-triangle'"
|
||||
class="{{ $ready ? 'text-success' : 'text-warning' }}" />
|
||||
<p class="text-lg font-semibold {{ $ready ? 'text-success' : 'text-warning' }}">
|
||||
@if ($ready)
|
||||
{{ $mode->isTest() ? __('readiness.ready_test') : __('readiness.ready_live') }}
|
||||
@else
|
||||
{{ trans_choice('readiness.not_ready', $blockingCount, ['n' => $blockingCount]) }}
|
||||
@endif
|
||||
</p>
|
||||
</div>
|
||||
|
||||
@foreach ($groups as $groupKey => $checks)
|
||||
<div class="rounded-lg border border-line bg-surface p-5 shadow-xs animate-rise">
|
||||
<h2 class="font-semibold text-ink">{{ __('readiness.group.'.$groupKey) }}</h2>
|
||||
|
||||
<ul class="mt-3 divide-y divide-line">
|
||||
@foreach ($checks as $check)
|
||||
@php
|
||||
$badgeStatus = $check->satisfied ? 'active' : ($check->isBlocking() ? 'failed' : 'warning');
|
||||
$statusLabel = $check->satisfied
|
||||
? __('readiness.status.satisfied')
|
||||
: ($check->isBlocking() ? __('readiness.status.blocking') : __('readiness.status.warning'));
|
||||
$iconName = $check->satisfied ? 'check' : ($check->isBlocking() ? 'x' : 'alert-triangle');
|
||||
$iconTone = $check->satisfied ? 'text-success' : ($check->isBlocking() ? 'text-danger' : 'text-warning');
|
||||
$result = $results[$check->key] ?? null;
|
||||
$heartbeat = $heartbeats[$check->key] ?? null;
|
||||
@endphp
|
||||
<li class="flex flex-wrap items-start gap-3 py-3">
|
||||
<x-ui.icon :name="$iconName" class="mt-0.5 size-4 shrink-0 {{ $iconTone }}" />
|
||||
|
||||
<div class="min-w-0 flex-1">
|
||||
<div class="flex flex-wrap items-center gap-2">
|
||||
<span class="font-medium text-ink">{{ $check->label }}</span>
|
||||
<x-ui.badge :status="$badgeStatus">{{ $statusLabel }}</x-ui.badge>
|
||||
</div>
|
||||
|
||||
@unless ($check->satisfied)
|
||||
<p class="mt-1 text-sm text-muted">{{ $check->breaks }}</p>
|
||||
@endunless
|
||||
|
||||
@if ($heartbeat !== null)
|
||||
<p class="mt-1 font-mono text-xs text-faint">
|
||||
{{ __('readiness.last_heartbeat', ['when' => $heartbeat]) }}
|
||||
</p>
|
||||
@endif
|
||||
|
||||
@if ($result !== null)
|
||||
<p class="mt-1 text-xs {{ $result['ok'] ? 'text-success' : 'text-danger' }}">
|
||||
{{ $result['ok'] ? __('readiness.check_ok') : __('readiness.check_failed') }}
|
||||
@if (($result['reason'] ?? null) !== null)
|
||||
— <span class="font-mono">{{ $result['reason'] }}</span>
|
||||
@endif
|
||||
</p>
|
||||
|
||||
{{-- StripeCheck rechnet die Kontoart des Schlüssels längst aus,
|
||||
und diese Seite hat sie bislang weggeworfen: wer „Prüfen"
|
||||
drückte, las „Geprüft: in Ordnung" über einen Live-Schlüssel
|
||||
im Testplatz. Die Prüfung selbst blockiert dafür jetzt
|
||||
(BillingChecks); hier steht, was der Knopf gerade gesehen
|
||||
hat, damit er ihr nicht widerspricht. --}}
|
||||
@if (array_key_exists('live', $result))
|
||||
@php $keyMode = $result['live'] ? 'live' : 'test'; @endphp
|
||||
<p class="mt-1 text-xs {{ $keyMode === $mode->value ? 'text-muted' : 'text-danger' }}">
|
||||
{{ __('readiness.check_key_mode', ['mode' => __('readiness.mode.'.$keyMode)]) }}
|
||||
@unless ($keyMode === $mode->value)
|
||||
{{ __('readiness.check_key_mode_conflict', ['mode' => __('readiness.mode.'.$mode->value)]) }}
|
||||
@endunless
|
||||
</p>
|
||||
@endif
|
||||
@endif
|
||||
</div>
|
||||
|
||||
<div class="flex shrink-0 items-center gap-3">
|
||||
{{-- mount() only requires hosts.manage OR secrets.manage, so an
|
||||
Admin (hosts.manage alone) can reach this page — but runCheck()
|
||||
itself demands secrets.manage and 403s otherwise. A button that
|
||||
is visible and throws when pressed is worse than no button. --}}
|
||||
@if (in_array($check->key, $runnable, true))
|
||||
@can('secrets.manage')
|
||||
<x-ui.button variant="secondary" size="sm"
|
||||
wire:click="runCheck('{{ $check->key }}')"
|
||||
wire:loading.attr="disabled"
|
||||
wire:target="runCheck('{{ $check->key }}')">
|
||||
<x-ui.icon name="refresh" class="size-4" />
|
||||
{{ __('readiness.run_check') }}
|
||||
</x-ui.button>
|
||||
@endcan
|
||||
@endif
|
||||
|
||||
<a href="{{ $this->checkUrl($check) }}" wire:navigate
|
||||
class="inline-flex items-center gap-1 text-xs font-semibold text-accent-text hover:underline">
|
||||
{{ __('readiness.fix_link') }}
|
||||
<x-ui.icon name="external-link" class="size-4" />
|
||||
</a>
|
||||
</div>
|
||||
</li>
|
||||
@endforeach
|
||||
</ul>
|
||||
</div>
|
||||
@endforeach
|
||||
</div>
|
||||
|
|
@ -1,4 +1,4 @@
|
|||
<?php use Illuminate\Support\Number; ?>
|
||||
<?php ?>
|
||||
<div class="space-y-6" x-data="{ msgs: @js(['purchased' => __('billing.purchased')]) }">
|
||||
@php $loc = app()->getLocale(); $eur = fn ($c) => Number::currency($c / 100, in: 'EUR', locale: $loc); @endphp
|
||||
|
||||
|
|
@ -7,6 +7,17 @@
|
|||
<p class="mt-1 text-sm text-muted">{{ __('billing.subtitle') }}</p>
|
||||
</div>
|
||||
|
||||
{{-- purchase() refuses before touching anything when the active mode has
|
||||
no Stripe key — a sentence here, not a 500 page, and not a cart entry
|
||||
nobody can ever pay for.
|
||||
|
||||
OUTSIDE the contract branch below on purpose: the customer most likely
|
||||
to meet this is the one buying for the FIRST time, who has no contract
|
||||
yet. Inside the @else it would be invisible to exactly them. --}}
|
||||
@error('purchase')
|
||||
<x-ui.alert variant="danger">{{ $message }}</x-ui.alert>
|
||||
@enderror
|
||||
|
||||
{{-- Nothing booked. Said plainly instead of falling back to the first plan
|
||||
and rendering it as "AKTUELLES PAKET — Status Aktiv": that is a contract
|
||||
nobody entered into, on the page a customer opens to find out what they
|
||||
|
|
|
|||
|
|
@ -2,8 +2,11 @@
|
|||
|
||||
use App\Http\Controllers\ImpersonationController;
|
||||
use App\Livewire\Admin;
|
||||
use App\Models\Invoice;
|
||||
use App\Services\Billing\InvoiceRenderer;
|
||||
use App\Services\Deployment\UpdateChannel;
|
||||
use Illuminate\Support\Facades\Auth;
|
||||
use Illuminate\Support\Facades\Gate;
|
||||
use Illuminate\Support\Facades\Route;
|
||||
|
||||
/*
|
||||
|
|
@ -97,12 +100,12 @@ Route::get('/invoices/new', Admin\NewInvoice::class)->name('invoices.new');
|
|||
// than a Livewire action: a download is a download, and routing one through a
|
||||
// component round-trip only adds a way for it to fail.
|
||||
Route::get('/invoices/{uuid}/pdf', function (string $uuid) {
|
||||
Illuminate\Support\Facades\Gate::authorize('site.manage');
|
||||
Gate::authorize('site.manage');
|
||||
|
||||
$invoice = App\Models\Invoice::query()->where('uuid', $uuid)->firstOrFail();
|
||||
$invoice = Invoice::query()->where('uuid', $uuid)->firstOrFail();
|
||||
|
||||
return response()->streamDownload(
|
||||
fn () => print (app(App\Services\Billing\InvoiceRenderer::class)->forInvoice($invoice)),
|
||||
fn () => print (app(InvoiceRenderer::class)->forInvoice($invoice)),
|
||||
$invoice->number.'.pdf',
|
||||
['Content-Type' => 'application/pdf'],
|
||||
);
|
||||
|
|
@ -117,6 +120,11 @@ Route::get('/integrations', Admin\Integrations::class)->name('integrations');
|
|||
// uses in routes/web.php.
|
||||
Route::get('/secrets', fn () => redirect()->route('admin.integrations', status: 301))->name('secrets');
|
||||
Route::get('/infrastructure', fn () => redirect()->route('admin.integrations', status: 301))->name('infrastructure');
|
||||
// Everything App\Support\Readiness knows how to check, in one place — gated
|
||||
// the same way as Integrations just above, since it is the page where both
|
||||
// halves that page configures (Settings and the vault) come back together as
|
||||
// one list of what is still missing.
|
||||
Route::get('/readiness', Admin\Readiness::class)->name('readiness');
|
||||
Route::get('/settings', Admin\Settings::class)->name('settings');
|
||||
// Its own route so RequireOperatorTwoFactor can exempt ENROLMENT without
|
||||
// exempting the rest of admin.settings — see that middleware for why.
|
||||
|
|
|
|||
|
|
@ -4,8 +4,10 @@ use App\Console\TickProvisioning;
|
|||
use App\Models\StripePendingEvent;
|
||||
use App\Provisioning\Jobs\CollectInstanceTraffic;
|
||||
use App\Provisioning\Jobs\PingHosts;
|
||||
use App\Provisioning\Jobs\RecordProvisioningHeartbeat;
|
||||
use App\Provisioning\Jobs\SyncMonitoringStatus;
|
||||
use App\Provisioning\Jobs\SyncVpnPeers;
|
||||
use App\Support\Settings;
|
||||
use Illuminate\Foundation\Inspiring;
|
||||
use Illuminate\Support\Facades\Artisan;
|
||||
use Illuminate\Support\Facades\Schedule;
|
||||
|
|
@ -20,6 +22,22 @@ Schedule::call(fn () => app(TickProvisioning::class)())
|
|||
->name('provisioning-tick')
|
||||
->withoutOverlapping();
|
||||
|
||||
// Two signs of life for the readiness page (App\Support\Readiness\
|
||||
// OperationChecks). Written through Settings, not the cache: a heartbeat that
|
||||
// vanishes on a Redis restart would report an outage that never happened.
|
||||
//
|
||||
// Two of them, because the scheduler and the provisioning queue worker fail
|
||||
// independently. The dangerous case is the second one: the scheduler runs and
|
||||
// enqueues jobs (this one included) that nobody ever picks up. A heartbeat
|
||||
// only the scheduler writes would call that state healthy.
|
||||
Schedule::call(fn () => Settings::set('heartbeat.scheduler', now()->toIso8601String()))
|
||||
->everyMinute()
|
||||
->name('heartbeat-scheduler');
|
||||
|
||||
Schedule::job(new RecordProvisioningHeartbeat)
|
||||
->everyMinute()
|
||||
->name('heartbeat-provisioning');
|
||||
|
||||
// Refresh the VPN peer state (handshakes, traffic) so the console does not go
|
||||
// stale while nobody has it open. The job itself runs on the provisioning
|
||||
// queue — that worker owns wg0.
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -1,13 +1,13 @@
|
|||
<?php
|
||||
|
||||
use App\Livewire\Admin\Instances as AdminInstances;
|
||||
use App\Livewire\Admin\Overview;
|
||||
use App\Livewire\Admin\Revenue;
|
||||
use App\Models\Customer;
|
||||
use App\Models\Host;
|
||||
use App\Models\Instance;
|
||||
use App\Models\ProvisioningRun;
|
||||
use App\Models\Subscription;
|
||||
use App\Livewire\Admin\Instances as AdminInstances;
|
||||
use App\Livewire\Admin\Overview;
|
||||
use App\Livewire\Admin\Revenue;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use Livewire\Livewire;
|
||||
|
||||
|
|
@ -19,7 +19,6 @@ use Livewire\Livewire;
|
|||
* twelve-month growth curve. It read like a running business and measured
|
||||
* nothing. These tests exist so that cannot come back quietly.
|
||||
*/
|
||||
|
||||
it('reports an empty estate as empty, not as a going concern', function () {
|
||||
// The strongest statement of the old bug: with nothing in the database the
|
||||
// console still claimed 42 customers, 39 instances, 4 hosts and €7,842 a
|
||||
|
|
@ -43,9 +42,17 @@ it('reports an empty estate as empty, not as a going concern', function () {
|
|||
})
|
||||
->assertViewHas('hostLoad', [])
|
||||
->assertViewHas('runs', [])
|
||||
->assertViewHas('notices', [])
|
||||
->assertSee(__('admin.no_open_runs'))
|
||||
->assertSee(__('admin.no_notices'));
|
||||
// Not notices === [] any more (Task 12): an installation with nothing
|
||||
// configured is not the same as an installation with nothing WRONG.
|
||||
// Readiness::blocking() reports a real, non-invented gap here (nine
|
||||
// unmet checks on a bare database — no Stripe key, no DNS token, no
|
||||
// scheduler heartbeat yet, and so on), and hiding that behind an
|
||||
// empty notices list would be exactly the kind of fiction this file
|
||||
// exists to catch. What is still true of a truly empty estate: no
|
||||
// INCIDENT-shaped notice invents itself out of nothing — the one
|
||||
// notice present is the readiness gap, and nothing else.
|
||||
->assertViewHas('notices', fn (array $notices) => collect($notices)->pluck('route')->all() === ['admin.readiness'])
|
||||
->assertSee(__('admin.no_open_runs'));
|
||||
});
|
||||
|
||||
it('counts what is actually there', function () {
|
||||
|
|
@ -112,20 +119,29 @@ it('states monthly revenue off the contracts, with a yearly term divided', funct
|
|||
->assertDontSee('999,00');
|
||||
});
|
||||
|
||||
it('raises a notice only when something is actually wrong', function () {
|
||||
// See above: the test suite deliberately delivers no mail, and the console
|
||||
// says so. Pinned here so "clean" means an estate with nothing wrong in it.
|
||||
/**
|
||||
* `admin.systems_ok` can no longer be asserted on a bare install (Task 12): a
|
||||
* brand new console always starts with at least one readiness gap, which is
|
||||
* exactly the point of Readiness::blocking() existing at all — a badge
|
||||
* claiming nothing is wrong would itself be the invented-data problem this
|
||||
* file is about. What this test is actually for still holds: an INCIDENT
|
||||
* notice (a failed run, a silent host…) appears only once one is real, on
|
||||
* top of whatever readiness gaps a fresh install already carries.
|
||||
*/
|
||||
it('raises an incident notice only when something is actually wrong', function () {
|
||||
// The suite deliberately delivers no mail, and the console now says so on
|
||||
// the overview. Pinned so this test's "clean" estate is clean of the MAIL
|
||||
// notice too — the readiness gaps above it stay, by design.
|
||||
config()->set('mail.default', 'smtp');
|
||||
|
||||
$clean = Livewire::actingAs(operator('Owner'), 'operator')->test(Overview::class);
|
||||
$clean->assertSee(__('admin.systems_ok'));
|
||||
$clean->assertDontSee(__('admin.notice.failed_runs', ['n' => 1]));
|
||||
|
||||
ProvisioningRun::factory()->create(['status' => ProvisioningRun::STATUS_FAILED]);
|
||||
|
||||
Livewire::actingAs(operator('Owner'), 'operator')
|
||||
->test(Overview::class)
|
||||
->assertSee(__('admin.notice.failed_runs', ['n' => 1]))
|
||||
->assertDontSee(__('admin.systems_ok'));
|
||||
->assertSee(__('admin.notice.failed_runs', ['n' => 1]));
|
||||
});
|
||||
|
||||
it('gets the same committed storage whether or not the sum was preloaded', function () {
|
||||
|
|
|
|||
|
|
@ -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,74 @@
|
|||
<?php
|
||||
|
||||
use App\Livewire\Admin\HostCreate;
|
||||
use App\Livewire\Admin\Hosts;
|
||||
use App\Models\Operator;
|
||||
use Livewire\Livewire;
|
||||
|
||||
beforeEach(function () {
|
||||
fakeServices();
|
||||
config()->set('admin_access.app_host', 'app.clupilot.com');
|
||||
config()->set('provisioning.dns.platform_zone', 'clupilot.com');
|
||||
});
|
||||
|
||||
function guidePage(string $component): \Livewire\Features\SupportTesting\Testable
|
||||
{
|
||||
return Livewire::actingAs(Operator::factory()->role('Owner')->create(), 'operator')->test($component);
|
||||
}
|
||||
|
||||
/**
|
||||
* Der eigentliche Fund, der diese Datei nötig gemacht hat.
|
||||
*
|
||||
* Die erste Fassung zeigte die Anleitung ERST NACH dem Anlegen. Ihre Schritte 1
|
||||
* und 2 passieren aber beim Anbieter — Server bestellen, Rettungssystem starten
|
||||
* —, und wer sie dort zum ersten Mal liest, hat den Einmal-Code schon in der
|
||||
* 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'));
|
||||
});
|
||||
|
||||
/**
|
||||
* Und schon auf der Liste, nicht erst hinter „Host hinzufügen": wer dort steht,
|
||||
* hat den Server womöglich noch gar nicht bestellt — und genau das ist Schritt 1.
|
||||
*/
|
||||
it('offers the same procedure on the hosts list', function () {
|
||||
guidePage(Hosts::class)
|
||||
->assertSee(__('hosts.takeover.how_title'))
|
||||
->assertSee(__('hosts.takeover.s1_title'))
|
||||
->assertSee(__('hosts.takeover.s2_title'));
|
||||
});
|
||||
|
||||
/**
|
||||
* Die zwei Schritte beim Anbieter sind als solche gekennzeichnet. Ohne die
|
||||
* Markierung liest sich die Liste, als könne man oben anfangen und sich
|
||||
* durcharbeiten — und Schritt 2 ist dann eine halbe Stunde Wartezeit mitten
|
||||
* in einem laufenden Code.
|
||||
*/
|
||||
it('marks the two provider steps as things to do first', function () {
|
||||
guidePage(HostCreate::class)->assertSee(__('hosts.takeover.badge_before'));
|
||||
});
|
||||
|
||||
/**
|
||||
* Die Anleitung nennt die Adresse, von der geladen wird, und es muss dieselbe
|
||||
* sein, die später in der Zeile steht. Zwei Fassungen davon liefen auseinander,
|
||||
* 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');
|
||||
});
|
||||
|
||||
/**
|
||||
* Vor dem Anlegen gibt es keine Befehlszeile — sonst stünde dort eine, die zu
|
||||
* keinem Host gehört.
|
||||
*/
|
||||
it('shows no command until a host exists', function () {
|
||||
guidePage(HostCreate::class)->assertDontSee('--wg-private');
|
||||
});
|
||||
|
|
@ -0,0 +1,176 @@
|
|||
<?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', 'app.clupilot.com');
|
||||
config()->set('provisioning.dns.platform_zone', 'clupilot.com');
|
||||
|
||||
// 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', '--fqdn'] as $argument) {
|
||||
expect($command)->toContain($argument);
|
||||
}
|
||||
|
||||
expect($command)
|
||||
->toContain('hub.clupilot.cloud:51820')
|
||||
->toContain('--api http://10.66.0.1')
|
||||
->toContain('/opt/clupilot');
|
||||
|
||||
// Der Name des Hosts liegt auf der PLATTFORM-Zone. `.cloud` gehört den
|
||||
// Kundeninstanzen und ist laut OfficialDomains bewusst eine andere
|
||||
// registrierbare Domain — geprüft wird deshalb der --fqdn-Wert selbst und
|
||||
// nicht die ganze Zeile: der Hub-Endpunkt ist eine eigene Einstellung und
|
||||
// darf heißen, wie er will.
|
||||
preg_match('/--fqdn (\S+)/', $command, $fqdn);
|
||||
expect($fqdn[1] ?? '')->toEndWith('.node.clupilot.com');
|
||||
});
|
||||
|
||||
/**
|
||||
* Dieselbe Trennung an der Stelle, an der sie schon einmal gebrochen war.
|
||||
*
|
||||
* `RegisterHostDns` nennt im eigenen Kopfkommentar `fsn-01.node.clupilot.com`,
|
||||
* die Prüfregel in `Datacenters` nennt denselben Namen, und `ServicesTest`
|
||||
* schreibt ihn wörtlich — der Schritt baute den Namen aber aus
|
||||
* `provisioning.dns.zone`, und das ist die KUNDENZONE. Auf dieser Installation
|
||||
* hieß ein Host damit `fsn-01.node.clupilot.cloud`.
|
||||
*/
|
||||
it('keeps host names out of the customer zone', function () {
|
||||
config()->set('provisioning.dns.zone', 'clupilot.cloud');
|
||||
config()->set('provisioning.dns.platform_zone', 'clupilot.com');
|
||||
|
||||
$host = Host::factory()->create(['dns_name' => 'fsn-01']);
|
||||
|
||||
expect(App\Support\HostTakeoverCommand::fqdnFor($host))
|
||||
->toBe('fsn-01.node.clupilot.com');
|
||||
});
|
||||
|
||||
/**
|
||||
* Ü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://app.clupilot.com/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');
|
||||
});
|
||||
|
||||
/**
|
||||
* Nach dem Anlegen bleibt dieselbe Anleitung stehen, nur mit der Zeile darin.
|
||||
* Ein Betreiber, der bei Schritt 5 nicht weiterweiß, soll nicht auf einer Seite
|
||||
* landen, die nur noch aus einem Kasten besteht.
|
||||
*/
|
||||
it('keeps the whole procedure visible after the host exists', function () {
|
||||
createHostAs()
|
||||
->assertSee(__('hosts.takeover.s1_title'))
|
||||
->assertSee(__('hosts.takeover.s5_title'))
|
||||
->assertSee(__('hosts.takeover.s6_title'))
|
||||
->assertSee('--wg-private', escape: false);
|
||||
});
|
||||
|
||||
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();
|
||||
});
|
||||
|
|
@ -5,6 +5,7 @@ use App\Services\Dns\HttpHetznerDnsClient;
|
|||
use App\Services\Monitoring\HttpMonitoringClient;
|
||||
use App\Services\Secrets\SecretVault;
|
||||
use App\Services\Stripe\HttpStripeClient;
|
||||
use App\Support\OperatingMode;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use Illuminate\Support\Facades\Http;
|
||||
|
||||
|
|
@ -17,28 +18,56 @@ use Illuminate\Support\Facades\Http;
|
|||
beforeEach(function () {
|
||||
config()->set('admin_access.secrets_key', 'base64:'.base64_encode(random_bytes(32)));
|
||||
config()->set('services.stripe.secret', 'sk_env_fallback');
|
||||
|
||||
// None of these tests are about the test/live split (that is
|
||||
// SecretVaultModeTest); they call put()/get() without a $mode and must
|
||||
// therefore land on a known slot regardless of what an earlier test file
|
||||
// left in the array cache that Settings and OperatingMode share.
|
||||
OperatingMode::set(OperatingMode::Live);
|
||||
});
|
||||
|
||||
it('prefers a stored key over the environment, and says which is in force', function () {
|
||||
$vault = app(SecretVault::class);
|
||||
|
||||
expect($vault->get('stripe.secret'))->toBe('sk_env_fallback')
|
||||
->and($vault->source('stripe.secret'))->toBe('environment');
|
||||
// This test uses dns.token (not stripe.secret) to preserve the original
|
||||
// assertion: a non-strict entry falls back to the environment and reports
|
||||
// it as its source. Stripe is exempt from fallback (it is strict),
|
||||
// tested separately in StripeStrictModeTest.
|
||||
config()->set('provisioning.dns.token', 'env_token_fallback');
|
||||
|
||||
$vault->put('stripe.secret', 'sk_live_stored_value', Operator::factory()->create());
|
||||
expect($vault->get('dns.token'))->toBe('env_token_fallback')
|
||||
->and($vault->source('dns.token'))->toBe('environment');
|
||||
|
||||
expect($vault->get('stripe.secret'))->toBe('sk_live_stored_value')
|
||||
->and($vault->source('stripe.secret'))->toBe('stored');
|
||||
$vault->put('dns.token', 'stored_value', Operator::factory()->create());
|
||||
|
||||
expect($vault->get('dns.token'))->toBe('stored_value')
|
||||
->and($vault->source('dns.token'))->toBe('stored');
|
||||
|
||||
// Removing it falls back rather than leaving nothing.
|
||||
$vault->forget('stripe.secret');
|
||||
expect($vault->get('stripe.secret'))->toBe('sk_env_fallback');
|
||||
$vault->forget('dns.token');
|
||||
expect($vault->get('dns.token'))->toBe('env_token_fallback');
|
||||
});
|
||||
|
||||
it('strips the environment from stripe when nothing is stored', function () {
|
||||
// Stripe is a strict entry: it never falls back to the environment, and
|
||||
// source() never reports it as in force when it is not stored. This
|
||||
// prevents the silent contradiction: get() returns null while source()
|
||||
// claims 'environment', and the console would tell the operator a key is
|
||||
// in force while the billing system refuses to use it.
|
||||
$vault = app(SecretVault::class);
|
||||
|
||||
config()->set('services.stripe.secret', 'sk_env_from_config');
|
||||
|
||||
expect($vault->get('stripe.secret'))->toBeNull()
|
||||
->and($vault->source('stripe.secret'))->toBe('none');
|
||||
});
|
||||
|
||||
it('stores the value encrypted, and never in the clear', function () {
|
||||
app(SecretVault::class)->put('stripe.secret', 'sk_live_verysecret', Operator::factory()->create());
|
||||
|
||||
$stored = DB::table('app_secrets')->where('key', 'stripe.secret')->value('value');
|
||||
// The row key carries a mode suffix (SecretVaultModeTest) — the active
|
||||
// mode here is pinned to live above.
|
||||
$stored = DB::table('app_secrets')->where('key', 'stripe.secret:live')->value('value');
|
||||
|
||||
expect($stored)->not->toContain('sk_live_verysecret');
|
||||
});
|
||||
|
|
@ -161,10 +190,10 @@ it('keeps the original creation date when a key is rotated', function () {
|
|||
$operator = Operator::factory()->create();
|
||||
app(SecretVault::class)->put('stripe.secret', 'sk_first', $operator);
|
||||
|
||||
$created = DB::table('app_secrets')->where('key', 'stripe.secret')->value('created_at');
|
||||
$created = DB::table('app_secrets')->where('key', 'stripe.secret:live')->value('created_at');
|
||||
|
||||
$this->travel(2)->days();
|
||||
app(SecretVault::class)->put('stripe.secret', 'sk_second', $operator);
|
||||
|
||||
expect(DB::table('app_secrets')->where('key', 'stripe.secret')->value('created_at'))->toBe($created);
|
||||
expect(DB::table('app_secrets')->where('key', 'stripe.secret:live')->value('created_at'))->toBe($created);
|
||||
});
|
||||
|
|
|
|||
|
|
@ -51,6 +51,10 @@ function entitlementShopper(string $plan = 'team'): array
|
|||
|
||||
$order->subscription->update(['instance_id' => $instance->id]);
|
||||
|
||||
// The tests that buy through the shop call Billing::purchase(), which
|
||||
// since Task 5 refuses outright without a Stripe key for the active mode.
|
||||
withStripeSecret();
|
||||
|
||||
return [$customer, $user, $order->subscription->fresh()];
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -46,6 +46,12 @@ function domainCustomer(string $plan, array $instanceAttributes = []): array
|
|||
'instance_id' => $instance->id,
|
||||
]);
|
||||
|
||||
// One test below calls Billing::purchase(), which since Task 5 refuses
|
||||
// outright without a Stripe key for the active mode. Without this, that
|
||||
// test would see the same "no order" result for the wrong reason — the
|
||||
// Stripe guard, not the plan-restriction it names — and prove nothing.
|
||||
withStripeSecret();
|
||||
|
||||
return [$customer, $user, $instance, $subscription];
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -33,6 +33,13 @@ use Livewire\Livewire;
|
|||
beforeEach(function () {
|
||||
$this->stripe = new FakeStripeClient;
|
||||
app()->instance(StripeClient::class, $this->stripe);
|
||||
|
||||
// Every test here calls Billing::purchase() to place the order it then
|
||||
// marks paid by hand. Since Task 5, that call refuses outright without a
|
||||
// Stripe key for the active mode — binding the fake StripeClient above
|
||||
// does not help, because the guard reads the vault directly rather than
|
||||
// going through the StripeClient interface.
|
||||
withStripeSecret();
|
||||
});
|
||||
|
||||
/** A customer on a live contract, with a machine. */
|
||||
|
|
|
|||
|
|
@ -45,6 +45,11 @@ function pendingChangeCustomer(string $plan = 'business'): array
|
|||
// due date arrives against this id, so there has to be one.
|
||||
$subscription->update(['instance_id' => $instance->id, 'stripe_subscription_id' => 'sub_'.$customer->id]);
|
||||
|
||||
// These tests call Billing::purchase() to book the downgrade/upgrade,
|
||||
// which since Task 5 refuses outright without a Stripe key for the
|
||||
// active mode.
|
||||
withStripeSecret();
|
||||
|
||||
return [$customer, $user, $subscription->fresh(), $instance];
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -65,6 +65,11 @@ function storageFixture(string $plan = 'team', array $instanceAttributes = []):
|
|||
|
||||
$subscription->update(['instance_id' => $instance->id]);
|
||||
|
||||
// Some of these tests book storage packs through Billing::purchase(),
|
||||
// which since Task 5 refuses outright without a Stripe key for the
|
||||
// active mode.
|
||||
withStripeSecret();
|
||||
|
||||
return [
|
||||
'host' => $host,
|
||||
'order' => $order,
|
||||
|
|
|
|||
|
|
@ -0,0 +1,130 @@
|
|||
<?php
|
||||
|
||||
use App\Models\PlanPrice;
|
||||
use App\Services\Stripe\FakeStripeClient;
|
||||
use App\Services\Stripe\StripeClient;
|
||||
use App\Support\OperatingMode;
|
||||
use App\Support\Settings;
|
||||
use App\Support\StripeCatalogueMode;
|
||||
use Illuminate\Contracts\Console\Kernel;
|
||||
|
||||
/**
|
||||
* Der Abgleich sagt, in wessen Konto er gearbeitet hat — und weigert sich, in
|
||||
* einen Katalog hineinzuarbeiten, der einem anderen gehört.
|
||||
*
|
||||
* Eine Stripe-Price-ID gehört dem Konto, das sie ausgestellt hat. Der Befehl
|
||||
* überspringt jede Zeile, die schon eine ID trägt (PlanPrices::inStep()), also
|
||||
* repariert ein zweiter Lauf nach dem Umschalten gar nichts — er meldete
|
||||
* „already in step" und ließe eine Zeile zurück, die behauptet, der Katalog
|
||||
* gehöre jetzt zum Livekonto. Genau diese Lüge macht die Weigerung unnötig.
|
||||
*/
|
||||
beforeEach(function () {
|
||||
$this->stripe = new FakeStripeClient;
|
||||
app()->instance(StripeClient::class, $this->stripe);
|
||||
});
|
||||
|
||||
it('records the mode its objects were created in', function () {
|
||||
OperatingMode::set(OperatingMode::Test);
|
||||
|
||||
$this->artisan('stripe:sync-catalogue')->assertSuccessful();
|
||||
|
||||
expect(StripeCatalogueMode::recorded())->toBe(OperatingMode::Test)
|
||||
->and(StripeCatalogueMode::hasStoredObjects())->toBeTrue();
|
||||
});
|
||||
|
||||
it('records nothing on a dry run, which creates nothing', function () {
|
||||
OperatingMode::set(OperatingMode::Test);
|
||||
|
||||
$this->artisan('stripe:sync-catalogue', ['--dry-run' => true])->assertSuccessful();
|
||||
|
||||
expect(StripeCatalogueMode::recorded())->toBeNull();
|
||||
});
|
||||
|
||||
it('refuses to work into a catalogue that belongs to the other account', function () {
|
||||
OperatingMode::set(OperatingMode::Test);
|
||||
$this->artisan('stripe:sync-catalogue')->assertSuccessful();
|
||||
|
||||
OperatingMode::set(OperatingMode::Live);
|
||||
|
||||
$this->artisan('stripe:sync-catalogue')->assertFailed();
|
||||
|
||||
// Und die Zeile lügt danach nicht: der Katalog gehört weiterhin dem
|
||||
// Testkonto, weil dieser Lauf nichts angelegt hat.
|
||||
expect(StripeCatalogueMode::recorded())->toBe(OperatingMode::Test);
|
||||
});
|
||||
|
||||
/**
|
||||
* „Herkunft nie festgehalten" ist NICHT „fremdes Konto".
|
||||
*
|
||||
* Der Zustand entsteht ohne jeden Fehler: `CheckoutController` → `PlanPrices::
|
||||
* ensure()` und `BookAddon` → `SyncStripeAddonItems` → `AddonPrices::ensure()`
|
||||
* legen fehlende Preise selbst an, und keiner der beiden ruft `record()`. Er
|
||||
* entsteht auch nach einem Lauf, der mittendrin abgebrochen ist.
|
||||
*
|
||||
* Die Objekte stammen dann aus genau dem geltenden Konto. Diesen Zustand in die
|
||||
* Konto-Weigerung zu schicken, hieße einem Betreiber das Leeren eines Katalogs
|
||||
* aufzutragen, an dem laufende Verträge abgerechnet werden — für ein
|
||||
* Wiederaufsetzen, für das die Idempotenzschlüssel dieses Befehls ausdrücklich
|
||||
* gebaut sind.
|
||||
*/
|
||||
it('takes up a catalogue whose origin was never written down', function () {
|
||||
OperatingMode::set(OperatingMode::Test);
|
||||
$this->artisan('stripe:sync-catalogue')->assertSuccessful();
|
||||
|
||||
Settings::forget(StripeCatalogueMode::SETTING);
|
||||
|
||||
$this->artisan('stripe:sync-catalogue')->assertSuccessful();
|
||||
|
||||
expect(StripeCatalogueMode::recorded())->toBe(OperatingMode::Test);
|
||||
});
|
||||
|
||||
it('leaves no half-built catalogue that contradicts itself when a run dies', function () {
|
||||
OperatingMode::set(OperatingMode::Test);
|
||||
|
||||
// Stripe geht mitten im Lauf weg, nachdem das erste Produkt schon angelegt
|
||||
// und seine ID gespeichert ist. `createProduct()` und `ensure()` werfen
|
||||
// ungefangen — stünde record() am ENDE von handle(), bliebe genau der
|
||||
// Zustand zurück, den der Test darüber beschreibt: IDs da, Herkunft leer.
|
||||
app()->instance(StripeClient::class, new class extends FakeStripeClient
|
||||
{
|
||||
public function createPrice(
|
||||
string $productId,
|
||||
int $amountCents,
|
||||
string $currency,
|
||||
string $interval,
|
||||
array $metadata = [],
|
||||
?string $idempotencyKey = null,
|
||||
): string {
|
||||
throw new RuntimeException('Stripe went away mid-run');
|
||||
}
|
||||
});
|
||||
|
||||
try {
|
||||
app(Kernel::class)->call('stripe:sync-catalogue');
|
||||
} catch (RuntimeException) {
|
||||
// Erwartet — der Abbruch IST der Testfall.
|
||||
}
|
||||
|
||||
expect(StripeCatalogueMode::hasStoredObjects())->toBeTrue()
|
||||
->and(StripeCatalogueMode::recorded())->toBe(OperatingMode::Test);
|
||||
});
|
||||
|
||||
it('syncs into the new account once the stale ids are cleared', function () {
|
||||
OperatingMode::set(OperatingMode::Test);
|
||||
$this->artisan('stripe:sync-catalogue')->assertSuccessful();
|
||||
|
||||
OperatingMode::set(OperatingMode::Live);
|
||||
|
||||
// Was der `breaks`-Satz und die Fehlermeldung dem Betreiber auftragen —
|
||||
// Zeiger UND Register, weil PlanPrices::inStep() für den Netto-Preis eines
|
||||
// Reverse-Charge-Kunden allein das Register fragt.
|
||||
DB::table('plan_families')->update(['stripe_product_id' => null]);
|
||||
DB::table('plan_prices')->update(['stripe_price_id' => null]);
|
||||
DB::table('stripe_plan_prices')->delete();
|
||||
DB::table('stripe_addon_prices')->delete();
|
||||
|
||||
$this->artisan('stripe:sync-catalogue')->assertSuccessful();
|
||||
|
||||
expect(StripeCatalogueMode::recorded())->toBe(OperatingMode::Live)
|
||||
->and(PlanPrice::query()->whereNotNull('stripe_price_id')->exists())->toBeTrue();
|
||||
});
|
||||
|
|
@ -18,9 +18,12 @@ use Illuminate\Support\Facades\Http;
|
|||
* the thing standing between a customer and being charged twice.
|
||||
*/
|
||||
beforeEach(function () {
|
||||
// The environment fallback is enough — HttpStripeClient reads the vault,
|
||||
// and the vault falls back to config until a secret has been stored.
|
||||
config()->set('services.stripe.secret', 'sk_test_plan_task_one');
|
||||
// A stored vault row, not config(): `stripe.secret` is a STRICT vault entry
|
||||
// as of the operating-mode work, so it never falls back to the environment
|
||||
// in either direction — otherwise the .env would be the back door through
|
||||
// which a live key reaches a test-mode checkout. Without a real row
|
||||
// HttpStripeClient::secret() throws StripeNotConfigured, which is the point.
|
||||
withStripeSecret();
|
||||
});
|
||||
|
||||
it('sends a different key once the metadata changes', function () {
|
||||
|
|
|
|||
|
|
@ -14,6 +14,10 @@ function billingSetup(string $plan = 'start'): array
|
|||
$order = Order::factory()->create(['customer_id' => $customer->id, 'plan' => $plan]);
|
||||
Instance::factory()->create(['customer_id' => $customer->id, 'order_id' => $order->id, 'plan' => $plan, 'status' => 'active']);
|
||||
|
||||
// Several of these tests call Billing::purchase(), which since Task 5
|
||||
// refuses outright without a Stripe key for the active mode.
|
||||
withStripeSecret();
|
||||
|
||||
return compact('user', 'customer');
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -3,14 +3,21 @@
|
|||
use App\Livewire\Billing;
|
||||
use App\Livewire\ConfirmRemoveOrder;
|
||||
use App\Models\Customer;
|
||||
use App\Models\Instance;
|
||||
use App\Models\Order;
|
||||
use App\Models\User;
|
||||
use Illuminate\Support\Carbon;
|
||||
use Illuminate\Support\Number;
|
||||
|
||||
function cartCustomer(): array
|
||||
{
|
||||
$customer = Customer::factory()->create();
|
||||
$user = User::factory()->create(['email' => $customer->email]);
|
||||
|
||||
// Some of these tests call Billing::purchase(), which since Task 5
|
||||
// refuses outright without a Stripe key for the active mode.
|
||||
withStripeSecret();
|
||||
|
||||
return [$customer, $user];
|
||||
}
|
||||
|
||||
|
|
@ -73,7 +80,7 @@ it('refuses to remove an order that is already paid', function () {
|
|||
|
||||
it('keeps at most one plan change in the cart', function () {
|
||||
[$customer, $user] = cartCustomer();
|
||||
App\Models\Instance::factory()->for($customer)->create(['plan' => 'start', 'status' => 'active']);
|
||||
Instance::factory()->for($customer)->create(['plan' => 'start', 'status' => 'active']);
|
||||
|
||||
$component = Livewire\Livewire::actingAs($user)->test(Billing::class);
|
||||
$component->call('purchase', 'upgrade', 'business');
|
||||
|
|
@ -88,7 +95,7 @@ it('keeps at most one plan change in the cart', function () {
|
|||
|
||||
it('still lets add-ons stack', function () {
|
||||
[$customer, $user] = cartCustomer();
|
||||
App\Models\Instance::factory()->for($customer)->create(['plan' => 'team', 'status' => 'active']);
|
||||
Instance::factory()->for($customer)->create(['plan' => 'team', 'status' => 'active']);
|
||||
|
||||
$component = Livewire\Livewire::actingAs($user)->test(Billing::class);
|
||||
$component->call('purchase', 'storage');
|
||||
|
|
@ -127,7 +134,7 @@ it('separates what recurs from a one-off top-up', function () {
|
|||
// Formatted through the same helper the view uses, or the non-breaking
|
||||
// space in the currency makes this a test about whitespace.
|
||||
$expected = __('billing.cart.recurring_note', [
|
||||
'amount' => Illuminate\Support\Number::currency(12.00, in: 'EUR', locale: 'de'),
|
||||
'amount' => Number::currency(12.00, in: 'EUR', locale: 'de'),
|
||||
]);
|
||||
|
||||
Livewire\Livewire::actingAs($user)->test(Billing::class)->assertSee($expected);
|
||||
|
|
@ -143,7 +150,7 @@ it('keeps the recurring figure consistent with the total to the cent', function
|
|||
Order::factory()->for($customer)->create(['type' => 'storage', 'amount_cents' => 3, 'status' => 'pending']);
|
||||
Order::factory()->for($customer)->create(['type' => 'storage', 'amount_cents' => 3, 'status' => 'pending']);
|
||||
|
||||
$expected = Illuminate\Support\Number::currency(0.08, in: 'EUR', locale: 'de');
|
||||
$expected = Number::currency(0.08, in: 'EUR', locale: 'de');
|
||||
|
||||
Livewire\Livewire::actingAs($user)->test(Billing::class)
|
||||
->assertSee($expected) // total gross
|
||||
|
|
@ -188,7 +195,7 @@ it('accepts a verified value stored in display form', function () {
|
|||
config()->set('provisioning.tax.rate_percent', 20);
|
||||
config()->set('provisioning.tax.seller_country', 'AT');
|
||||
|
||||
[$customer, ] = cartCustomer();
|
||||
[$customer] = cartCustomer();
|
||||
Order::factory()->for($customer)->create(['type' => 'storage', 'amount_cents' => 1000, 'status' => 'pending']);
|
||||
|
||||
// A verifier may hand back the number the way it prints it; spaces are
|
||||
|
|
@ -200,7 +207,7 @@ it('accepts a verified value stored in display form', function () {
|
|||
]);
|
||||
|
||||
expect($customer->fresh()->hasVerifiedVatId())->toBeTrue()
|
||||
->and($customer->fresh()->vat_id_verified_at)->toBeInstanceOf(Illuminate\Support\Carbon::class)
|
||||
->and($customer->fresh()->vat_id_verified_at)->toBeInstanceOf(Carbon::class)
|
||||
->and(Order::query()->first()->fresh()->grossCents())->toBe(1000);
|
||||
});
|
||||
|
||||
|
|
@ -208,7 +215,7 @@ it('will not let a typed-in VAT ID zero the tax', function () {
|
|||
config()->set('provisioning.tax.rate_percent', 20);
|
||||
config()->set('provisioning.tax.seller_country', 'AT');
|
||||
|
||||
[$customer, ] = cartCustomer();
|
||||
[$customer] = cartCustomer();
|
||||
Order::factory()->for($customer)->create(['type' => 'storage', 'amount_cents' => 1000, 'status' => 'pending']);
|
||||
|
||||
// Plausible-looking but unverified: still domestic VAT.
|
||||
|
|
|
|||
|
|
@ -0,0 +1,102 @@
|
|||
<?php
|
||||
|
||||
use App\Exceptions\StripeNotConfigured;
|
||||
use App\Livewire\Billing;
|
||||
use App\Models\Customer;
|
||||
use App\Models\Operator;
|
||||
use App\Models\Order;
|
||||
use App\Models\User;
|
||||
use App\Services\Secrets\SecretVault;
|
||||
use App\Services\Stripe\HttpStripeClient;
|
||||
use App\Support\OperatingMode;
|
||||
use Illuminate\Support\Facades\Http;
|
||||
use Livewire\Livewire;
|
||||
|
||||
/**
|
||||
* Fehlt der Schlüssel des aktiven Modus, entsteht kein Auftrag — und der Kunde
|
||||
* liest einen Satz statt einer 500er-Seite.
|
||||
*
|
||||
* Die Ausnahme ist eine KONKRETE Klasse. `toThrow(Throwable::class)` würde hier
|
||||
* jeden beliebigen Fehler durchgehen lassen, auch einen Tippfehler im Test.
|
||||
*/
|
||||
it('refuses to reach stripe without a key for the active mode', function () {
|
||||
OperatingMode::set(OperatingMode::Test);
|
||||
|
||||
expect(fn () => app(HttpStripeClient::class)->createPrice('prod_x', 100, 'EUR', 'month'))
|
||||
->toThrow(StripeNotConfigured::class);
|
||||
});
|
||||
|
||||
/**
|
||||
* Die andere Hälfte derselben Entscheidung, und der Riegel vor dem Merge:
|
||||
* `secret()` WIRFT, `isConfigured()` WIRFT NICHT.
|
||||
*
|
||||
* Sechs Aufrufer stellen genau diese Frage, um KEINE Ausnahme zu bekommen —
|
||||
* CheckoutController:87 (der Kunde liest sonst eine 500 statt eines Satzes),
|
||||
* SyncStripeCatalogue, RepriceStripeSubscriptions, SyncStripeAddonItems,
|
||||
* MoveStripeSubscriptionPrice. `main` trägt an dieser Stelle noch
|
||||
* `return filled($this->secret());`, und der Trockenlauf des Merges führt die
|
||||
* Datei OHNE Konfliktmarkierung zusammen: niemand wird gezwungen hinzusehen.
|
||||
* Mit `main`s Rumpf und dem werfenden `secret()` dieses Zweigs wird aus der
|
||||
* Frage eine Ausnahme — und ohne diesen Test bleiben dabei 1795 Tests grün
|
||||
* (nachgewiesen im Schluss-Review durch genau diese Mutation).
|
||||
*/
|
||||
it('answers the configured question instead of throwing it', function () {
|
||||
Http::fake();
|
||||
OperatingMode::set(OperatingMode::Test);
|
||||
|
||||
// Nicht toBeFalse() allein: eine geworfene Ausnahme wäre kein „false",
|
||||
// sondern gar keine Antwort — und genau das ist der Rückschritt.
|
||||
expect(app(HttpStripeClient::class)->isConfigured())->toBeFalse();
|
||||
|
||||
// Die Frage darf auch nichts kosten: sie wird auf jedem Kassenaufruf
|
||||
// gestellt und liest nur den Tresor.
|
||||
Http::assertNothingSent();
|
||||
});
|
||||
|
||||
it('says configured once the slot of the active mode is filled', function () {
|
||||
Http::fake();
|
||||
OperatingMode::set(OperatingMode::Test);
|
||||
app(SecretVault::class)->put('stripe.secret', 'sk_test_x', Operator::factory()->create(), OperatingMode::Test);
|
||||
|
||||
expect(app(HttpStripeClient::class)->isConfigured())->toBeTrue();
|
||||
|
||||
Http::assertNothingSent();
|
||||
});
|
||||
|
||||
it('does not create an order when the key is missing', function () {
|
||||
OperatingMode::set(OperatingMode::Test);
|
||||
$customer = Customer::factory()->create();
|
||||
// A portal user is matched to its customer by email, not by a foreign
|
||||
// key — see the existing Billing feature tests (e.g. StorageAllowanceTest).
|
||||
$user = User::factory()->create(['email' => $customer->email]);
|
||||
|
||||
// 'team' is a real, sellable plan (seeded by the plan-catalogue migration)
|
||||
// that ranks above the customer's implicit 'start' plan — a genuine
|
||||
// upgrade the purchase() guards would otherwise accept. A made-up key
|
||||
// would be refused by the existing plan-validity check regardless of
|
||||
// whether the Stripe guard works at all, and would prove nothing.
|
||||
//
|
||||
// assertHasErrors(), not assertHasNoErrors(): the guard reports through
|
||||
// addError('purchase', …) — the same page-blocking pattern order.blade.php
|
||||
// already uses for "cannot proceed" — and Livewire's test harness folds
|
||||
// any non-empty error bag into the call's result. Asserting no errors
|
||||
// here would contradict the very behaviour this test exists to prove.
|
||||
Livewire::actingAs($user)
|
||||
->test(Billing::class)
|
||||
->call('purchase', 'upgrade', 'team')
|
||||
->assertHasErrors('purchase');
|
||||
|
||||
// Der Punkt: nach der Zahlung wäre es zu spät. Es darf gar nichts entstehen.
|
||||
expect(Order::count())->toBe(0);
|
||||
});
|
||||
|
||||
it('says why instead of failing silently', function () {
|
||||
OperatingMode::set(OperatingMode::Test);
|
||||
$customer = Customer::factory()->create();
|
||||
$user = User::factory()->create(['email' => $customer->email]);
|
||||
|
||||
Livewire::actingAs($user)
|
||||
->test(Billing::class)
|
||||
->call('purchase', 'upgrade', 'team')
|
||||
->assertSee(__('billing.stripe_not_configured'));
|
||||
});
|
||||
|
|
@ -19,7 +19,6 @@ use Livewire\Livewire;
|
|||
* disabled button that does not name the obstacle is the thing people ring
|
||||
* about.
|
||||
*/
|
||||
|
||||
function downgradeCustomer(int $seats = 1, ?int $usedBytes = null): array
|
||||
{
|
||||
$user = User::factory()->create();
|
||||
|
|
@ -44,6 +43,12 @@ function downgradeCustomer(int $seats = 1, ?int $usedBytes = null): array
|
|||
]);
|
||||
}
|
||||
|
||||
// One test below calls Billing::purchase(), which since Task 5 refuses
|
||||
// outright without a Stripe key for the active mode. Without this, that
|
||||
// test would see the same "no order" result for the wrong reason — the
|
||||
// Stripe guard, not the seat block it names — and prove nothing.
|
||||
withStripeSecret();
|
||||
|
||||
return [$user, $customer, $instance];
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
});
|
||||
|
|
@ -0,0 +1,34 @@
|
|||
<?php
|
||||
|
||||
use App\Support\OperatingMode;
|
||||
use App\Support\Settings;
|
||||
|
||||
/**
|
||||
* Der Modus entscheidet, welche Zugangsdaten gelten. Er wird an genau einer
|
||||
* Stelle gelesen, damit nirgends sonst ein String verglichen wird — ein
|
||||
* Tippfehler in einem Vergleich wäre sonst ein stiller Wechsel auf Live.
|
||||
*/
|
||||
it('is live when nothing has been stored', function () {
|
||||
expect(OperatingMode::current())->toBe(OperatingMode::Live);
|
||||
});
|
||||
|
||||
it('reads the stored mode', function () {
|
||||
Settings::set('platform.mode', 'test');
|
||||
|
||||
expect(OperatingMode::current())->toBe(OperatingMode::Test);
|
||||
});
|
||||
|
||||
it('falls back to live when the stored value is not a mode', function () {
|
||||
// Eine Zeile, die von Hand oder von einer alten Fassung geschrieben wurde.
|
||||
// Live ist die sichere Richtung: eine echte Zahlung wird korrekt behandelt,
|
||||
// ein Testereignis scheitert laut.
|
||||
Settings::set('platform.mode', 'staging');
|
||||
|
||||
expect(OperatingMode::current())->toBe(OperatingMode::Live);
|
||||
});
|
||||
|
||||
it('writes the mode and takes effect immediately', function () {
|
||||
OperatingMode::set(OperatingMode::Test);
|
||||
|
||||
expect(OperatingMode::current())->toBe(OperatingMode::Test);
|
||||
});
|
||||
|
|
@ -0,0 +1,388 @@
|
|||
<?php
|
||||
|
||||
use App\Models\Host;
|
||||
use App\Models\PlanFamily;
|
||||
use App\Models\PlanVersion;
|
||||
use App\Services\Dns\DnsTokenCheck;
|
||||
use App\Services\Proxmox\FakeProxmoxClient;
|
||||
use App\Services\Proxmox\ProxmoxClient;
|
||||
use App\Services\Proxmox\VmTemplateCheck;
|
||||
use App\Services\Vpn\WireguardEndpointCheck;
|
||||
use App\Support\Settings;
|
||||
use Illuminate\Http\Client\ConnectionException;
|
||||
use Illuminate\Support\Facades\Http;
|
||||
use Illuminate\Support\Str;
|
||||
|
||||
/**
|
||||
* Ein UDP-Port lässt sich von außen nicht sauber anklopfen — es gibt keinen
|
||||
* Handshake zu beobachten. Was ENTSCHEIDBAR ist: ob dort überhaupt eine
|
||||
* Adresse steht, die ein Host im Internet erreichen kann.
|
||||
*
|
||||
* Am 2026-07-30 stand `10.10.90.185:51820` in der Konfiguration. Das sieht
|
||||
* gesetzt aus und ist für jeden Host außerhalb dieses Netzes unerreichbar.
|
||||
*
|
||||
* Jeder Test ruft trotzdem Http::fake() — ohne Ausnahme, wie jeder Test dieser
|
||||
* drei Prüfungen. WireguardEndpointCheck macht heute keinen Netzverkehr, aber
|
||||
* die Zusicherung gilt der Prüfung, nicht der heutigen Implementierung.
|
||||
*/
|
||||
it('rejects a private address', function () {
|
||||
Http::fake();
|
||||
expect((new WireguardEndpointCheck)->run('10.10.90.185:51820')['ok'])->toBeFalse();
|
||||
});
|
||||
|
||||
it('rejects a documentation address', function () {
|
||||
// RFC 5737 — Seed- und Beispieldaten landen erfahrungsgemäß in echten
|
||||
// Installationen.
|
||||
Http::fake();
|
||||
expect((new WireguardEndpointCheck)->run('203.0.113.11:51820')['ok'])->toBeFalse();
|
||||
});
|
||||
|
||||
it('rejects loopback', function () {
|
||||
Http::fake();
|
||||
expect((new WireguardEndpointCheck)->run('127.0.0.1:51820')['ok'])->toBeFalse();
|
||||
});
|
||||
|
||||
it('accepts a public address', function () {
|
||||
Http::fake();
|
||||
expect((new WireguardEndpointCheck)->run('198.51.45.9:51820')['ok'])->toBeTrue();
|
||||
});
|
||||
|
||||
it('accepts a hostname, which it cannot judge', function () {
|
||||
// Ein Name kann öffentlich auflösen; hier zu raten wäre schlechter als
|
||||
// durchzulassen — die Prüfung sagt, was sie weiß, nicht was sie vermutet.
|
||||
Http::fake();
|
||||
expect((new WireguardEndpointCheck)->run('vpn.clupilot.cloud:51820')['ok'])->toBeTrue();
|
||||
});
|
||||
|
||||
it('rejects an endpoint without a port', function () {
|
||||
Http::fake();
|
||||
expect((new WireguardEndpointCheck)->run('198.51.45.9')['ok'])->toBeFalse();
|
||||
});
|
||||
|
||||
it('names the offending address, so the page can show it', function () {
|
||||
Http::fake();
|
||||
expect((new WireguardEndpointCheck)->run('10.10.90.185:51820')['reason'])->toBe('not_public');
|
||||
});
|
||||
|
||||
it('rejects a blank endpoint', function () {
|
||||
Http::fake();
|
||||
expect((new WireguardEndpointCheck)->run('')['reason'])->toBe('missing');
|
||||
});
|
||||
|
||||
/**
|
||||
* Fix-Runde (Befund 1): FILTER_FLAG_NO_PRIV_RANGE|FILTER_FLAG_NO_RES_RANGE
|
||||
* does not reject RFC 6598 Carrier-Grade NAT — verified live against
|
||||
* `100.64.0.5:51820` before this fix, which came back `ok: true`. A host
|
||||
* behind CGNAT is exactly as unreachable from outside as one behind RFC 1918,
|
||||
* it is just an ISP handing the address out instead of an operator.
|
||||
*/
|
||||
it('rejects a carrier-grade NAT address', function () {
|
||||
Http::fake();
|
||||
expect((new WireguardEndpointCheck)->run('100.64.0.5:51820')['ok'])->toBeFalse();
|
||||
});
|
||||
|
||||
/**
|
||||
* Fix-Runde (Befund 1, vollständiger Durchgang): kein Mehrfach-Empfänger ist
|
||||
* je der eigene Endpunkt eines einzelnen WireGuard-Peers.
|
||||
*/
|
||||
it('rejects a multicast address', function () {
|
||||
Http::fake();
|
||||
expect((new WireguardEndpointCheck)->run('224.1.1.1:51820')['ok'])->toBeFalse();
|
||||
});
|
||||
|
||||
// --- DnsTokenCheck --------------------------------------------------------
|
||||
//
|
||||
// SICHERHEITSAUFLAGE: jeder dieser Tests benutzt Http::fake() und darf unter
|
||||
// keinen Umständen die echte Hetzner-API oder die echte Zone berühren. Ein
|
||||
// Test, der clupilot.cloud tatsächlich anfasst, ist ein Fehlschlag der
|
||||
// Aufgabe, auch wenn er grün ist.
|
||||
|
||||
beforeEach(function () {
|
||||
// Eine feste, erfundene Zone statt des .env-Werts dieser Installation —
|
||||
// ein Test, der den echten Zonennamen des Entwicklerrechners nachrechnet,
|
||||
// beweist nichts (siehe phpunit.xml zu CLUPILOT_DNS_ZONE).
|
||||
Settings::set('provisioning.dns_zone', 'probe.example');
|
||||
});
|
||||
|
||||
it('confirms write access by writing a probe record and removing it again', function () {
|
||||
// Genau der Punkt der ganzen Prüfung: Zone LISTEN allein beweist nichts,
|
||||
// weil ein Leserecht-Token das ebenfalls anstandslos tut. Erst Schreiben
|
||||
// und Löschen beweisen ein Schreibrecht-Token.
|
||||
Http::fake([
|
||||
'dns.hetzner.com/api/v1/zones' => Http::response(['zones' => [['id' => 'zone-1', 'name' => 'probe.example']]]),
|
||||
'dns.hetzner.com/api/v1/records' => Http::response(['record' => ['id' => 'rec-probe-1']]),
|
||||
'dns.hetzner.com/api/v1/records/*' => Http::response([], 200),
|
||||
]);
|
||||
|
||||
$result = (new DnsTokenCheck)->run('rw-token');
|
||||
|
||||
expect($result['ok'])->toBeTrue()
|
||||
->and($result['reason'])->toBe('writable')
|
||||
->and($result['probe_removed'])->toBeTrue();
|
||||
|
||||
Http::assertSent(fn ($request) => $request->method() === 'POST'
|
||||
&& str_contains((string) $request->url(), '/records')
|
||||
&& $request->hasHeader('Auth-API-Token', 'rw-token'));
|
||||
Http::assertSent(fn ($request) => $request->method() === 'DELETE'
|
||||
&& str_contains((string) $request->url(), '/records/rec-probe-1'));
|
||||
});
|
||||
|
||||
it('reports the zone as not found when the token cannot see it', function () {
|
||||
// Ein Token für eine andere Zone (oder gar keine) listet erfolgreich,
|
||||
// findet die konfigurierte Zone darin aber nicht.
|
||||
Http::fake([
|
||||
'dns.hetzner.com/api/v1/zones' => Http::response(['zones' => [['id' => 'zone-9', 'name' => 'someone-elses-zone.example']]]),
|
||||
]);
|
||||
|
||||
$result = (new DnsTokenCheck)->run('some-token');
|
||||
|
||||
expect($result['ok'])->toBeFalse()
|
||||
->and($result['reason'])->toBe('zone_not_found')
|
||||
->and($result['zone'])->toBe('probe.example');
|
||||
|
||||
// Kein Schreibversuch, wenn die Zone gar nicht gefunden wurde.
|
||||
Http::assertNotSent(fn ($request) => $request->method() === 'POST');
|
||||
});
|
||||
|
||||
it('tells a read-only token apart from one that can write', function () {
|
||||
// Genau der Leserecht-Token aus dem Handoff: er listet die Zone
|
||||
// anstandslos (200) und sieht in der Konsole aus wie ein funktionierender
|
||||
// — bis zum Schreibversuch, den Hetzner mit 403 quittiert.
|
||||
Http::fake([
|
||||
'dns.hetzner.com/api/v1/zones' => Http::response(['zones' => [['id' => 'zone-1', 'name' => 'probe.example']]]),
|
||||
'dns.hetzner.com/api/v1/records' => Http::response(['error' => 'forbidden'], 403),
|
||||
]);
|
||||
|
||||
$result = (new DnsTokenCheck)->run('read-only-token');
|
||||
|
||||
expect($result['ok'])->toBeFalse()
|
||||
->and($result['reason'])->toBe('read_only')
|
||||
->and($result['status'])->toBe(403);
|
||||
|
||||
// Kein Löschversuch für einen Eintrag, der nie geschrieben wurde.
|
||||
Http::assertNotSent(fn ($request) => $request->method() === 'DELETE');
|
||||
});
|
||||
|
||||
/**
|
||||
* Fix-Runde (Befund 2): Das GET auf /zones ist gegen Throwable abgesichert
|
||||
* (Reason "unreachable"), der Schreibversuch danach war es nicht — ein
|
||||
* Netzwerkfehler genau dort hätte die Ausnahme aus run() herausgeworfen, auf
|
||||
* einer Seite, deren einziger Zweck ist, ruhig zu berichten. "Netz kaputt"
|
||||
* bleibt dabei von "Token darf nicht schreiben" (read_only, 403 s.o.) und von
|
||||
* "Zone falsch benannt" getrennt — dieselbe Reason wie beim GET, weil es
|
||||
* dieselbe Fehlerklasse ist, nur an einer anderen Stelle der Kette.
|
||||
*/
|
||||
it('reports unreachable when the write attempt itself cannot reach the network', function () {
|
||||
Http::fake([
|
||||
'dns.hetzner.com/api/v1/zones' => Http::response(['zones' => [['id' => 'zone-1', 'name' => 'probe.example']]]),
|
||||
'dns.hetzner.com/api/v1/records' => function () {
|
||||
throw new ConnectionException('Could not resolve host');
|
||||
},
|
||||
]);
|
||||
|
||||
$result = (new DnsTokenCheck)->run('rw-token');
|
||||
|
||||
expect($result['ok'])->toBeFalse()
|
||||
->and($result['reason'])->toBe('unreachable');
|
||||
});
|
||||
|
||||
/**
|
||||
* Fix-Runde (Befund 2, Sonderfall Aufräumen): der Schreibversuch selbst hat
|
||||
* bereits bewiesen, dass der Token schreiben darf — das ist der Zweck der
|
||||
* ganzen Prüfung, und ein kaputtes Netz beim Löschen ändert daran nichts.
|
||||
* `probe_removed` muss trotzdem false sein, und der liegengebliebene Eintrag
|
||||
* muss benennbar bleiben, sonst weiß niemand, dass er in der Zone liegt.
|
||||
*/
|
||||
it('still proves write access when the cleanup delete cannot reach the network', function () {
|
||||
Http::fake([
|
||||
'dns.hetzner.com/api/v1/zones' => Http::response(['zones' => [['id' => 'zone-1', 'name' => 'probe.example']]]),
|
||||
'dns.hetzner.com/api/v1/records' => Http::response(['record' => ['id' => 'rec-orphaned']]),
|
||||
'dns.hetzner.com/api/v1/records/*' => function () {
|
||||
throw new ConnectionException('Could not resolve host');
|
||||
},
|
||||
]);
|
||||
|
||||
$result = (new DnsTokenCheck)->run('rw-token');
|
||||
|
||||
expect($result['ok'])->toBeTrue()
|
||||
->and($result['reason'])->toBe('writable')
|
||||
->and($result['probe_removed'])->toBeFalse()
|
||||
->and($result['leftover_record_id'])->toBe('rec-orphaned');
|
||||
});
|
||||
|
||||
it('does not attempt anything without a token or a zone', function () {
|
||||
// Zurückgesetzt statt sich auf den .env-Wert dieser Installation zu
|
||||
// verlassen — genau die Falle, die phpunit.xml zu CLUPILOT_DNS_ZONE
|
||||
// dokumentiert, und der Grund, warum HETZNER_DNS_TOKEN dort jetzt
|
||||
// ebenfalls auf leer gepinnt ist.
|
||||
config()->set('provisioning.dns.token', '');
|
||||
Http::fake();
|
||||
|
||||
expect((new DnsTokenCheck)->run('')['reason'])->toBe('missing');
|
||||
|
||||
Http::assertNothingSent();
|
||||
});
|
||||
|
||||
// --- VmTemplateCheck -------------------------------------------------------
|
||||
|
||||
beforeEach(function () {
|
||||
// 2026_07_26_040000_create_plan_catalogue_tables.php seeds a real baseline
|
||||
// catalogue (start/team/business/enterprise, all template_vmid 9000) as
|
||||
// part of the migration itself — not a Seeder, so it is present in every
|
||||
// test database and does not roll away with RefreshDatabase. Left alone,
|
||||
// it is unconditionally "required" by VmTemplateCheck alongside whatever
|
||||
// a test creates. Closed here instead of deleted: a published version
|
||||
// cannot be deleted (see PlanVersion::booted()), but its availability
|
||||
// WINDOW is deliberately not frozen — closing it changes nothing about
|
||||
// what the baseline promises, it only takes it out of "currently sold"
|
||||
// so these tests can speak about their own versions in isolation.
|
||||
PlanVersion::query()->whereNotNull('published_at')->update(['available_until' => now()->subDay()]);
|
||||
});
|
||||
|
||||
function makePublishedVersion(int $templateVmid, ?string $familyKey = null): void
|
||||
{
|
||||
$family = PlanFamily::query()->create([
|
||||
'key' => $familyKey ?? 'active-check-'.Str::random(8),
|
||||
'name' => 'Active Check Plan',
|
||||
'tier' => 9,
|
||||
]);
|
||||
$version = $family->versions()->create([
|
||||
'version' => 1, 'quota_gb' => 10, 'traffic_gb' => 100, 'seats' => 1, 'ram_mb' => 1024,
|
||||
'cores' => 1, 'disk_gb' => 20, 'performance' => 'standard', 'features' => [],
|
||||
'available_from' => now(),
|
||||
]);
|
||||
$version->update(['published_at' => now(), 'template_vmid' => $templateVmid]);
|
||||
}
|
||||
|
||||
it('is satisfied once the template actually exists on an active host', function () {
|
||||
// FakeProxmoxClient talks to nothing real; Http::fake() rides along anyway
|
||||
// — every test of these three checks does, without exception.
|
||||
Http::fake();
|
||||
|
||||
$pve = new FakeProxmoxClient;
|
||||
$pve->clonedVmids = [4001];
|
||||
app()->instance(ProxmoxClient::class, $pve);
|
||||
|
||||
Host::factory()->create(['status' => 'active', 'api_token_ref' => 'ref-1', 'node' => 'pve']);
|
||||
makePublishedVersion(4001);
|
||||
|
||||
$result = app(VmTemplateCheck::class)->run();
|
||||
|
||||
expect($result['ok'])->toBeTrue()
|
||||
->and($result['reason'])->toBe('present');
|
||||
});
|
||||
|
||||
it('names the missing vmid when the template is not on any active host', function () {
|
||||
Http::fake();
|
||||
|
||||
$pve = new FakeProxmoxClient; // clonedVmids/runningVmids stay empty: nothing exists anywhere
|
||||
app()->instance(ProxmoxClient::class, $pve);
|
||||
|
||||
Host::factory()->create(['status' => 'active', 'api_token_ref' => 'ref-1', 'node' => 'pve']);
|
||||
makePublishedVersion(4002);
|
||||
|
||||
$result = app(VmTemplateCheck::class)->run();
|
||||
|
||||
expect($result['ok'])->toBeFalse()
|
||||
->and($result['reason'])->toBe('template_missing')
|
||||
->and($result['vmids'])->toContain(4002);
|
||||
});
|
||||
|
||||
/**
|
||||
* Fix-Runde (Befund 3): ein Host, der gerade nicht antwortet, ist kein
|
||||
* Beweis dafür, dass die Vorlage fehlt — sie könnte genau auf diesem Host
|
||||
* liegen. Der bestehende FakeProxmoxClient kann das nicht abbilden (jede
|
||||
* Antwort ist entweder true oder false, nie eine Ausnahme), deshalb eine
|
||||
* anonyme Unterklasse nach dem Muster aus TrafficTest.php
|
||||
* ($flaky/$blind), die vmExists() gezielt scheitern lässt.
|
||||
*/
|
||||
it('does not confuse a silent host with a confirmed-missing template', function () {
|
||||
Http::fake();
|
||||
|
||||
$silent = new class extends FakeProxmoxClient
|
||||
{
|
||||
public function vmExists(string $node, int $vmid): bool
|
||||
{
|
||||
throw new RuntimeException('proxmox unreachable');
|
||||
}
|
||||
};
|
||||
app()->instance(ProxmoxClient::class, $silent);
|
||||
|
||||
Host::factory()->create(['status' => 'active', 'api_token_ref' => 'ref-1', 'node' => 'pve']);
|
||||
makePublishedVersion(4004);
|
||||
|
||||
$result = app(VmTemplateCheck::class)->run();
|
||||
|
||||
// "build the template" and "your hosts are not answering" are two
|
||||
// different instructions — a reason of 'template_missing' here would
|
||||
// send an operator to build a template that might already exist.
|
||||
expect($result['ok'])->toBeFalse()
|
||||
->and($result['reason'])->toBe('hosts_unreachable')
|
||||
->and($result['reason'])->not->toBe('template_missing')
|
||||
->and($result['vmids'])->toContain(4004);
|
||||
});
|
||||
|
||||
/**
|
||||
* Fix-Runde (Befund 3, Gegenprobe): ein Host, der antwortet und die Vorlage
|
||||
* bestätigt, darf das Ergebnis nicht kippen lassen, nur weil ein ANDERER
|
||||
* Host in derselben Runde schweigt — "gefunden" sticht "still", und die
|
||||
* Schleife bricht bei einem Treffer ab, statt den stillen Host danach noch
|
||||
* als Beweis für irgendetwas zu werten.
|
||||
*
|
||||
* Eine einzige ProxmoxClient-Instanz bedient in VmTemplateCheck ALLE Hosts
|
||||
* (`forHost($host)` wird pro Host auf demselben `$this->pve` aufgerufen), also
|
||||
* unterscheidet dieser Fake anhand dessen, welcher Host zuletzt über
|
||||
* `forHost()` hereinkam — genau das Feld, das `FakeProxmoxClient::forHost()`
|
||||
* ohnehin schon merkt.
|
||||
*/
|
||||
it('still confirms a template that a working host has, despite a silent one', function () {
|
||||
Http::fake();
|
||||
|
||||
$mixed = new class extends FakeProxmoxClient
|
||||
{
|
||||
public function vmExists(string $node, int $vmid): bool
|
||||
{
|
||||
if ($this->host?->node === 'silent-node') {
|
||||
throw new RuntimeException('proxmox unreachable');
|
||||
}
|
||||
|
||||
return parent::vmExists($node, $vmid);
|
||||
}
|
||||
};
|
||||
$mixed->clonedVmids = [4005]; // what the WORKING host actually has
|
||||
app()->instance(ProxmoxClient::class, $mixed);
|
||||
|
||||
Host::factory()->create(['status' => 'active', 'api_token_ref' => 'ref-1', 'node' => 'silent-node']);
|
||||
Host::factory()->create(['status' => 'active', 'api_token_ref' => 'ref-2', 'node' => 'working-node']);
|
||||
makePublishedVersion(4005);
|
||||
|
||||
$result = app(VmTemplateCheck::class)->run();
|
||||
|
||||
expect($result['ok'])->toBeTrue()
|
||||
->and($result['reason'])->toBe('present');
|
||||
});
|
||||
|
||||
it('reports no usable host when nothing is left to ask', function () {
|
||||
Http::fake();
|
||||
app()->instance(ProxmoxClient::class, new FakeProxmoxClient);
|
||||
|
||||
// Kein aktiver Host mit lesbarem Token — derselbe Zustand, den
|
||||
// ProvisioningChecks::usable_host als Sperre meldet.
|
||||
Host::factory()->create(['status' => 'onboarding', 'api_token_ref' => 'ref-1', 'node' => 'pve']);
|
||||
makePublishedVersion(4003);
|
||||
|
||||
$result = app(VmTemplateCheck::class)->run();
|
||||
|
||||
expect($result['ok'])->toBeFalse()
|
||||
->and($result['reason'])->toBe('no_usable_host');
|
||||
});
|
||||
|
||||
it('is satisfied when no plan version is published yet', function () {
|
||||
Http::fake();
|
||||
app()->instance(ProxmoxClient::class, new FakeProxmoxClient);
|
||||
|
||||
$result = app(VmTemplateCheck::class)->run();
|
||||
|
||||
expect($result['ok'])->toBeTrue()
|
||||
->and($result['reason'])->toBe('nothing_published');
|
||||
});
|
||||
|
|
@ -0,0 +1,219 @@
|
|||
<?php
|
||||
|
||||
use App\Models\InvoiceSeries;
|
||||
use App\Models\Operator;
|
||||
use App\Models\PlanFamily;
|
||||
use App\Models\PlanPrice;
|
||||
use App\Services\Secrets\SecretVault;
|
||||
use App\Support\OperatingMode;
|
||||
use App\Support\Readiness;
|
||||
use App\Support\Readiness\Check;
|
||||
use App\Support\Settings;
|
||||
use App\Support\StripeCatalogueMode;
|
||||
|
||||
/**
|
||||
* Die Bereitschaftsprüfung berichtet, was fehlt — und sagt dazu, was
|
||||
* kaputtgeht. Ein Feldname allein ist keine Auskunft: „vat_id fehlt" sagt
|
||||
* nichts, „der Kunde bekommt keinen Beleg" sagt alles.
|
||||
*/
|
||||
function checkFor(string $key): ?Check
|
||||
{
|
||||
return collect(Readiness::all())->firstWhere('key', $key);
|
||||
}
|
||||
|
||||
it('reports the stripe key of the active mode as missing', function () {
|
||||
OperatingMode::set(OperatingMode::Test);
|
||||
|
||||
expect(checkFor('billing.stripe_secret')->satisfied)->toBeFalse();
|
||||
expect(checkFor('billing.stripe_secret')->severity)->toBe(Check::SEVERITY_BLOCKING);
|
||||
});
|
||||
|
||||
it('reports it as satisfied once the key of that mode is stored', function () {
|
||||
OperatingMode::set(OperatingMode::Test);
|
||||
app(SecretVault::class)->put('stripe.secret', 'sk_test_x', Operator::factory()->create(), OperatingMode::Test);
|
||||
|
||||
expect(checkFor('billing.stripe_secret')->satisfied)->toBeTrue();
|
||||
});
|
||||
|
||||
it('does not accept a live key as proof while the test mode is active', function () {
|
||||
// Sonst meldete die Seite Bereitschaft für einen Modus, in dem die Kasse
|
||||
// sperrt — die Seite widerspräche der Sperre aus Task 5.
|
||||
OperatingMode::set(OperatingMode::Test);
|
||||
app(SecretVault::class)->put('stripe.secret', 'sk_live_x', Operator::factory()->create(), OperatingMode::Live);
|
||||
|
||||
expect(checkFor('billing.stripe_secret')->satisfied)->toBeFalse();
|
||||
});
|
||||
|
||||
/**
|
||||
* Ein Schlüssel im falschen Platz ist ein blockierender Befund.
|
||||
*
|
||||
* Gemessen im Schluss-Review: `put('stripe.secret', 'sk_live_REALMONEY')` im
|
||||
* Testbetrieb wird angenommen, `get()` liefert ihn, die Bereitschaftsseite
|
||||
* meldete „erfüllt" — und oben auf derselben Seite steht die Plakette
|
||||
* „Testbetrieb". Das ist derselbe Ausgang, gegen den die strikte Ausnahme im
|
||||
* Tresor gebaut wurde („bucht echtes Geld ab, während die Konsole Testbetrieb
|
||||
* anzeigt"), nur über den eingetippten statt den automatischen Weg.
|
||||
*
|
||||
* Am Präfix ohne Netzverkehr entscheidbar — OperatingMode::ofStripeKey(),
|
||||
* dieselbe Stelle, die auch die Migration und StripeCheck fragen.
|
||||
*/
|
||||
it('refuses a live key sitting in the test slot', function () {
|
||||
OperatingMode::set(OperatingMode::Test);
|
||||
app(SecretVault::class)->put('stripe.secret', 'sk_live_REALMONEY', Operator::factory()->create(), OperatingMode::Test);
|
||||
|
||||
expect(checkFor('billing.stripe_secret')->satisfied)->toBeFalse();
|
||||
expect(checkFor('billing.stripe_secret')->severity)->toBe(Check::SEVERITY_BLOCKING);
|
||||
});
|
||||
|
||||
it('refuses a test key sitting in the live slot', function () {
|
||||
// Die andere Richtung kostet kein Geld, sondern nimmt keins ein: der Kunde
|
||||
// zahlt in Stripes Testkonto, und die Bestellung sieht bezahlt aus.
|
||||
OperatingMode::set(OperatingMode::Live);
|
||||
app(SecretVault::class)->put('stripe.secret', 'sk_test_notreal', Operator::factory()->create(), OperatingMode::Live);
|
||||
|
||||
expect(checkFor('billing.stripe_secret')->satisfied)->toBeFalse();
|
||||
});
|
||||
|
||||
it('accepts the restricted key of the active mode', function () {
|
||||
// rk_ ist ein echter Schlüssel, kein Sonderfall — die Erkennung darf ihn
|
||||
// nicht für einen Widerspruch halten, nur weil er nicht sk_ heißt.
|
||||
OperatingMode::set(OperatingMode::Test);
|
||||
app(SecretVault::class)->put('stripe.secret', 'rk_test_readonly', Operator::factory()->create(), OperatingMode::Test);
|
||||
|
||||
expect(checkFor('billing.stripe_secret')->satisfied)->toBeTrue();
|
||||
});
|
||||
|
||||
it('says what the wrong key does, not that a field is empty', function () {
|
||||
OperatingMode::set(OperatingMode::Test);
|
||||
app(SecretVault::class)->put('stripe.secret', 'sk_live_REALMONEY', Operator::factory()->create(), OperatingMode::Test);
|
||||
|
||||
// Der Satz für „gar kein Schlüssel" wäre hier eine Lüge: einer liegt da,
|
||||
// er ist der falsche, und die Folge ist eine ganz andere.
|
||||
expect(checkFor('billing.stripe_secret')->breaks)
|
||||
->not->toBe(__('readiness.billing.stripe_secret_breaks'));
|
||||
});
|
||||
|
||||
it('reports incomplete company details without repeating the list', function () {
|
||||
Settings::forget('company.name');
|
||||
|
||||
expect(checkFor('billing.company_details')->satisfied)->toBeFalse();
|
||||
});
|
||||
|
||||
it('names what breaks, not just what is missing', function () {
|
||||
expect(checkFor('billing.company_details')->breaks)->not->toBe('');
|
||||
});
|
||||
|
||||
it('says the installation is not ready while something blocking is open', function () {
|
||||
expect(Readiness::isReady())->toBeFalse();
|
||||
expect(Readiness::blocking())->not->toBeEmpty();
|
||||
});
|
||||
|
||||
it('does not report the invoice series ready when the required series is switched off', function () {
|
||||
// IssueInvoice draws its series with `where('kind', ...)->where('active', true)`
|
||||
// (IssueInvoice.php:373 and :444) — a deactivated 'invoice' row is as
|
||||
// invisible to it as a missing one. Counting distinct kinds without the
|
||||
// same `active` filter would report readiness for an installation where
|
||||
// not a single invoice can draw a number.
|
||||
InvoiceSeries::query()->where('kind', 'invoice')->update(['active' => false]);
|
||||
|
||||
expect(checkFor('billing.invoice_series')->satisfied)->toBeFalse();
|
||||
});
|
||||
|
||||
it('does not raise a permanent alarm for a draft version stripe never touches', function () {
|
||||
// stripe:sync-catalogue only mirrors PUBLISHED versions (its own header
|
||||
// comment: "Only PUBLISHED versions are synced. A draft has promised
|
||||
// nothing..."). A draft is an entirely ordinary state — there is a
|
||||
// dedicated modal to create one — so a query that does not also filter on
|
||||
// `published_at` would keep this check permanently unsatisfied on any
|
||||
// installation that has ever drafted a next version: an alarm that is
|
||||
// always on and therefore never read.
|
||||
PlanPrice::query()->update(['stripe_price_id' => 'price_already_synced']);
|
||||
|
||||
$family = PlanFamily::query()->create(['key' => 'draftonly', 'name' => 'Draft Only', 'tier' => 9]);
|
||||
$draft = $family->versions()->create([
|
||||
'version' => 1, 'quota_gb' => 10, 'traffic_gb' => 100, 'seats' => 1, 'ram_mb' => 1024,
|
||||
'cores' => 1, 'disk_gb' => 20, 'performance' => 'standard', 'features' => [],
|
||||
'available_from' => now(),
|
||||
// published_at left null: a draft, exactly what stripe:sync-catalogue skips.
|
||||
]);
|
||||
$draft->prices()->create(['term' => 'monthly', 'amount_cents' => 1000, 'currency' => 'EUR']);
|
||||
|
||||
// Der Abgleich hält seit dieser Runde fest, in welchem Konto er die IDs
|
||||
// angelegt hat; ohne diese Zeile wäre der Katalog oben von unbekannter
|
||||
// Herkunft und die Prüfung meldete zu Recht einen Widerspruch.
|
||||
StripeCatalogueMode::record();
|
||||
|
||||
expect(checkFor('billing.catalogue_synced')->satisfied)->toBeTrue();
|
||||
});
|
||||
|
||||
/**
|
||||
* Der Modus schaltet die Zugangsdaten um — nicht die Objekte, die aus ihnen
|
||||
* entstanden sind.
|
||||
*
|
||||
* Eine Stripe-Price-ID gehört dem Konto, das sie ausgestellt hat. Der geplante
|
||||
* Ablauf dieser Installation war: im Testbetrieb abgleichen, Live-Schlüssel
|
||||
* hinterlegen, umschalten — und diese Prüfung meldete weiter „erfüllt", weil
|
||||
* sie nur nachsah, ob die Spalte gefüllt ist. Die Seite sagte „Bereit für
|
||||
* Livebetrieb", und die erste echte Bestellung bekam von Stripe *No such
|
||||
* price*. Kein Auftrag, und die Kernzusage dieser Seite gebrochen in genau dem
|
||||
* Moment, für den sie existiert.
|
||||
*/
|
||||
it('does not call the catalogue synced when its ids belong to the other account', function () {
|
||||
PlanPrice::query()->update(['stripe_price_id' => 'price_from_the_test_account']);
|
||||
StripeCatalogueMode::record(OperatingMode::Test);
|
||||
|
||||
OperatingMode::set(OperatingMode::Live);
|
||||
|
||||
expect(checkFor('billing.catalogue_synced')->satisfied)->toBeFalse();
|
||||
expect(checkFor('billing.catalogue_synced')->severity)->toBe(Check::SEVERITY_BLOCKING);
|
||||
});
|
||||
|
||||
it('says the sale is refused and a fresh sync is needed, not that a column is empty', function () {
|
||||
PlanPrice::query()->update(['stripe_price_id' => 'price_from_the_test_account']);
|
||||
StripeCatalogueMode::record(OperatingMode::Test);
|
||||
|
||||
OperatingMode::set(OperatingMode::Live);
|
||||
|
||||
// Der Satz für „nie abgeglichen" wäre hier falsch: abgeglichen wurde, nur
|
||||
// in einem anderen Konto — und ein blosser neuer Lauf repariert das NICHT,
|
||||
// weil stripe:sync-catalogue jede Zeile überspringt, die schon eine ID
|
||||
// trägt. Das muss dastehen, sonst schickt die Seite den Betreiber auf einen
|
||||
// Befehl, der ihm „already in step" antwortet.
|
||||
expect(checkFor('billing.catalogue_synced')->breaks)
|
||||
->not->toBe(__('readiness.billing.catalogue_synced_breaks'))
|
||||
// Nur DIESER Fall trägt die Leeranweisung.
|
||||
->and(checkFor('billing.catalogue_synced')->breaks)->toContain('stripe_plan_prices');
|
||||
});
|
||||
|
||||
/**
|
||||
* Der dritte Zustand, und er darf nicht mit dem zweiten zusammenfallen.
|
||||
*
|
||||
* IDs liegen da, aber nichts sagt, aus welchem Konto — weil ein Abgleich
|
||||
* mittendrin abgebrochen ist, oder weil eine Bestellung sich ihren fehlenden
|
||||
* Preis selbst angelegt hat (CheckoutController → PlanPrices::ensure(),
|
||||
* BookAddon → AddonPrices::ensure(); keiner der beiden ruft record()). Sie
|
||||
* stammen aus genau dem geltenden Konto. Wer hier die Leeranweisung liest,
|
||||
* löscht einen Katalog, an dem laufende Verträge abgerechnet werden.
|
||||
*/
|
||||
it('tells an unrecorded origin apart from a foreign account', function () {
|
||||
PlanPrice::query()->update(['stripe_price_id' => 'price_minted_by_a_checkout']);
|
||||
Settings::forget(StripeCatalogueMode::SETTING);
|
||||
|
||||
// Blockierend bleibt es: beweisen lässt sich die Herkunft nicht, und die
|
||||
// Seite behauptet nichts, was sie nicht weiß.
|
||||
expect(checkFor('billing.catalogue_synced')->satisfied)->toBeFalse();
|
||||
|
||||
// Aber die Handlungsanweisung ist „lauf den Abgleich noch einmal", nicht
|
||||
// „lösche deinen Katalog" — kein Registername in diesem Satz.
|
||||
expect(checkFor('billing.catalogue_synced')->breaks)
|
||||
->not->toContain('stripe_plan_prices')
|
||||
->and(checkFor('billing.catalogue_synced')->breaks)->toContain('stripe:sync-catalogue');
|
||||
});
|
||||
|
||||
it('is satisfied again once the catalogue was synced in the mode that is running', function () {
|
||||
PlanPrice::query()->update(['stripe_price_id' => 'price_from_the_live_account']);
|
||||
OperatingMode::set(OperatingMode::Live);
|
||||
StripeCatalogueMode::record();
|
||||
|
||||
expect(checkFor('billing.catalogue_synced')->satisfied)->toBeTrue();
|
||||
});
|
||||
|
|
@ -0,0 +1,112 @@
|
|||
<?php
|
||||
|
||||
use App\Models\Mailbox;
|
||||
use App\Models\MailTemplate;
|
||||
use App\Models\Operator;
|
||||
use App\Services\Secrets\SecretVault;
|
||||
use App\Support\OperatingMode;
|
||||
use App\Support\Readiness;
|
||||
use App\Support\Readiness\Check;
|
||||
|
||||
function deliveryCheck(string $key): ?Check
|
||||
{
|
||||
return collect(Readiness::all())->firstWhere('key', $key);
|
||||
}
|
||||
|
||||
/**
|
||||
* `mail.default = log` heißt: die VM läuft, der Beleg ist geschrieben, und die
|
||||
* Mail, die dem Kunden seine fertige Cloud ankündigt, liegt in einer Datei auf
|
||||
* dem Server. Der Kunde erfährt nie, dass er eine Cloud hat. Kein Fehler
|
||||
* taucht irgendwo auf.
|
||||
*/
|
||||
it('refuses to call an installation ready while mail goes to the log file', function () {
|
||||
config()->set('mail.default', 'log');
|
||||
|
||||
expect(deliveryCheck('delivery.mailer_not_log')->satisfied)->toBeFalse();
|
||||
});
|
||||
|
||||
/**
|
||||
* Fix-Runde (Befund 1): `!== 'log'` allein ist zu eng. MailboxTransport::
|
||||
* NON_DELIVERING kennt bereits die vollständige Liste — 'array' (der
|
||||
* MAIL_MAILER, den phpunit.xml der GANZEN Suite aufzwingt) und der
|
||||
* unkonfigurierte Fall (null) liefern genauso wenig zu wie 'log'. Eine
|
||||
* Prüfung, die nur 'log' kennt, war unter dem eigenen Testlauf grün, obwohl
|
||||
* hier nie eine Mail hinausginge — genau das Grün-aus-falschem-Grund, gegen
|
||||
* das diese Seite gebaut ist. Jeder Wert der Liste wird hier durchgegangen,
|
||||
* nicht nur der eine, den der ursprüngliche Vorfall zeigte.
|
||||
*/
|
||||
it('does not accept any value MailboxTransport itself treats as non-delivering', function (?string $default) {
|
||||
config()->set('mail.default', $default);
|
||||
|
||||
expect(deliveryCheck('delivery.mailer_not_log')->satisfied)->toBeFalse();
|
||||
})->with([
|
||||
'log' => ['log'],
|
||||
'array (the suite\'s own MAIL_MAILER)' => ['array'],
|
||||
'unset' => [null],
|
||||
]);
|
||||
|
||||
it('accepts a real mailer', function () {
|
||||
config()->set('mail.default', 'smtp');
|
||||
|
||||
expect(deliveryCheck('delivery.mailer_not_log')->satisfied)->toBeTrue();
|
||||
});
|
||||
|
||||
it('needs at least one mailbox to send from', function () {
|
||||
// Eine Migration seedet fünf Mailboxen (no-reply, support, billing,
|
||||
// office, info) für jede Installation — auch für diese Suite. Auf den
|
||||
// leeren Zustand zurückgesetzt, den dieser Test eigentlich prüft (siehe
|
||||
// clearMailboxSeed() in Pest.php).
|
||||
clearMailboxSeed();
|
||||
|
||||
// CompleteProvisioning verschickt CloudReady SYNCHRON und wirft, wenn
|
||||
// MailboxResolver keine Mailbox findet — der letzte Schritt der
|
||||
// Bereitstellung scheitert dann, nachdem die Maschine schon läuft und der
|
||||
// Kunde schon bezahlt hat.
|
||||
expect(deliveryCheck('delivery.mailbox')->satisfied)->toBeFalse();
|
||||
|
||||
Mailbox::factory()->create();
|
||||
|
||||
expect(deliveryCheck('delivery.mailbox')->satisfied)->toBeTrue();
|
||||
});
|
||||
|
||||
it('treats missing reply templates as a warning, never as a blocker', function () {
|
||||
// Eine Migration seedet einen Grundstock an Antwortvorlagen für jede
|
||||
// Installation — auch für diese Suite. Geleert, um den leeren Zustand zu
|
||||
// prüfen, den ein frisches Setup ohne diese Migration tatsächlich hätte.
|
||||
MailTemplate::query()->delete();
|
||||
|
||||
expect(deliveryCheck('delivery.mail_templates')->satisfied)->toBeFalse();
|
||||
expect(deliveryCheck('delivery.mail_templates')->severity)->toBe(Check::SEVERITY_WARNING);
|
||||
|
||||
MailTemplate::query()->create([
|
||||
'name' => 'Test Template',
|
||||
'subject' => 'Re: your question',
|
||||
'body' => 'Thanks for reaching out.',
|
||||
'sort' => 1,
|
||||
'active' => true,
|
||||
]);
|
||||
|
||||
expect(deliveryCheck('delivery.mail_templates')->satisfied)->toBeTrue();
|
||||
});
|
||||
|
||||
it('treats a missing inbound mail password as a warning, never as a blocker', function () {
|
||||
OperatingMode::set(OperatingMode::Live);
|
||||
|
||||
expect(deliveryCheck('delivery.inbound_mail_password')->satisfied)->toBeFalse();
|
||||
expect(deliveryCheck('delivery.inbound_mail_password')->severity)->toBe(Check::SEVERITY_WARNING);
|
||||
|
||||
app(SecretVault::class)->put('inbound_mail.password', 'secret', Operator::factory()->create(), OperatingMode::Live);
|
||||
|
||||
expect(deliveryCheck('delivery.inbound_mail_password')->satisfied)->toBeTrue();
|
||||
});
|
||||
|
||||
it('names what breaks for every delivery check, not just what is missing', function () {
|
||||
foreach ([
|
||||
'delivery.mailer_not_log',
|
||||
'delivery.mailbox',
|
||||
'delivery.mail_templates',
|
||||
'delivery.inbound_mail_password',
|
||||
] as $key) {
|
||||
expect(deliveryCheck($key)->breaks)->not->toBe('');
|
||||
}
|
||||
});
|
||||
|
|
@ -0,0 +1,96 @@
|
|||
<?php
|
||||
|
||||
use App\Provisioning\Jobs\RecordProvisioningHeartbeat;
|
||||
use App\Support\Readiness;
|
||||
use App\Support\Readiness\Check;
|
||||
use App\Support\Settings;
|
||||
use Illuminate\Support\Facades\Queue;
|
||||
|
||||
function operationCheck(string $key): ?Check
|
||||
{
|
||||
return collect(Readiness::all())->firstWhere('key', $key);
|
||||
}
|
||||
|
||||
/**
|
||||
* „Ohne Worker passiert schlicht nichts, ohne Fehlermeldung." Genau dieser
|
||||
* Zustand war in der Konsole bisher nicht von „alles ruhig" zu unterscheiden.
|
||||
*
|
||||
* Zwei getrennte Herzschläge, weil Zeitplaner und Warteschlangen-Worker
|
||||
* getrennt ausfallen: der Zeitplaner kann laufen und Aufträge einstellen, die
|
||||
* niemand abholt.
|
||||
*/
|
||||
it('reports a missing heartbeat as not satisfied', function () {
|
||||
expect(operationCheck('operation.scheduler')->satisfied)->toBeFalse();
|
||||
});
|
||||
|
||||
it('accepts a fresh heartbeat', function () {
|
||||
Settings::set('heartbeat.scheduler', now()->toIso8601String());
|
||||
|
||||
expect(operationCheck('operation.scheduler')->satisfied)->toBeTrue();
|
||||
});
|
||||
|
||||
it('rejects a heartbeat older than five minutes', function () {
|
||||
Settings::set('heartbeat.scheduler', now()->subMinutes(6)->toIso8601String());
|
||||
|
||||
expect(operationCheck('operation.scheduler')->satisfied)->toBeFalse();
|
||||
});
|
||||
|
||||
it('proves the worker, not just the scheduler', function () {
|
||||
// Der Zeitplaner läuft, der Bereitstellungs-Worker nicht: der eine
|
||||
// Herzschlag ist frisch, der andere nicht.
|
||||
Settings::set('heartbeat.scheduler', now()->toIso8601String());
|
||||
|
||||
expect(operationCheck('operation.scheduler')->satisfied)->toBeTrue();
|
||||
expect(operationCheck('operation.queue_provisioning')->satisfied)->toBeFalse();
|
||||
});
|
||||
|
||||
it('records the worker heartbeat when the job runs', function () {
|
||||
(new RecordProvisioningHeartbeat)->handle();
|
||||
|
||||
expect(operationCheck('operation.queue_provisioning')->satisfied)->toBeTrue();
|
||||
});
|
||||
|
||||
/**
|
||||
* Fix-Runde (Befund 1): calling ->handle() directly, as every test above
|
||||
* does, proves only that the job writes its value — never that it is the
|
||||
* PROVISIONING worker doing it. That one property is the entire reason a
|
||||
* second heartbeat exists at all: if this job ever migrated onto the default
|
||||
* queue, this check would keep reporting a live provisioning worker while the
|
||||
* queue that actually matters sat unattended — the exact silent failure this
|
||||
* task was built to catch.
|
||||
*/
|
||||
it('is pushed onto the provisioning queue, not the default one', function () {
|
||||
Queue::fake();
|
||||
|
||||
RecordProvisioningHeartbeat::dispatch();
|
||||
|
||||
Queue::assertPushedOn('provisioning', RecordProvisioningHeartbeat::class);
|
||||
});
|
||||
|
||||
/**
|
||||
* Fix-Runde (Befund 2): reproduced live by the reviewer — a garbled value in
|
||||
* the heartbeat row made Carbon::parse() throw, which propagated straight out
|
||||
* of Readiness::all(). Not just this one check failing: the whole readiness
|
||||
* page, whose only purpose is to calmly report what is missing, going down
|
||||
* with it. Unreachable through any real write path today, which is the
|
||||
* argument FOR guarding it cheaply, not against.
|
||||
*/
|
||||
it('treats an unreadable heartbeat as not satisfied, without taking the whole page down', function () {
|
||||
Settings::set('heartbeat.scheduler', 'not-a-timestamp');
|
||||
|
||||
$checks = Readiness::all();
|
||||
|
||||
expect(collect($checks)->firstWhere('key', 'operation.scheduler')->satisfied)->toBeFalse();
|
||||
});
|
||||
|
||||
/**
|
||||
* Fix-Runde (Befund 3): isFresh() had no upper bound — a heartbeat with a
|
||||
* timestamp in the future (a clock skewed between containers, or a corrupted
|
||||
* value that still happens to parse) would report permanent health for as
|
||||
* long as nobody noticed the clock was wrong.
|
||||
*/
|
||||
it('does not treat a heartbeat from the future as healthy', function () {
|
||||
Settings::set('heartbeat.scheduler', now()->addHour()->toIso8601String());
|
||||
|
||||
expect(operationCheck('operation.scheduler')->satisfied)->toBeFalse();
|
||||
});
|
||||
Some files were not shown because too many files have changed in this diff Show More
Loading…
Reference in New Issue