From 200df30b9d1218a6ee9c4ce6eb0e7845ed1672cd Mon Sep 17 00:00:00 2001 From: nexxo Date: Fri, 31 Jul 2026 19:50:01 +0200 Subject: [PATCH] Hetzner-Cloud-DNS, Bereitschaftsseite aus sich heraus behebbar Die alte Hetzner-DNS-API ist abgeschaltet (301 auf die Weboberflaeche). Jede Bereitstellung starb in ConfigureDnsAndTls, nachdem der Kunde bezahlt hatte. HttpHetznerDnsClient und DnsTokenCheck sprechen jetzt die Cloud-API: Bearer statt Auth-API-Token, RRSets ueber {name}/{typ} statt Record-IDs, der Zonen-Lookup entfaellt. Gegen das Live-Konto gemessen, nicht geraten. Drei Fallen, die am Konto gemessen wurden: TXT-Werte muessen in Anfuehrungszeichen (sonst 422, was als read_only gemeldet worden waere), ein Name mit Zonensuffix wird STILL angenommen (201), und der Fake gab andere IDs aus als der echte Client -- zwoelf Tests waren gruen ueber einem Abbau, der im Betrieb geworfen haette. Bereitschaftspunkte, die nur eine Shell beheben konnte, sind jetzt bedienbar: SSH-Schluesselpaar erzeugen (Ed25519 ueber phpseclib, privater Teil direkt in den Tresor), Stripe-Katalog abgleichen (Warteschlange, Trockenlauf, Ausgabe wortgleich), Mailzustellung als Schalter statt MAIL_MAILER, Neustart der Arbeiterprozesse. Stripe hat jetzt alle drei Werte in der Konsole: Secret Key, Signatur-Secret (Tresor, je Modus getrennt) und Publishable Key (Klartext). Codex-Review (R15), zwei P1 behoben: der Signaturschluessel faellt nicht mehr vom Live- in den Testplatz, und eine Record-ID aus der alten API macht einen Host nicht mehr unloeschbar -- was adressierbar ist, wandelt eine Migration um, der Rest wird beim Loeschen laut uebergangen statt geworfen. 2109 Tests gruen. Co-Authored-By: Claude Opus 5 --- app/Jobs/RunCatalogueSync.php | 73 ++++++++ app/Livewire/Admin/ConfirmGenerateSshKey.php | 37 ++++ app/Livewire/Admin/ConfirmSyncCatalogue.php | 35 ++++ app/Livewire/Admin/Integrations.php | 131 +++++++++++++ app/Livewire/Admin/Mail.php | 17 ++ app/Livewire/Admin/MailPreview.php | 3 +- app/Livewire/Admin/Overview.php | 5 +- app/Livewire/Admin/Readiness.php | 45 +++++ app/Mail/Transport/MailboxTransport.php | 7 +- app/Services/Billing/CatalogueSyncStatus.php | 76 ++++++++ app/Services/Dns/DnsTokenCheck.php | 98 +++++++--- app/Services/Dns/FakeHetznerDnsClient.php | 24 ++- app/Services/Dns/HttpHetznerDnsClient.php | 166 +++++++++-------- app/Services/Dns/LegacyRecordIds.php | 63 +++++++ app/Services/Dns/RrsetId.php | 106 +++++++++++ app/Services/Secrets/SecretVault.php | 83 ++++++++- app/Services/Ssh/Keypair.php | 52 ++++++ app/Support/MailDelivery.php | 71 +++++++ app/Support/OperatingMode.php | 11 +- app/Support/Readiness/BillingChecks.php | 10 +- app/Support/Readiness/DeliveryChecks.php | 3 +- app/Support/StripePublishableKey.php | 52 ++++++ app/Support/StripeWebhookSecret.php | 35 ++-- config/services.php | 5 + ...1_120000_migrate_legacy_dns_record_ids.php | 41 +++++ ...7-31-zahlungsmittel-und-mahnlauf-design.md | 163 ++++++++++++++++ lang/de/integrations.php | 15 ++ lang/de/mail_settings.php | 4 + lang/de/readiness.php | 10 +- lang/de/secrets.php | 15 +- lang/en/integrations.php | 15 ++ lang/en/mail_settings.php | 4 + lang/en/readiness.php | 10 +- lang/en/secrets.php | 15 +- .../components/admin/secret-field.blade.php | 14 ++ .../views/components/ui/switch.blade.php | 70 +++++++ .../admin/confirm-generate-ssh-key.blade.php | 17 ++ .../admin/confirm-sync-catalogue.blade.php | 17 ++ .../livewire/admin/integrations.blade.php | 126 +++++++++++-- resources/views/livewire/admin/mail.blade.php | 18 +- .../views/livewire/admin/readiness.blade.php | 51 ++++- tests/Feature/Admin/CatalogueSyncTest.php | 108 +++++++++++ tests/Feature/Admin/CodexFixRoundTest.php | 152 +++++++++++++++ .../Feature/Admin/MailDeliverySwitchTest.php | 93 ++++++++++ tests/Feature/Admin/SecretVaultTest.php | 7 +- tests/Feature/Admin/SshKeypairTest.php | 123 +++++++++++++ .../Admin/StripePublishableKeyTest.php | 94 ++++++++++ .../Admin/StripeWebhookSecretInVaultTest.php | 104 +++++++++++ tests/Feature/Billing/WithdrawalTest.php | 4 +- .../Provisioning/HetznerCloudDnsTest.php | 174 ++++++++++++++++++ tests/Feature/Provisioning/HostStepsTest.php | 6 +- .../Feature/Provisioning/PruneHostDnsTest.php | 15 +- tests/Feature/Provisioning/ServicesTest.php | 62 +------ tests/Feature/Readiness/ActiveChecksTest.php | 128 ++++++++++--- .../Feature/Readiness/RestartWorkersTest.php | 75 ++++++++ tests/Feature/ReadinessPageTest.php | 2 +- 56 files changed, 2696 insertions(+), 264 deletions(-) create mode 100644 app/Jobs/RunCatalogueSync.php create mode 100644 app/Livewire/Admin/ConfirmGenerateSshKey.php create mode 100644 app/Livewire/Admin/ConfirmSyncCatalogue.php create mode 100644 app/Services/Billing/CatalogueSyncStatus.php create mode 100644 app/Services/Dns/LegacyRecordIds.php create mode 100644 app/Services/Dns/RrsetId.php create mode 100644 app/Services/Ssh/Keypair.php create mode 100644 app/Support/MailDelivery.php create mode 100644 app/Support/StripePublishableKey.php create mode 100644 database/migrations/2026_07_31_120000_migrate_legacy_dns_record_ids.php create mode 100644 docs/superpowers/specs/2026-07-31-zahlungsmittel-und-mahnlauf-design.md create mode 100644 resources/views/components/ui/switch.blade.php create mode 100644 resources/views/livewire/admin/confirm-generate-ssh-key.blade.php create mode 100644 resources/views/livewire/admin/confirm-sync-catalogue.blade.php create mode 100644 tests/Feature/Admin/CatalogueSyncTest.php create mode 100644 tests/Feature/Admin/CodexFixRoundTest.php create mode 100644 tests/Feature/Admin/MailDeliverySwitchTest.php create mode 100644 tests/Feature/Admin/SshKeypairTest.php create mode 100644 tests/Feature/Admin/StripePublishableKeyTest.php create mode 100644 tests/Feature/Admin/StripeWebhookSecretInVaultTest.php create mode 100644 tests/Feature/Provisioning/HetznerCloudDnsTest.php create mode 100644 tests/Feature/Readiness/RestartWorkersTest.php diff --git a/app/Jobs/RunCatalogueSync.php b/app/Jobs/RunCatalogueSync.php new file mode 100644 index 0000000..0d0293e --- /dev/null +++ b/app/Jobs/RunCatalogueSync.php @@ -0,0 +1,73 @@ +dryRun ? ['--dry-run' => true] : []); + $output = Artisan::output(); + } catch (Throwable $e) { + // A throw is an outcome too, and it has to reach the page. Left + // uncaught this would be a failed job in a table nobody on this + // page can see, under a "sync running" note that never resolves. + $status->record( + ok: false, + dryRun: $this->dryRun, + output: $e->getMessage(), + by: $this->by, + ); + + return; + } + + $status->record( + ok: $exit === 0, + dryRun: $this->dryRun, + // Whole and unedited — see CatalogueSyncStatus. The command's own + // refusals are instructions, not statuses. + output: trim($output), + by: $this->by, + ); + } +} diff --git a/app/Livewire/Admin/ConfirmGenerateSshKey.php b/app/Livewire/Admin/ConfirmGenerateSshKey.php new file mode 100644 index 0000000..25044b5 --- /dev/null +++ b/app/Livewire/Admin/ConfirmGenerateSshKey.php @@ -0,0 +1,37 @@ +authorize('secrets.manage'); + } + + public function confirm(): void + { + $this->authorize('secrets.manage'); + $this->dispatch('ssh-key-generate-confirmed'); + $this->closeModal(); + } + + public function render() + { + return view('livewire.admin.confirm-generate-ssh-key'); + } +} diff --git a/app/Livewire/Admin/ConfirmSyncCatalogue.php b/app/Livewire/Admin/ConfirmSyncCatalogue.php new file mode 100644 index 0000000..3463a2e --- /dev/null +++ b/app/Livewire/Admin/ConfirmSyncCatalogue.php @@ -0,0 +1,35 @@ +authorize('plans.manage'); + } + + public function confirm(): void + { + $this->authorize('plans.manage'); + $this->dispatch('catalogue-sync-confirmed'); + $this->closeModal(); + } + + public function render() + { + return view('livewire.admin.confirm-sync-catalogue'); + } +} diff --git a/app/Livewire/Admin/Integrations.php b/app/Livewire/Admin/Integrations.php index 6c314d2..814a0da 100644 --- a/app/Livewire/Admin/Integrations.php +++ b/app/Livewire/Admin/Integrations.php @@ -2,16 +2,20 @@ namespace App\Livewire\Admin; +use App\Jobs\RunCatalogueSync; use App\Livewire\Concerns\ConfirmsPassword; +use App\Services\Billing\CatalogueSyncStatus; use App\Services\Deployment\UpdateChannel; use App\Services\Env\EnvFileEditor; use App\Services\Env\InvalidEnvContentException; use App\Services\Mail\InboundMailbox; use App\Services\Mail\InboundMailStatus; use App\Services\Secrets\SecretVault; +use App\Services\Ssh\Keypair; use App\Support\OperatingMode; use App\Support\ProvisioningSettings; use App\Support\Settings; +use App\Support\StripePublishableKey; use Illuminate\Support\Facades\Artisan; use Illuminate\Support\Facades\Auth; use Illuminate\Support\Facades\Gate; @@ -113,6 +117,13 @@ class Integrations extends Component public string $sshPublicKey = ''; + /** + * Der veröffentlichbare Stripe-Schlüssel des aktiven Modus. + * + * Keine Tresor-Eigenschaft: er ist öffentlich und wird im Klartext gezeigt. + */ + public string $stripePublishableKey = ''; + public string $monitoringUrl = ''; /** @@ -175,6 +186,10 @@ class Integrations extends Component $this->tab = self::TABS[0]; } + // Öffentlich, also ohne die hosts.manage-Bedingung darunter: er wird + // im Klartext angezeigt und schützt nichts. + $this->stripePublishableKey = StripePublishableKey::current(); + if (Gate::allows('hosts.manage')) { $this->dnsZone = ProvisioningSettings::dnsZone(); $this->wgEndpoint = ProvisioningSettings::wgEndpoint(); @@ -214,6 +229,11 @@ class Integrations extends Component OperatingMode::set($target); + // Der Platz unter dem Feld hat gewechselt. Ohne diese Zeile zeigt es + // weiter den Schlüssel des anderen Kontos — und speichert ihn beim + // nächsten Klick in den falschen. + $this->stripePublishableKey = StripePublishableKey::current(); + $this->dispatch('notify', message: __('integrations.mode_switched', [ 'mode' => __('readiness.mode.'.$target->value), ])); @@ -287,6 +307,25 @@ class Integrations extends Component $this->save($key); } + /** + * Der dritte Stripe-Wert, und der einzige, der kein Geheimnis ist. + * + * Eigene Aktion statt eines Tresoreintrags: er ist öffentlich, wird im + * Klartext angezeigt und nicht maskiert — siehe StripePublishableKey. + * Hinter derselben Sperre wie der Rest der Zahlungskarte, weil er auf ihr + * steht und nicht, weil er schützenswert wäre. + */ + public function savePublishableKey(): void + { + $this->guardSecrets(); + + $this->validate(['stripePublishableKey' => ['nullable', 'string', 'max:255']]); + + StripePublishableKey::set($this->stripePublishableKey); + + $this->dispatch('notify', message: __('secrets.publishable_saved')); + } + public function forget(string $key): void { $this->guardSecrets(); @@ -303,6 +342,90 @@ class Integrations extends Component $this->forget($key); } + /** + * Mint a fresh SSH identity here, rather than in a shell somewhere. + * + * `ssh.private_key` was the one vault entry with no way to fill it from the + * console: the readiness page flagged it, and the only cure was + * `ssh-keygen` in a terminal, a copy-paste of a private key through a + * clipboard, and remembering to shred the temp file. A page that reports a + * gap it cannot close has moved the work, not done it. + * + * Both halves are written in one go, deliberately. They are two records of + * ONE fact, and this is the only moment both are known — the private half + * is write-only from here on, so a public half saved separately (or not at + * all) could never be checked against it again. Order matters too: the + * private half goes in first, because a published public key whose private + * half was never stored would have EstablishSshTrust install a key nothing + * can log in with, and scrub the root password on the way past. + * + * The private half is never returned, rendered or put in the snapshot; the + * public half is, because it has to be copied onto a machine by hand. + */ + public function generateSshKey(): void + { + $this->guardSecrets(); + + $pair = Keypair::generate(); + + app(SecretVault::class)->put('ssh.private_key', $pair->privateKey, Auth::guard('operator')->user()); + Settings::set('provisioning.ssh_public_key', $pair->publicKey); + + // The form field on this same page — otherwise the next saveInfra() + // would write the OLD public key back over the one just published. + $this->sshPublicKey = $pair->publicKey; + $this->entered[self::field('ssh.private_key')] = ''; + $this->check = null; + + $this->dispatch('notify', message: __('secrets.ssh_generated')); + } + + /** ConfirmGenerateSshKey dispatches this back (R23) — see that class. */ + #[On('ssh-key-generate-confirmed')] + public function onGenerateSshKeyConfirmed(): void + { + $this->generateSshKey(); + } + + /** + * Push the plan catalogue into Stripe from here, rather than from a shell. + * + * Deliberately NOT synchronous. The command talks to Stripe and creates + * objects; a first run on a full catalogue is seconds to minutes, and a + * button that held the request open for it would answer with a gateway + * timeout while the run carried on behind it. So it goes on the queue and + * the result is shown on this page when it lands — see RunCatalogueSync. + * + * The dry run is the FIRST button and asks nothing: it changes nothing and + * prints what would be created. The live run is the second and confirms + * (R23). Anything else would be a button that spends money on one click. + * + * `plans.manage` rather than the vault lock: this is the plan catalogue + * being mirrored, the same decision as publishing a version, and it reveals + * no credential. Owner and Admin, which is the same pair that can reach + * this page for the DNS zone. + */ + public function syncCatalogue(bool $dryRun = true): void + { + $this->authorize('plans.manage'); + + RunCatalogueSync::dispatch( + dryRun: $dryRun, + by: Auth::guard('operator')->user()?->email ?? 'console', + ); + + $this->dispatch('notify', message: __($dryRun + ? 'integrations.catalogue_dry_run_queued' + : 'integrations.catalogue_sync_queued')); + } + + /** ConfirmSyncCatalogue dispatches this back (R23) — see that class. */ + #[On('catalogue-sync-confirmed')] + public function onCatalogueSyncConfirmed(): void + { + $this->syncCatalogue(false); + } + public function test(string $key): void { $this->guardSecrets(); @@ -518,6 +641,10 @@ class Integrations extends Component // When the mailbox was last reached, and what came back. Null until // somebody — or the scheduler — has asked once. 'inboundStatus' => app(InboundMailStatus::class)->last(), + // What the last catalogue run printed. Null until somebody has + // pressed one of the two buttons once. + 'catalogueSync' => app(CatalogueSyncStatus::class)->last(), + 'canCatalogue' => Gate::allows('plans.manage'), 'canSecrets' => $canSecrets, 'canInfra' => $canInfra, 'unlocked' => $unlocked, @@ -537,6 +664,10 @@ class Integrations extends Component // The SSH identity is a multi-line PEM key: a single-line // password field would mangle it on paste. 'multiline' => $key === 'ssh.private_key', + // …and the only entry this console can mint for itself. + // Everything else here is issued by somebody else (Stripe, + // Hetzner, the mail host) and can only be pasted in. + 'generatable' => $key === 'ssh.private_key', ]) ->keyBy('key'), ]); diff --git a/app/Livewire/Admin/Mail.php b/app/Livewire/Admin/Mail.php index 84c19cd..96a3a64 100644 --- a/app/Livewire/Admin/Mail.php +++ b/app/Livewire/Admin/Mail.php @@ -7,6 +7,7 @@ use App\Models\Mailbox; use App\Services\Mail\MailboxTester; use App\Services\Mail\MailPurpose; use App\Services\Secrets\SecretCipher; +use App\Support\MailDelivery; use App\Support\Settings; use Livewire\Attributes\Layout; use Livewire\Component; @@ -31,6 +32,14 @@ class Mail extends Component public string $encryption = 'tls'; + /** + * Ob diese Installation wirklich zustellt. + * + * Zwei Zustände, keine Treiberauswahl — siehe App\Support\MailDelivery. + * Steht im Serverformular, weil er über genau dessen Felder entscheidet. + */ + public bool $deliver = false; + /** @var array purpose => mailbox key */ public array $purposes = []; @@ -62,6 +71,7 @@ class Mail extends Component $this->host = (string) Settings::get('mail.host', ''); $this->port = (int) Settings::get('mail.port', 587); $this->encryption = (string) Settings::get('mail.encryption', 'tls'); + $this->deliver = MailDelivery::delivers(); foreach (MailPurpose::ALL as $purpose) { $this->purposes[$purpose] = (string) Settings::get(MailPurpose::settingKey($purpose), ''); @@ -114,6 +124,13 @@ class Mail extends Component Settings::set('mail.port', (int) $this->port); Settings::set('mail.encryption', $this->encryption); + // Der Hauptschalter, und er gehört genau hierher: er entscheidet, ob + // die drei Zeilen darüber überhaupt benutzt werden. Bis hierher stand + // er als MAIL_MAILER in der .env — die Bereitschaftsseite meldete ihn + // als blockierend und konnte weder sagen, worauf man ihn stellt, noch + // dorthin verlinken, wo man es täte. + MailDelivery::set($this->deliver); + if ($serverChanged) { Mailbox::invalidateAllVerifications(); } diff --git a/app/Livewire/Admin/MailPreview.php b/app/Livewire/Admin/MailPreview.php index e4e6a3d..5110920 100644 --- a/app/Livewire/Admin/MailPreview.php +++ b/app/Livewire/Admin/MailPreview.php @@ -3,6 +3,7 @@ namespace App\Livewire\Admin; use App\Services\Mail\MailPreviews; +use App\Support\MailDelivery; use Illuminate\Support\Facades\Auth; use Illuminate\Support\Facades\Mail; use Livewire\Attributes\Layout; @@ -100,7 +101,7 @@ class MailPreview extends Component // A preview that goes nowhere is worse than none: with a // non-delivering mailer the browser view still works and the send // silently writes to a file. - 'delivers' => ! in_array(config('mail.default'), ['log', 'array', null], true), + 'delivers' => MailDelivery::delivers(), ]); } } diff --git a/app/Livewire/Admin/Overview.php b/app/Livewire/Admin/Overview.php index 4ebc4df..b3dd5bb 100644 --- a/app/Livewire/Admin/Overview.php +++ b/app/Livewire/Admin/Overview.php @@ -13,6 +13,7 @@ use App\Models\Subscription; use App\Services\Billing\PlanCatalogue; use App\Services\Provisioning\HostCapacity; use App\Support\AdminArea; +use App\Support\MailDelivery; use App\Support\Readiness; use App\Support\Settings; use Illuminate\Support\Carbon; @@ -394,10 +395,10 @@ 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'), MailboxTransport::NON_DELIVERING, true)) { + if (! MailDelivery::delivers()) { $notices[] = [ 'level' => 'warning', - 'text' => __('admin.notice.mail_not_delivering', ['mailer' => (string) (config('mail.default') ?? 'null')]), + 'text' => __('admin.notice.mail_not_delivering', ['mailer' => (string) (MailDelivery::transport() ?? 'null')]), 'route' => 'admin.mail', ]; } diff --git a/app/Livewire/Admin/Readiness.php b/app/Livewire/Admin/Readiness.php index ac87858..02704fb 100644 --- a/app/Livewire/Admin/Readiness.php +++ b/app/Livewire/Admin/Readiness.php @@ -2,6 +2,7 @@ namespace App\Livewire\Admin; +use App\Services\Deployment\UpdateChannel; use App\Services\Dns\DnsTokenCheck; use App\Services\Proxmox\VmTemplateCheck; use App\Services\Stripe\StripeCheck; @@ -11,6 +12,7 @@ use App\Support\Readiness as ReadinessRegistry; use App\Support\Readiness\Check; use App\Support\Settings; use Illuminate\Support\Carbon; +use Illuminate\Support\Facades\Auth; use Illuminate\Support\Facades\Gate; use Livewire\Attributes\Layout; use Livewire\Component; @@ -104,6 +106,46 @@ class Readiness extends Component $this->results[$key] = app($class)->run(); } + /** + * The two operation entries are not settings, so they get an ACTION. + * + * "Läuft der Zeitplaner" and "läuft der Bereitstellungs-Worker" have no + * field behind them — there is nothing to type. Their `tab` pointed at the + * raw .env editor, which holds nothing that helps: a stopped worker is a + * process, not a value. What actually cures it is a restart, and this + * console has been able to ask for one since the .env editor learned to + * (UpdateChannel — the panel cannot restart anything itself; see that + * class). It just could not be asked for from the page that reports the + * problem. + * + * `hosts.manage`, matching the update controls on Admin\Settings: this asks + * the host agent to bounce four containers, which is operating the + * installation rather than reading a credential. + */ + public function restartWorkers(): void + { + $this->authorize('hosts.manage'); + + $channel = app(UpdateChannel::class); + + // Honest rather than silent, exactly like saveEnv()'s own version of + // this: with no agent the request lands in a file nobody reads, and + // "wird neu gestartet" over that is the confident display this project + // has already paid for twice. + if (! $channel->state()['agent_seen']) { + $this->dispatch('notify', message: __('readiness.restart_no_agent')); + + return; + } + + $by = Auth::guard('operator')->user()?->email ?? 'console'; + + // The single request slot can already be taken by a check or an update. + $this->dispatch('notify', message: __($channel->requestRestart($by) + ? 'readiness.restart_requested' + : 'readiness.restart_busy')); + } + /** * Where an operator goes to actually fix ONE entry. * @@ -150,6 +192,9 @@ class Readiness extends Component 'ready' => ReadinessRegistry::isReady(), 'mode' => OperatingMode::current(), 'runnable' => array_keys(self::RUNNABLE), + // Die zwei Punkte, deren Kur ein Neustart ist und keine Eingabe. + 'restartable' => array_keys(self::HEARTBEAT_KEYS), + 'canRestart' => Gate::allows('hosts.manage'), 'heartbeats' => collect(self::HEARTBEAT_KEYS) ->map(fn (string $settingsKey) => $this->heartbeatDisplay($settingsKey)) ->all(), diff --git a/app/Mail/Transport/MailboxTransport.php b/app/Mail/Transport/MailboxTransport.php index 479f861..4bda56c 100644 --- a/app/Mail/Transport/MailboxTransport.php +++ b/app/Mail/Transport/MailboxTransport.php @@ -5,6 +5,7 @@ namespace App\Mail\Transport; use App\Models\Mailbox; use App\Services\Mail\MailboxResolver; use App\Services\Mail\MailTlsPolicy; +use App\Support\MailDelivery; use App\Support\Settings; use Illuminate\Mail\Transport\ArrayTransport; use Illuminate\Mail\Transport\LogTransport; @@ -96,7 +97,11 @@ class MailboxTransport implements TransportInterface */ private function resolution(): array { - $default = config('mail.default'); + // The stored switch first, MAIL_MAILER behind it — see + // App\Support\MailDelivery. Read here, at the point of use, so a + // long-running queue worker follows a change made in the console + // instead of keeping whatever was true when it started. + $default = MailDelivery::transport(); if (in_array($default, self::NON_DELIVERING, true)) { return [$default ?? 'null', null]; diff --git a/app/Services/Billing/CatalogueSyncStatus.php b/app/Services/Billing/CatalogueSyncStatus.php new file mode 100644 index 0000000..58c4a31 --- /dev/null +++ b/app/Services/Billing/CatalogueSyncStatus.php @@ -0,0 +1,76 @@ + Carbon::now()->toIso8601String(), + 'ok' => $ok, + 'dry_run' => $dryRun, + // Tail, not head: the summary line an operator is looking for + // ("N object(s) would be created", or the refusal) is the last + // thing printed, and a head-truncated log would cut off exactly it. + 'output' => mb_strlen($output) > self::MAX_OUTPUT + ? '…'.mb_substr($output, -self::MAX_OUTPUT) + : $output, + 'by' => $by, + ]); + } + + /** + * The last run, or null if nobody has ever asked. + * + * @return array{at: Carbon, ok: bool, dry_run: bool, output: string, by: string}|null + */ + public function last(): ?array + { + $row = Settings::get(self::KEY); + + if (! is_array($row) || ! isset($row['at'])) { + return null; + } + + return [ + // Parsed back into a Carbon so the view can put it on the wall + // clock (R19) rather than printing an ISO string at somebody. + 'at' => Carbon::parse((string) $row['at']), + 'ok' => (bool) ($row['ok'] ?? false), + 'dry_run' => (bool) ($row['dry_run'] ?? false), + 'output' => (string) ($row['output'] ?? ''), + 'by' => (string) ($row['by'] ?? ''), + ]; + } +} diff --git a/app/Services/Dns/DnsTokenCheck.php b/app/Services/Dns/DnsTokenCheck.php index aaeef19..7b1147d 100644 --- a/app/Services/Dns/DnsTokenCheck.php +++ b/app/Services/Dns/DnsTokenCheck.php @@ -4,6 +4,7 @@ namespace App\Services\Dns; use App\Services\Secrets\SecretVault; use App\Support\ProvisioningSettings; +use Illuminate\Http\Client\PendingRequest; use Illuminate\Support\Facades\Http; use Illuminate\Support\Str; use Throwable; @@ -15,10 +16,14 @@ use Throwable; * 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 + * already paid. So this writes a throwaway TXT RRSet and deletes it again * immediately; the write (and its rejection) is the only thing that actually * tells the two kinds of token apart. * + * Speaks the CLOUD API (api.hetzner.cloud/v1, Bearer, RRSets) — the old + * dns.hetzner.com endpoint redirects to the web UI and answers nothing. + * See HttpHetznerDnsClient for the rest of that move. + * * 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 @@ -36,9 +41,11 @@ final class DnsTokenCheck return ['ok' => false, 'reason' => 'missing']; } + $http = fn () => Http::withToken((string) $token)->acceptJson()->timeout(15) + ->baseUrl('https://api.hetzner.cloud/v1'); + try { - $zones = Http::withHeaders(['Auth-API-Token' => $token])->acceptJson()->timeout(15) - ->get('https://dns.hetzner.com/api/v1/zones'); + $zones = $http()->get('/zones'); } catch (Throwable) { return ['ok' => false, 'reason' => 'unreachable']; } @@ -75,7 +82,9 @@ final class DnsTokenCheck // Einträgen vorhanden — und die Konsole sagte, es gebe keine. // // Nur ein echtes `{"zones": [...]}` zählt als Antwort. Alles andere - // heißt: hier hat jemand anderes geredet. + // heißt: hier hat jemand anderes geredet. Und genau das war die Antwort + // des ALTEN Endpunkts, seit die DNS-Konsole in den Nur-Lese-Betrieb + // ging: eine 301 auf eine HTML-Seite. $rumpf = $zones->json(); if (! is_array($rumpf) || ! isset($rumpf['zones']) || ! is_array($rumpf['zones'])) { @@ -88,9 +97,12 @@ final class DnsTokenCheck } $verfuegbar = collect($rumpf['zones'])->pluck('name')->filter()->values(); - $zoneId = collect($rumpf['zones'])->firstWhere('name', $zone)['id'] ?? null; - if ($zoneId === null) { + // Die Liste wird weiterhin geholt, obwohl der Zonenname inzwischen + // direkt in den Pfad ginge: sie ist es, die den Fehlschlag unten + // beantwortbar macht. `GET /zones/{name}` sagt nur 404 — das ist genau + // die Sackgasse, aus der der Betreiber den Token austauscht. + if (! $verfuegbar->contains($zone)) { // Die gefundenen Zonen gehören in die Antwort. // // `zone_not_found` allein ist eine Sackgasse: der Betreiber liest @@ -109,30 +121,63 @@ final class DnsTokenCheck ]; } - // 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. - // + return $this->probeWrite($http, (string) $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. + * + * @param callable(): PendingRequest $http + * @return array + */ + private function probeWrite(callable $http, string $zone): array + { + $name = '_clupilot-write-probe-'.Str::lower(Str::random(12)); + $rrsetId = $name.'/TXT'; + // 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" + // 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, - ]); + $probe = $http()->post("/zones/{$zone}/rrsets", [ + 'name' => $name, + 'type' => 'TXT', + 'ttl' => 60, + // Die Anführungszeichen gehören in den WERT. + // + // Am Live-Konto gemessen: ohne sie antwortet die API mit 422 + // `invalid_input`, „TXT records must be fully escaped with + // double quotes". Vor dieser Zeile wäre daraus `read_only` + // geworden — die Prüfung hätte einem einwandfreien Token + // bescheinigt, er dürfe nicht schreiben, und der Betreiber + // hätte einen funktionierenden Schlüssel ausgetauscht. + 'records' => [['value' => '"clupilot readiness probe"']], + ]); } catch (Throwable) { return ['ok' => false, 'reason' => 'unreachable']; } if (! $probe->successful()) { - return ['ok' => false, 'reason' => 'read_only', 'status' => $probe->status()]; + // Nur 401 und 403 sagen etwas über die BERECHTIGUNG. Ein 422 sagt, + // dass der Aufruf falsch geformt war, ein 409 dass schon etwas da + // liegt, ein 503 dass Hetzner gerade nicht mag — und keiner davon + // ist ein Grund, dem Betreiber zu raten, einen funktionierenden + // Token auszutauschen. Dieselbe Trennung wie oben bei der + // Zonenliste, eine Stufe weiter. + if ($probe->status() === 401 || $probe->status() === 403) { + return ['ok' => false, 'reason' => 'read_only', 'status' => $probe->status()]; + } + + return [ + 'ok' => false, + 'reason' => 'write_failed', + 'status' => $probe->status(), + 'detail' => Str::limit((string) ($probe->json('error.message') ?? $probe->body()), 160), + ]; } // The write already proved the token can write — that is the whole @@ -142,12 +187,8 @@ final class DnsTokenCheck // 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(); + $removed = $http()->delete("/zones/{$zone}/rrsets/{$rrsetId}")->successful(); } catch (Throwable) { $removed = false; } @@ -155,7 +196,10 @@ final class DnsTokenCheck $result = ['ok' => true, 'reason' => 'writable', 'probe_removed' => $removed]; if (! $removed) { - $result['leftover_record_id'] = $recordId; + // Der Name, den dieser Aufruf gerade nicht wegbekommen hat — nicht + // eine ID aus der Antwort, die auch fehlen kann. Was hier steht, + // muss man in der Konsole wiederfinden können. + $result['leftover_record_id'] = $rrsetId; } return $result; diff --git a/app/Services/Dns/FakeHetznerDnsClient.php b/app/Services/Dns/FakeHetznerDnsClient.php index aa8cca1..81d1343 100644 --- a/app/Services/Dns/FakeHetznerDnsClient.php +++ b/app/Services/Dns/FakeHetznerDnsClient.php @@ -4,9 +4,15 @@ namespace App\Services\Dns; use RuntimeException; +/** + * Hands out the same `{name}/{type}` ids as the real client and refuses the + * same fqdns — see RrsetId. A fake that answered `rec-1` would let every test + * that purges a host or prunes a legacy record go green over a teardown that + * throws in production. + */ class FakeHetznerDnsClient implements HetznerDnsClient { - /** @var array fqdn => record_id */ + /** @var array fqdn => rrset id */ public array $records = []; /** @var array fqdn => published value — without this a test @@ -18,17 +24,20 @@ class FakeHetznerDnsClient implements HetznerDnsClient public bool $failDelete = false; - private int $counter = 1; - public function upsertRecord(string $fqdn, string $type, string $value): string { if ($this->failUpsert) { throw new RuntimeException('hetzner dns 5xx'); } + // Derived, not counted: the id a caller stores has to be the one the + // real client would store, or the round trip through deleteRecord() + // below proves nothing about the round trip in production. + $id = RrsetId::for($fqdn, $type); + $this->values[$fqdn] = $value; - return $this->records[$fqdn] ??= 'rec-'.$this->counter++; + return $this->records[$fqdn] = $id; } public function deleteRecord(string $recordId): void @@ -37,6 +46,13 @@ class FakeHetznerDnsClient implements HetznerDnsClient throw new RuntimeException('hetzner dns 5xx'); } + // Lets a legacy id pass exactly like the real client does — a host + // carrying one has to stay purgeable, and a fake that threw here would + // prove the opposite of what the real client does. + if (! RrsetId::isRrsetId($recordId)) { + return; + } + $this->records = array_filter($this->records, fn ($id) => $id !== $recordId); } } diff --git a/app/Services/Dns/HttpHetznerDnsClient.php b/app/Services/Dns/HttpHetznerDnsClient.php index dabdbea..d48c5f1 100644 --- a/app/Services/Dns/HttpHetznerDnsClient.php +++ b/app/Services/Dns/HttpHetznerDnsClient.php @@ -3,28 +3,41 @@ namespace App\Services\Dns; use App\Services\Secrets\SecretVault; -use App\Support\ProvisioningSettings; use Illuminate\Http\Client\PendingRequest; use Illuminate\Support\Facades\Http; -use RuntimeException; +use Illuminate\Support\Facades\Log; +use Illuminate\Support\Str; /** - * Hetzner DNS API client. Zone from ProvisioningSettings (console-stored, - * falling back to CLUPILOT_DNS_ZONE); the token comes from the vault - * (console-stored, falling back to HETZNER_DNS_TOKEN). + * Hetzner DNS client, speaking the CLOUD API. Zone from ProvisioningSettings + * (console-stored, falling back to CLUPILOT_DNS_ZONE); the token comes from the + * vault (console-stored, falling back to HETZNER_DNS_TOKEN). * Not unit-tested against the real service (live I/O). + * + * Hetzner moved DNS into the cloud console on 7 October 2025; the old console + * went read-only on 20 May 2026 and `dns.hetzner.com/api/v1` has redirected to + * the web UI ever since. Old tokens and zone ids were not carried over. Until + * this class was moved across, every provisioning run died in + * ConfigureDnsAndTls — after the customer had paid. + * + * Two things changed beyond the host name: + * + * - Authentication is `Authorization: Bearer`, not `Auth-API-Token`. + * - There are no individual records any more, only RRSets addressed by + * `{name}/{type}` (see RrsetId). A set carries several values and + * `set_records` replaces the whole set, so writing is idempotent at the + * provider. That deletes the old find-then-update walk over `GET /records` — + * and with it the paging bug that once published a SECOND A record for a name + * past the hundredth entry in the zone, round-robining a customer's cloud + * onto a host it was not on. There is no lookup left to get wrong. */ class HttpHetznerDnsClient implements HetznerDnsClient { - /** Hetzner's own maximum; asking for more is answered with 100 anyway. */ - private const PAGE_SIZE = 100; - /** - * A ceiling on the record walk — 10 000 records in one zone is far past - * anything this product will hold, and far short of a request loop that never - * ends because a page count came back wrong. + * Unchanged from the old client on purpose: this migration moves the calls, + * not the zone's behaviour. Hetzner's floor is 60s. */ - private const MAX_PAGES = 100; + private const TTL = 300; private function http(): PendingRequest { @@ -33,88 +46,81 @@ class HttpHetznerDnsClient implements HetznerDnsClient // 3): an overlay would add a query to every request, and a // long-running queue worker would keep whatever token was true when // it started. - return Http::withHeaders(['Auth-API-Token' => (string) app(SecretVault::class)->get('dns.token')]) - ->baseUrl('https://dns.hetzner.com/api/v1') + return Http::withToken((string) app(SecretVault::class)->get('dns.token')) + ->acceptJson() + ->baseUrl('https://api.hetzner.cloud/v1') ->timeout(15); } - private function zoneId(): string - { - $zone = ProvisioningSettings::dnsZone(); - $zones = $this->http()->get('/zones', ['name' => $zone])->throw()->json('zones', []); - - return $zones[0]['id'] ?? throw new RuntimeException("DNS zone {$zone} not found"); - } - public function upsertRecord(string $fqdn, string $type, string $value): string { - $zoneId = $this->zoneId(); - $name = rtrim(str_replace('.'.ProvisioningSettings::dnsZone(), '', $fqdn), '.'); + // The zone name goes straight into the path now — the old client had to + // look its id up first, and that lookup is simply gone. + $zone = RrsetId::zone(); + $name = RrsetId::relativeName($fqdn); + $type = Str::upper($type); + $records = [['value' => $value]]; - $existing = $this->findRecord($zoneId, $name, $type); + $created = $this->http()->post("/zones/{$zone}/rrsets", [ + 'name' => $name, + 'type' => $type, + 'ttl' => self::TTL, + 'records' => $records, + ]); - if ($existing) { - $this->http()->put("/records/{$existing['id']}", [ - 'zone_id' => $zoneId, 'type' => $type, 'name' => $name, 'value' => $value, 'ttl' => 300, - ])->throw(); - - return (string) $existing['id']; + // 409 `uniqueness_error` — measured against the live account — is the + // API saying the set is already there, which on a re-run is the normal + // case rather than a fault. Replacing the whole set from here is what + // makes the second pass land on exactly one value: the address a + // customer's instance moved to, with the one it moved off gone in the + // same call. + // + // Create-first rather than replace-first because it terminates in at + // most two calls with no race window: a 409 proves something exists, and + // set_records then overwrites it whatever it was. The other order + // (replace, 404, create) can lose a race and 409 on the create. + if ($created->status() === 409) { + $this->http() + ->post("/zones/{$zone}/rrsets/{$name}/{$type}/actions/set_records", ['records' => $records]) + ->throw(); + } else { + $created->throw(); } - return (string) $this->http()->post('/records', [ - 'zone_id' => $zoneId, 'type' => $type, 'name' => $name, 'value' => $value, 'ttl' => 300, - ])->throw()->json('record.id'); - } - - /** - * The record with this name and type in the zone, or null. - * - * Paged, because Hetzner pages. `GET /records` answers with at most 100 - * entries and this asked only for the first page — so once the zone held more - * than a hundred records the lookup stopped finding entries that were plainly - * there, fell through to POST, and Hetzner accepted a SECOND A record for the - * same name. Two addresses for one instance, resolved round-robin, one of them - * a host the customer's machine is not on: the cloud was up about half the - * time, and every subsequent run made it worse, because the `address` and - * `plan-change` pipelines upsert on every pass. - * - * A hundred is not a distant number here. The zone carries one A record per - * customer instance plus one per host, and nothing prunes it faster than - * instances are sold. - * - * @return array|null - */ - private function findRecord(string $zoneId, string $name, string $type): ?array - { - $page = 1; - - do { - $response = $this->http() - ->get('/records', ['zone_id' => $zoneId, 'page' => $page, 'per_page' => self::PAGE_SIZE]) - ->throw(); - - foreach ($response->json('records', []) as $record) { - if (($record['name'] ?? null) === $name && ($record['type'] ?? null) === $type) { - return $record; - } - } - - // Absent or nonsensical pagination means "this was the lot": a client - // that trusted the field to be there could loop for ever against an - // API having a bad day, and MAX_PAGES is the floor under that even - // when it answers. - $lastPage = (int) $response->json('meta.pagination.last_page', $page); - } while ($page++ < min($lastPage, self::MAX_PAGES)); - - return null; + // Composed rather than read back out of the response, because the + // replace branch above and the create branch have to hand back the same + // thing, and because this is the string deleteRecord() takes apart + // again. The composition is the API's own — see RrsetId. + return "{$name}/{$type}"; } public function deleteRecord(string $recordId): void { - $response = $this->http()->delete("/records/{$recordId}"); + // An id from the OLD record API cannot address anything here, and + // throwing over it would be worse than the leak it is: PurgeHost calls + // this before deleting the row that holds the id, so a host carrying a + // legacy one could never be purged at all — every attempt would die on + // the same line, for ever, and the machine would sit in the pool. + // + // Said out loud rather than swallowed. The record does stay in the zone, + // and this log line is the only place that can still be noticed. + // See LegacyRecordIds for the ids that CAN be brought across, and for + // why I was wrong to treat one empty database as proof. + if (! RrsetId::isRrsetId($recordId)) { + Log::warning('Skipping a legacy DNS record id — not addressable in the cloud API, remove it by hand in the Hetzner console.', [ + 'record_id' => $recordId, + 'zone' => RrsetId::zone(), + ]); - // A record that is already gone is the outcome we wanted. Without this, - // a lost response or a retry after a later failure turns every further + return; + } + + [$name, $type] = RrsetId::split($recordId); + + $response = $this->http()->delete('/zones/'.RrsetId::zone()."/rrsets/{$name}/{$type}"); + + // A set that is already gone is the outcome we wanted. Without this, a + // lost response or a retry after a later failure turns every further // attempt into a 404 — and a host that can then never be purged. if ($response->notFound()) { return; diff --git a/app/Services/Dns/LegacyRecordIds.php b/app/Services/Dns/LegacyRecordIds.php new file mode 100644 index 0000000..764528e --- /dev/null +++ b/app/Services/Dns/LegacyRecordIds.php @@ -0,0 +1,63 @@ +get() as $record) { + if (str_contains((string) $record->record_id, '/')) { + continue; + } + + try { + $id = RrsetId::for((string) $record->fqdn, (string) $record->type); + } catch (Throwable) { + // Outside the configured zone, or no zone configured at all. + // Left exactly as it is — see the class comment. + continue; + } + + $record->update(['record_id' => $id]); + $rewritten++; + } + + return $rewritten; + } +} diff --git a/app/Services/Dns/RrsetId.php b/app/Services/Dns/RrsetId.php new file mode 100644 index 0000000..be759b7 --- /dev/null +++ b/app/Services/Dns/RrsetId.php @@ -0,0 +1,106 @@ + true, ], + 'stripe.webhook_secret' => [ + 'config' => 'services.stripe.webhook_secret', + // Stripe issues a SEPARATE signing secret per mode — see the note + // on config_test above. + 'config_test' => 'services.stripe.webhook_secret_test', + 'label' => 'secrets.item.stripe_webhook_secret', + 'env_key' => 'STRIPE_WEBHOOK_SECRET / STRIPE_WEBHOOK_SECRET_TEST', + // Kein Einspringen des Live-Platzes für einen leeren Testplatz — + // siehe isPerMode(). Stripe stellt je Modus ein eigenes Geheimnis + // aus; der Live-Wert prüft ein Testereignis nicht, er lässt es nur + // scheitern, während die Konsole „belegt" meldet. + 'per_mode' => true, + ], 'dns.token' => [ 'config' => 'provisioning.dns.token', 'label' => 'secrets.item.dns_token', @@ -139,7 +164,7 @@ final class SecretVault return null; } - $configured = config(self::REGISTRY[$key]['config']); + $configured = config(self::configKey($key)); return $configured === null ? null : (string) $configured; } @@ -182,11 +207,34 @@ final class SecretVault $this->row($key, $mode) !== null => 'stored', $this->fallback($key, $mode) !== null => 'stored_live', $this->isStrict($key) => 'none', - filled(config(self::REGISTRY[$key]['config'])) => 'environment', + // The line for THIS mode, not just any line: Stripe's signing + // secret has one per mode, and reporting "aus der Serverdatei" off + // the live line while the test slot is what is in force is the same + // dummy source() was fixed for once already. + filled(config(self::configKey($key, $mode))) => 'environment', default => 'none', }; } + /** + * Which .env-backed config key backs this entry under this mode. + * + * One key for almost everything — the same credential names the same + * account in both modes. Stripe's signing secret is the exception: Stripe + * issues one per mode, the server file has always held two lines, and + * falling the test slot back to the live line would hand out a secret that + * verifies nothing while the console called it set. + */ + private static function configKey(string $key, ?OperatingMode $mode = null): string + { + $meta = self::REGISTRY[$key]; + $mode ??= OperatingMode::current(); + + return $mode->isTest() && isset($meta['config_test']) + ? $meta['config_test'] + : $meta['config']; + } + public function outline(string $key, ?OperatingMode $mode = null): ?string { return $this->inForce($key, $mode)?->outline; @@ -272,13 +320,36 @@ final class SecretVault { $mode ??= OperatingMode::current(); - if (! $mode->isTest() || $this->isStrict($key)) { + if (! $mode->isTest() || $this->isStrict($key) || $this->isPerMode($key)) { return null; } return $this->row($key, OperatingMode::Live); } + /** + * Entries whose value is issued SEPARATELY per mode — no stored row of one + * mode may ever stand in for the other. + * + * Different from `strict` in exactly one respect, and that respect is the + * point: a strict entry sees no environment at all, this one still falls + * back to the server file — to ITS OWN line for this mode (`config_test`). + * Stripe's signing secret is the case: two variables have always been in + * the .env, one per mode, so the file half was never ambiguous. It was the + * STORED half that could cross over, and it did so invisibly — test-mode + * webhooks would be verified with the live secret, fail every one, while + * source() reported the slot as filled from the live one. + * + * Codex review 2026-07-31, P1. `strict` alone would have been the wrong + * cure: it would have cut the .env fallback too, and every installation + * carrying STRIPE_WEBHOOK_SECRET in its server file would have started + * rejecting live payment events on upgrade. + */ + private function isPerMode(string $key): bool + { + return (bool) (self::REGISTRY[$key]['per_mode'] ?? false); + } + /** Does this entry carry the strict trait? Populated in task 3. */ private function isStrict(string $key): bool { diff --git a/app/Services/Ssh/Keypair.php b/app/Services/Ssh/Keypair.php new file mode 100644 index 0000000..69d80be --- /dev/null +++ b/app/Services/Ssh/Keypair.php @@ -0,0 +1,52 @@ +toString('OpenSSH'), + // One line, `ssh-ed25519 AAAA… clupilot`, which is the whole of + // what EstablishSshTrust appends to a host's authorized_keys. + publicKey: $key->getPublicKey()->toString('OpenSSH', ['comment' => self::COMMENT]), + ); + } +} diff --git a/app/Support/MailDelivery.php b/app/Support/MailDelivery.php new file mode 100644 index 0000000..e2e30b1 --- /dev/null +++ b/app/Support/MailDelivery.php @@ -0,0 +1,71 @@ + self::Test, - str_starts_with($secret, 'sk_live_'), str_starts_with($secret, 'rk_live_') => self::Live, + str_starts_with($secret, 'sk_test_'), str_starts_with($secret, 'rk_test_'), + str_starts_with($secret, 'pk_test_') => self::Test, + str_starts_with($secret, 'sk_live_'), str_starts_with($secret, 'rk_live_'), + str_starts_with($secret, 'pk_live_') => self::Live, default => null, }; } diff --git a/app/Support/Readiness/BillingChecks.php b/app/Support/Readiness/BillingChecks.php index 1c196af..ca96f8a 100644 --- a/app/Support/Readiness/BillingChecks.php +++ b/app/Support/Readiness/BillingChecks.php @@ -82,12 +82,14 @@ final class BillingChecks satisfied: filled($secret) && ! $wrongSlot, ), new Check( - key: 'billing.webhook_secret', + key: 'billing.stripe_webhook_secret', group: self::GROUP, severity: Check::SEVERITY_BLOCKING, - label: __('readiness.billing.webhook_secret'), - breaks: __('readiness.billing.webhook_secret_breaks'), - tab: 'env', + label: __('readiness.billing.stripe_webhook_secret'), + breaks: __('readiness.billing.stripe_webhook_secret_breaks'), + // Seit dem Umzug in den Tresor ein Feld auf der Zahlungskarte, + // nicht mehr eine Zeile im Roheditor. + tab: 'services', // 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. diff --git a/app/Support/Readiness/DeliveryChecks.php b/app/Support/Readiness/DeliveryChecks.php index 82ae9c3..93eaa97 100644 --- a/app/Support/Readiness/DeliveryChecks.php +++ b/app/Support/Readiness/DeliveryChecks.php @@ -6,6 +6,7 @@ use App\Mail\Transport\MailboxTransport; use App\Models\Mailbox; use App\Models\MailTemplate; use App\Services\Secrets\SecretVault; +use App\Support\MailDelivery; /** * What has to be in place for the customer to actually find out any of this @@ -32,7 +33,7 @@ final class DeliveryChecks // 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), + satisfied: MailDelivery::delivers(), ), new Check( key: 'delivery.mailbox', diff --git a/app/Support/StripePublishableKey.php b/app/Support/StripePublishableKey.php new file mode 100644 index 0000000..547a0ef --- /dev/null +++ b/app/Support/StripePublishableKey.php @@ -0,0 +1,52 @@ +away()` auf Stripes eigene Bezahlseite; im Browser läuft kein + * Stripe.js, das ihn bräuchte. Er steht hier, weil ein Betreiber beim + * Einrichten drei Werte vor sich hat und nicht raten soll, welcher wohin + * gehört — und weil der Tag, an dem eine eingebettete Bezahlmaske dazukommt, + * ihn ohnehin verlangt. Das ist auf der Karte auch so gesagt, statt es zu + * verschweigen und wie eine wirksame Einstellung aussehen zu lassen. + */ +final class StripePublishableKey +{ + private static function settingKey(?OperatingMode $mode = null): string + { + return 'stripe.publishable_key.'.($mode ?? OperatingMode::current())->value; + } + + /** Der gespeicherte Wert, sonst der aus der Serverdatei — je Modus die eigene Zeile. */ + public static function current(?OperatingMode $mode = null): string + { + $mode ??= OperatingMode::current(); + $stored = Settings::get(self::settingKey($mode)); + + if (filled($stored)) { + return (string) $stored; + } + + return (string) config($mode->isTest() ? 'services.stripe.key_test' : 'services.stripe.key'); + } + + public static function set(string $key, ?OperatingMode $mode = null): void + { + Settings::set(self::settingKey($mode), trim($key)); + } +} diff --git a/app/Support/StripeWebhookSecret.php b/app/Support/StripeWebhookSecret.php index e1c7acb..90ba9b7 100644 --- a/app/Support/StripeWebhookSecret.php +++ b/app/Support/StripeWebhookSecret.php @@ -2,21 +2,30 @@ namespace App\Support; +use App\Services\Secrets\SecretVault; + /** * 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. + * Liegt im Tresor, mit den zwei Plätzen, die der Tresor für Test und Live + * ohnehin führt — und fällt auf die `.env` zurück, je Modus auf die eigene + * Zeile (STRIPE_WEBHOOK_SECRET_TEST bzw. STRIPE_WEBHOOK_SECRET), damit eine + * bestehende Installation sich durch den Umzug nicht ändert. * - * 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. + * Vorher standen beide Werte AUSSCHLIESSLICH in der Serverdatei, begründet + * damit, dass dieser Wert bei jedem eingehenden Zahlungsereignis gelesen wird + * und ein Datenbankproblem die Signaturprüfung still fehlschlagen ließe. Diese + * Begründung hat nicht gehalten: `stripe.secret` — der Schlüssel, mit dem das + * Geld eingezogen wird — liegt seit jeher in derselben Tabelle und wird bei + * jeder Buchung gelesen, und die AUSWAHL hier fragte die Datenbank sowieso + * schon. Bezahlt hat es der Betreiber: ein Wert in der Konsole, zwei in einer + * Datei, und eine Bereitschaftsprüfung, die das als blockierend meldete. + * + * Die Richtung des Ausfalls bleibt die sichere: `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 @@ -26,8 +35,6 @@ 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'); + return (string) app(SecretVault::class)->get('stripe.webhook_secret'); } } diff --git a/config/services.php b/config/services.php index e8cec2a..d1be3d9 100644 --- a/config/services.php +++ b/config/services.php @@ -42,6 +42,11 @@ return [ // 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'), + // Der veröffentlichbare Schlüssel, je Modus einer. Nur der Rückfall für + // App\Support\StripePublishableKey — der Wert selbst steht in der + // Konsole, im Klartext, weil er öffentlich ist. + 'key' => env('STRIPE_KEY'), + 'key_test' => env('STRIPE_KEY_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. diff --git a/database/migrations/2026_07_31_120000_migrate_legacy_dns_record_ids.php b/database/migrations/2026_07_31_120000_migrate_legacy_dns_record_ids.php new file mode 100644 index 0000000..db51b14 --- /dev/null +++ b/database/migrations/2026_07_31_120000_migrate_legacy_dns_record_ids.php @@ -0,0 +1,41 @@ +migrate(); + + if ($rewritten > 0) { + echo " migrated {$rewritten} legacy DNS record id(s) to the cloud API format\n"; + } + } + + /** + * Kein down(). Der alte Endpunkt ist abgeschaltet — eine Rückwandlung + * ergäbe IDs, mit denen niemand mehr etwas anfangen kann, und das + * ursprüngliche `rec-…` ist aus dem neuen Wert ohnehin nicht + * wiederherstellbar. + */ + public function down(): void {} +}; diff --git a/docs/superpowers/specs/2026-07-31-zahlungsmittel-und-mahnlauf-design.md b/docs/superpowers/specs/2026-07-31-zahlungsmittel-und-mahnlauf-design.md new file mode 100644 index 0000000..f4e085c --- /dev/null +++ b/docs/superpowers/specs/2026-07-31-zahlungsmittel-und-mahnlauf-design.md @@ -0,0 +1,163 @@ +# Zahlungsmittel und Mahnlauf + +Entwurf vom 31. Juli 2026. Zwei Lieferungen, in dieser Reihenfolge. + +## Was heute passiert, wenn eine Abbuchung scheitert + +Gemessen im Code, nicht erinnert: + +- `ApplyStripeBillingEvent::invoicePaymentFailed()` setzt den Vertrag auf + `past_due` und schreibt einen `SubscriptionRecord` je Versuch. +- **Sonst nichts.** Keine Mail, keine Anzeige im Portal, keine Sperre. Der + Kopfkommentar nennt auch den Grund: „cutting a customer off on the first + failed attempt would punish an expired card as though it were a refusal to + pay." +- Das Portal hat **keinerlei Zahlungsmittel-Verwaltung**. Kein + `payment_method`, kein Billing-Portal, kein Bezahllink. Ein Kunde kann seine + Karte nicht austauschen — nirgends. +- `ProxmoxClient::shutdownVm()` existiert (geordnet, mit Zeitlimit). Das + Werkzeug zum Abschalten ist da, es benutzt nur niemand dafür. + +Der letzte Punkt der Aufzählung ist der wichtigste: **ein Mahnlauf über einem +Portal ohne Kartenwechsel mahnt jemanden, der die Ursache gar nicht beheben +kann.** Deshalb die Zweiteilung, und deshalb diese Reihenfolge. + +## Getroffene Entscheidungen + +| Frage | Entscheidung | +|---|---| +| Wer führt den Mahnlauf | **CluPilot.** Stripes eigene Mahn-Mails werden im Stripe-Dashboard einmal von Hand abgeschaltet. Stripe versucht weiter automatisch abzubuchen; Fristen, Stufen, Gebühren, Sperre und alle Kundenmails kommen von hier. | +| Wie die Gebühr eingezogen wird | **Eigene Gebührenrechnung neben der gescheiterten.** Keine Stornierung, keine Rechnungsnummer wird angefasst. „Jetzt bezahlen" begleicht beide in einem Vorgang. | +| Wie weit die Sperre reicht | **Nur der betroffene Vertrag.** Andere Verträge desselben Kunden laufen weiter. | +| Kartenwechsel | **Eigene Maske mit Stripe Elements.** Bleibt im Design des Portals; der Publishable Key wird damit erstmals wirklich gelesen. | +| Gebührenhöhe (Vorgabe) | **Steigend: 5 € zur zweiten, 10 € zur dritten Mahnung.** Startstufe und Beträge im Adminbereich einstellbar. | + +Eine Stripe-Abo-Rechnung ist beim Scheitern bereits finalisiert; eine Gebühr +lässt sich nachträglich nicht auf sie draufbuchen. Das ist der Grund für die +zweite Zeile, nicht Bequemlichkeit. + +--- + +# Lieferung 1 — Zahlungsmittel und Nachzahlen + +Für sich allein nützlich, auch ohne den Mahnlauf: ein Kunde mit abgelaufener +Karte kann sie heute nicht tauschen. + +## Bestandteile + +- **Kartenmaske im Portal** (Stripe Elements). Ein SetupIntent wird + serverseitig erzeugt, die Maske bestätigt ihn im Browser, das entstandene + Zahlungsmittel wird als Vorgabe am Stripe-Kunden gesetzt. SCA-Rückfragen + laufen über Elements' eigene Bestätigung. +- **Offene Rechnungen bezahlen.** Liste der offenen Stripe-Rechnungen des + Kunden mit einem Knopf je Rechnung und einem „alles offene bezahlen". + Serverseitig `POST /invoices/{id}/pay`. +- **Banner im Portal**, sobald ein Vertrag `past_due` ist: was gescheitert ist, + was offen ist, und die zwei Knöpfe. +- **`STRIPE_KEY`/`STRIPE_KEY_TEST`** wird ab hier gelesen. Fehlt er, zeigt die + Maske das und bietet nichts an, statt eine leere Karte zu zeichnen. + +## Prüfungen + +- Ein Kunde ohne hinterlegtes Zahlungsmittel bekommt die Maske, nicht eine + Fehlerseite. +- Ein bestätigter SetupIntent macht das Zahlungsmittel zur **Vorgabe** — sonst + scheitert die nächste Abbuchung mit derselben kaputten Karte weiter. +- Bezahlen einer offenen Rechnung, die inzwischen anderweitig beglichen wurde, + ist folgenlos und meldet keinen Fehler. +- Der Publishable Key des falschen Betriebsmodus wird abgewiesen, nicht + benutzt. + +--- + +# Lieferung 2 — Mahnlauf, Gebühren, Sperre + +## Der Zustand lebt in einer eigenen Tabelle + +`dunning_cases` — eine Zeile je **Rückstands-Episode**: + +| Spalte | Bedeutung | +|---|---| +| `subscription_id` | welcher Vertrag | +| `stripe_invoice_id` | die gescheiterte Rechnung | +| `level` | 0 = Hinweis, 1–3 = Mahnstufe, 4 = gesperrt | +| `next_step_at` | wann der nächste Schritt fällig ist | +| `opened_at`, `settled_at` | Beginn und Ende der Episode | +| `fee_invoice_ids` | die ausgestellten Gebührenrechnungen | + +**Nicht als Spalte an `subscriptions`.** Ein Kunde kann mehr als einmal in +Rückstand geraten; eine Spalte überschriebe die vorige Episode — und genau die +will jemand sehen, wenn der Kunde anruft und fragt, warum schon wieder Gebühren +anfallen. + +## Die Teile + +| Teil | Aufgabe | +|---|---| +| `App\Actions\OpenDunningCase` | Hängt an `invoicePaymentFailed()`. Je Rechnung genau einmal — ein zweiter fehlgeschlagener Versuch derselben Rechnung eröffnet keinen zweiten Fall. | +| `App\Services\Billing\DunningSchedule` | Fristen, Startstufe, Beträge aus `Settings`. Eine Stelle, an der die Vorgaben stehen. | +| `App\Console\Commands\AdvanceDunning` | Täglich. Fällige Fälle eine Stufe weiter: Mail, Gebührenrechnung, auf der letzten Stufe abschalten. | +| `App\Actions\SettleDunningCase` | Wenn **beide** Rechnungsarten bezahlt sind: Fall schließen, VM hochfahren, Marke entfernen. | +| Portal | Banner mit Grund und Betrag, „Zahlung nachholen". | +| Konsole | `admin.dunning` — Einstellungen oben, laufende Fälle darunter. | + +## Vorgabe-Fristen + +| Tag | Was passiert | +|---|---| +| 0 | Hinweis-Mail. **Keine** Mahnung, keine Gebühr — eine abgelaufene Karte ist keine Zahlungsverweigerung. | +| 3 | Erste Mahnung | +| 10 | Zweite Mahnung, **+5 €** | +| 17 | Dritte Mahnung, **+10 €** | +| 24 | Cloud wird abgeschaltet | + +Alles einstellbar, einschließlich der Stufe, ab der eine Gebühr anfällt. + +## Abschalten heißt abschalten + +`shutdownVm()` — geordnet, mit Zeitlimit. Dazu `instances.suspended_at`, damit +weder Monitoring noch ein Wiederanlauf die Maschine versehentlich zurückholt. +**Nichts wird gelöscht, keine Platte angefasst.** Beim Begleichen: hochfahren, +Marke weg. + +**Anmelden und Bezahlen bleiben immer offen.** Eine Sperre, die den Kunden von +der Rechnung aussperrt, ist ein Fehler, kein Druckmittel. + +## Konsole: `admin.dunning` + +Eigener Eintrag unter **Betrieb**, neben „Rechnungen" — das liest jemand, +während er sich um offenes Geld kümmert, nicht während er die Installation +einrichtet. Berechtigung `site.manage`, wie die zwei Seiten daneben. + +Oben die Einstellungen. Darunter die laufenden Fälle mit Kunde, Vertrag, Stufe, +offenem Betrag und Fälligkeit — und den zwei Eingriffen, die ein Mensch +braucht: **Frist verlängern** und **Fall schließen** (wenn jemand überwiesen +hat). Beide mit Begründungsfeld, beide im Fall vermerkt. + +## Mails + +Sechs Vorlagen über `MailPurpose::BILLING`: Hinweis (Tag 0), Mahnung 1–3, +Sperre, Freigabe. Jede nennt den offenen Betrag, die Gebühren getrennt +ausgewiesen, und führt auf denselben Knopf im Portal. + +## Prüfungen + +- Ein Fall schreitet **nur** fort, wenn `next_step_at` erreicht ist, und ein + zweiter Lauf am selben Tag tut nichts. +- Gebührenrechnungen entstehen **genau einmal je Stufe**, auch wenn der Befehl + zweimal läuft. +- `SettleDunningCase` schließt erst, wenn Rückstand **und** Gebühren bezahlt + sind — die halbe Zahlung schließt nichts. +- Die Sperre fährt herunter und **löscht nie**; ein Test, der das festhält, + gehört zu den teuersten, die hier fehlen könnten. +- Ein beglichener Fall fährt die Maschine wieder hoch. +- Die Sperre verhindert **nicht** Anmeldung, Portal oder Bezahlseite. +- Ein Vertrag in Sperre lässt andere Verträge desselben Kunden unberührt. + +## Was ausdrücklich nicht dazugehört + +- **Kein Inkasso, keine Kündigung.** Nach der Sperre passiert nichts weiter + von selbst; was dann geschieht, entscheidet ein Mensch. +- **Keine anteilige Rückrechnung.** Die laufende Periode bleibt, wie sie ist. +- **Keine zweite Stufe der Sperre** (ganzes Konto). Wenn sie gebraucht wird, + ist sie ein eigener Entwurf. diff --git a/lang/de/integrations.php b/lang/de/integrations.php index 3494874..06b7ab2 100644 --- a/lang/de/integrations.php +++ b/lang/de/integrations.php @@ -18,6 +18,21 @@ return [ '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', + 'catalogue_title' => 'Preiskatalog in Stripe', + 'catalogue_body' => 'Legt für jede veröffentlichte Paketversion Produkt und Preise in Stripe an. Der Trockenlauf zeigt zuerst, was entstünde.', + 'catalogue_dry_run' => 'Trockenlauf', + 'catalogue_sync' => 'Abgleichen', + 'catalogue_dry_run_queued' => 'Trockenlauf eingereiht — das Ergebnis erscheint hier, sobald er durch ist.', + 'catalogue_sync_queued' => 'Abgleich eingereiht — das Ergebnis erscheint hier, sobald er durch ist.', + 'catalogue_never_run' => 'Noch nie gelaufen.', + 'catalogue_ok' => 'Durchgelaufen', + 'catalogue_failed' => 'Fehlgeschlagen', + 'catalogue_was_dry_run' => 'Trockenlauf', + 'catalogue_was_live_run' => 'Echter Lauf', + 'catalogue_sync_title' => 'Katalog wirklich nach Stripe schreiben?', + 'catalogue_sync_body' => 'Es entstehen Produkte und Preise in dem Stripe-Konto, das zum aktuellen Betriebsmodus gehört. Ein Stripe-Preis lässt sich nicht mehr ändern, nur archivieren. Der Trockenlauf zeigt vorher, was angelegt würde.', + 'catalogue_sync_confirm' => 'Abgleichen', + 'payments_title' => 'Zahlungen (Stripe)', 'payments_body' => 'Der Secret Key, mit dem Zahlungen entgegengenommen und Webhooks geprüft werden.', diff --git a/lang/de/mail_settings.php b/lang/de/mail_settings.php index 4629148..641855e 100644 --- a/lang/de/mail_settings.php +++ b/lang/de/mail_settings.php @@ -11,6 +11,10 @@ return [ 'host' => 'Server', 'port' => 'Port', 'encryption' => 'Verschlüsselung', + 'deliver' => 'Zustellung', + 'deliver_hint' => 'Entscheidet, ob die Serverdaten darunter überhaupt benutzt werden. Steht das auf „nur ins Log", verlässt keine einzige Mail den Server — auch der Postfach-Test meldet dann noch Erfolg, weil er seinen eigenen Weg nimmt.', + 'deliver_on' => 'Wird versendet', + 'deliver_off' => 'Nur ins Log', 'encryption_none' => 'Keine', 'save' => 'Speichern', 'server_saved' => 'Serverdaten gespeichert.', diff --git a/lang/de/readiness.php b/lang/de/readiness.php index 0c43bca..8683f07 100644 --- a/lang/de/readiness.php +++ b/lang/de/readiness.php @@ -48,8 +48,8 @@ return [ // 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.', + 'stripe_webhook_secret' => 'Stripe-Signaturschlüssel', + 'stripe_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', @@ -115,9 +115,15 @@ return [ '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.', ], + 'restart_workers' => 'Arbeiter neu starten', + 'restart_requested' => 'Neustart angefordert — Zeitplaner, Warteschlangen und Reverb kommen in wenigen Sekunden zurück.', + 'restart_busy' => 'Es liegt bereits eine Anfrage beim Agenten. Bitte kurz warten und erneut versuchen.', + 'restart_no_agent' => 'Auf diesem Server läuft kein Agent, der den Neustart ausführen könnte — hier hilft nur ein Neustart der Container von Hand.', + 'env_only' => 'Für diesen Wert gibt es bewusst kein Feld: er verschlüsselt alles, was hier sonst gespeichert wird. Wer ihn über die Konsole setzen könnte, könnte den Schutz sämtlicher Zugangsdaten austauschen. Er steht in der Serverdatei — „Beheben" führt in deren Editor.', 'zone_wanted' => 'Der Token ist gültig — er hat die Zonenliste geholt. Gesucht wurde die Zone :zone.', 'zone_available' => 'In diesem Konto liegen: :zones. Entweder CLUPILOT_DNS_ZONE anpassen oder die Zone bei Hetzner anlegen.', 'zone_none' => 'In diesem Konto liegt keine einzige Zone. Der Token gehört vermutlich zu einem anderen Hetzner-Projekt.', 'zone_list_unclear' => 'Hetzner hat die Zonenliste nicht ausgeliefert (HTTP :status). Über die Zonen dieses Kontos sagt das nichts — der Token kann trotzdem richtig sein.', 'zone_list_unreadable' => 'Die Antwort kam mit HTTP :status, war aber keine Zonenliste. Da hat etwas anderes geantwortet — ein Portal, ein Filter, ein Proxy. Anfang der Antwort: :body', + 'write_failed' => 'Der Schreibversuch wurde mit HTTP :status abgelehnt. Über die Berechtigung des Tokens sagt das nichts — der wäre mit 401 oder 403 abgewiesen worden. Hetzner sagt dazu: :detail', ]; diff --git a/lang/de/secrets.php b/lang/de/secrets.php index d5d0e0d..2b45a5e 100644 --- a/lang/de/secrets.php +++ b/lang/de/secrets.php @@ -4,6 +4,8 @@ return [ // Die Namen der verwalteten Zugangsdaten. 'item' => [ 'stripe_secret' => 'Stripe Secret Key', + 'stripe_webhook_secret' => 'Stripe Webhook-Signatur-Secret', + 'stripe_publishable_key' => 'Stripe Publishable Key (öffentlich)', 'inbound_mail_password' => 'Passwort des Support-Postfachs', 'dns_token' => 'Hetzner-DNS-API-Token', 'monitoring_token' => 'Uptime-Kuma-API-Token', @@ -53,6 +55,13 @@ return [ // unterscheiden. Der Fließtext selbst (body) ist unverändert der frühere // Bestätigungssatz; _confirm ist jetzt die kurze Knopfbeschriftung, wie // überall sonst im Repo. + 'ssh_generate' => 'Schlüsselpaar erzeugen', + 'ssh_generate_hint' => 'Ed25519. Der private Teil geht direkt in den Tresor und wird nie angezeigt.', + 'ssh_generate_title' => 'Neues Schlüsselpaar erzeugen?', + 'ssh_generate_body' => 'Der bisherige private Schlüssel wird ersetzt. Der bisherige öffentliche Teil steht weiter in den authorized_keys jedes übernommenen Hosts, der neue noch nicht — bis Sie ihn dort eintragen, erreicht die Konsole keine dieser Maschinen mehr.', + 'ssh_generate_confirm' => 'Erzeugen', + 'ssh_generated' => 'Schlüsselpaar erzeugt. Der öffentliche Teil steht unten — er gehört auf jeden bereits übernommenen Host.', + 'save_title' => ':label wirklich übernehmen?', 'save_body' => 'Er wirkt sofort — ein falscher Wert legt Zahlungen still.', 'save_confirm' => 'Übernehmen', @@ -78,5 +87,9 @@ return [ 'check_webhooks_unknown' => 'Dieser Schlüssel darf die Webhooks nicht lesen — das ist bei eingeschränkten Schlüsseln normal.', 'check_webhooks_none' => 'Bei Stripe ist kein Webhook eingetragen. Ohne ihn werden Zahlungen nicht verbucht.', - 'webhook_secret_note' => 'Das Webhook-Signatur-Secret bleibt bewusst in der Serverdatei (.env). Es wird bei jedem eingehenden Zahlungsereignis gelesen — läge es hier, würde ein Datenbankproblem zu stillschweigend fehlschlagenden Signaturprüfungen.', + 'publishable_hint' => 'Beginnt mit pk_test_ oder pk_live_. Wird im Klartext gespeichert — er ist öffentlich.', + 'publishable_wrong_mode' => 'Dieser Schlüssel gehört zum Konto für :mode. Er liegt damit im falschen Platz.', + 'publishable_saved' => 'Publishable Key gespeichert.', + 'publishable_note' => 'Der Publishable Key wird von dieser Installation heute nicht ausgelesen: die Kasse leitet auf Stripes eigene Bezahlseite weiter, im Browser läuft kein Stripe.js. Er steht hier, damit alle drei Werte aus dem Stripe-Dashboard an einer Stelle liegen — und er wird gebraucht, sobald eine eingebettete Bezahlmaske dazukommt. Gesagt statt verschwiegen, damit niemand ihn für eine wirksame Einstellung hält.', + 'webhook_secret_note' => 'Beide Werte gelten je Betriebsmodus: Stripe stellt für Test und Live eigene Schlüssel aus, und jede Karte hier schreibt in den Platz des gerade aktiven Modus. Das Signatur-Secret steht im Stripe-Dashboard unter Entwickler → Webhooks bei dem Endpunkt, der auf diese Installation zeigt.', ]; diff --git a/lang/en/integrations.php b/lang/en/integrations.php index 6d98f1f..bafcc11 100644 --- a/lang/en/integrations.php +++ b/lang/en/integrations.php @@ -18,6 +18,21 @@ return [ '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', + 'catalogue_title' => 'Price catalogue in Stripe', + 'catalogue_body' => 'Creates a product and prices in Stripe for every published plan version. The dry run shows what would be created first.', + 'catalogue_dry_run' => 'Dry run', + 'catalogue_sync' => 'Sync', + 'catalogue_dry_run_queued' => 'Dry run queued — the result appears here once it is through.', + 'catalogue_sync_queued' => 'Sync queued — the result appears here once it is through.', + 'catalogue_never_run' => 'Never run.', + 'catalogue_ok' => 'Completed', + 'catalogue_failed' => 'Failed', + 'catalogue_was_dry_run' => 'Dry run', + 'catalogue_was_live_run' => 'Live run', + 'catalogue_sync_title' => 'Really write the catalogue to Stripe?', + 'catalogue_sync_body' => 'Products and prices are created in the Stripe account belonging to the current operating mode. A Stripe price cannot be edited afterwards, only archived. The dry run shows what would be created first.', + 'catalogue_sync_confirm' => 'Sync', + 'payments_title' => 'Payments (Stripe)', 'payments_body' => 'The secret key used to accept payments and verify webhooks.', diff --git a/lang/en/mail_settings.php b/lang/en/mail_settings.php index fb653b7..bc8befe 100644 --- a/lang/en/mail_settings.php +++ b/lang/en/mail_settings.php @@ -11,6 +11,10 @@ return [ 'host' => 'Server', 'port' => 'Port', 'encryption' => 'Encryption', + 'deliver' => 'Delivery', + 'deliver_hint' => 'Decides whether the server details below are used at all. Set to "log only", not a single mail leaves this server — the mailbox test still reports success, because it takes its own route.', + 'deliver_on' => 'Sending', + 'deliver_off' => 'Log only', 'encryption_none' => 'None', 'save' => 'Save', 'server_saved' => 'Server settings saved.', diff --git a/lang/en/readiness.php b/lang/en/readiness.php index 309e566..a8c40d6 100644 --- a/lang/en/readiness.php +++ b/lang/en/readiness.php @@ -46,8 +46,8 @@ return [ // 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.', + 'stripe_webhook_secret' => 'Stripe signing secret', + 'stripe_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', @@ -110,9 +110,15 @@ return [ '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.', ], + 'restart_workers' => 'Restart the workers', + 'restart_requested' => 'Restart requested — scheduler, queues and Reverb come back in a few seconds.', + 'restart_busy' => 'A request is already waiting for the agent. Give it a moment and try again.', + 'restart_no_agent' => 'No agent is running on this server to carry out the restart — here only restarting the containers by hand helps.', + 'env_only' => 'This value deliberately has no field: it encrypts everything else stored here. Anyone able to set it from the console could replace the protection over every credential. It lives in the server file — "Fix" opens its editor.', 'zone_wanted' => 'The token is valid — it fetched the zone list. It looked for the zone :zone.', 'zone_available' => 'This account holds: :zones. Either adjust CLUPILOT_DNS_ZONE or create the zone at Hetzner.', 'zone_none' => 'This account holds no zones at all. The token probably belongs to a different Hetzner project.', 'zone_list_unclear' => 'Hetzner did not return the zone list (HTTP :status). That says nothing about this account\'s zones — the token may still be correct.', 'zone_list_unreadable' => 'The response came back with HTTP :status but was not a zone list. Something else answered — a portal, a filter, a proxy. Start of the response: :body', + 'write_failed' => 'The write attempt was rejected with HTTP :status. That says nothing about the token\'s permissions — those come back as 401 or 403. Hetzner says: :detail', ]; diff --git a/lang/en/secrets.php b/lang/en/secrets.php index 164bef3..f54a8ae 100644 --- a/lang/en/secrets.php +++ b/lang/en/secrets.php @@ -4,6 +4,8 @@ return [ // Names of the managed credentials. 'item' => [ 'stripe_secret' => 'Stripe secret key', + 'stripe_webhook_secret' => 'Stripe webhook signing secret', + 'stripe_publishable_key' => 'Stripe publishable key (public)', 'inbound_mail_password' => 'Support mailbox password', 'dns_token' => 'Hetzner DNS API token', 'monitoring_token' => 'Uptime Kuma API token', @@ -51,6 +53,13 @@ return [ // three keys is meant — the old sentence alone did not. The body is // unchanged, the former confirmation sentence; _confirm is now the short // confirm-button label, as everywhere else in the repo. + 'ssh_generate' => 'Generate a keypair', + 'ssh_generate_hint' => 'Ed25519. The private half goes straight into the vault and is never shown.', + 'ssh_generate_title' => 'Generate a new keypair?', + 'ssh_generate_body' => 'The current private key is replaced. The current public half stays in the authorized_keys of every onboarded host and the new one is not there yet — until you install it, this console cannot reach any of those machines.', + 'ssh_generate_confirm' => 'Generate', + 'ssh_generated' => 'Keypair generated. The public half is below — it belongs on every host already onboarded.', + 'save_title' => 'Really apply :label?', 'save_body' => 'It takes effect immediately — a wrong value stops payments.', 'save_confirm' => 'Apply', @@ -76,5 +85,9 @@ return [ 'check_webhooks_unknown' => 'This key may not read webhooks — normal for a restricted key.', 'check_webhooks_none' => 'Stripe has no webhook configured. Without one, payments are not recorded.', - 'webhook_secret_note' => 'The webhook signing secret deliberately stays in the server file (.env). It is read on every incoming payment event — stored here, a database problem would turn into silently failing signature checks.', + 'publishable_hint' => 'Starts with pk_test_ or pk_live_. Stored in the clear — it is public.', + 'publishable_wrong_mode' => 'This key belongs to the :mode account, so it is in the wrong slot.', + 'publishable_saved' => 'Publishable key saved.', + 'publishable_note' => 'This installation does not read the publishable key today: the checkout redirects to Stripe\'s own payment page, and no Stripe.js runs in the browser. It is here so all three values from the Stripe dashboard live in one place — and it is needed the moment an embedded payment form is added. Said rather than left out, so nobody mistakes it for a setting that takes effect.', + 'webhook_secret_note' => 'Both values are per operating mode: Stripe issues separate keys for test and live, and each card here writes into the slot of the mode currently active. The signing secret is in the Stripe dashboard under Developers → Webhooks, on the endpoint pointing at this installation.', ]; diff --git a/resources/views/components/admin/secret-field.blade.php b/resources/views/components/admin/secret-field.blade.php index 2fc6705..5dbb554 100644 --- a/resources/views/components/admin/secret-field.blade.php +++ b/resources/views/components/admin/secret-field.blade.php @@ -87,6 +87,20 @@ placeholder="-----BEGIN OPENSSH PRIVATE KEY-----">

{{ __('secrets.ssh_private_key_hint') }}

@error('entered.'.$entry['field'])

{{ $message }}

@enderror + + @if ($entry['generatable']) + {{-- Neben dem Feld, nicht unten bei Speichern: es ist die + Alternative ZUM Einfügen, nicht ein weiterer Schritt + danach. Der erzeugte private Teil geht direkt in den + Tresor und erscheint nie in diesem Textfeld. --}} +
+ + {{ __('secrets.ssh_generate') }} + + {{ __('secrets.ssh_generate_hint') }} +
+ @endif @else null, + 'hint' => null, + 'on' => null, + 'off' => null, +]) +@php $id = $attributes->get('id', $name); @endphp +{{-- + An on/off control that reads as on/off. + + A two-option except(['id', 'class']) }}> + + {{-- Track. --}} + + + {{-- Knob — sibling of the input, laid over the track. + + `top-1/2 -translate-y-1/2` rather than a top offset in pixels: an + absolutely positioned flex child has no useful static position to + fall back on, and a hard-coded offset would drift the moment the + row's line-height changes. Tailwind composes translate-x and + translate-y through separate custom properties, so the checked + state can move it sideways without losing the centring. --}} + + + @if ($on !== null && $off !== null) + {{-- Gleiche Mindestbreite für beide Wörter, sonst wandert der + Schalter beim Umlegen: „Wird versendet" ist breiter als „Nur + ins Log", und die Zeile ist rechtsbündig. `min-w`, nicht `w` — + eine längere Übersetzung darf schieben, aber nie abgeschnitten + werden. --}} + {{ $off }} + + @endif + + diff --git a/resources/views/livewire/admin/confirm-generate-ssh-key.blade.php b/resources/views/livewire/admin/confirm-generate-ssh-key.blade.php new file mode 100644 index 0000000..1f23565 --- /dev/null +++ b/resources/views/livewire/admin/confirm-generate-ssh-key.blade.php @@ -0,0 +1,17 @@ +
+
+ + + +
+

{{ __('secrets.ssh_generate_title') }}

+

{{ __('secrets.ssh_generate_body') }}

+
+
+
+ {{ __('common.cancel') }} + + {{ __('secrets.ssh_generate_confirm') }} + +
+
diff --git a/resources/views/livewire/admin/confirm-sync-catalogue.blade.php b/resources/views/livewire/admin/confirm-sync-catalogue.blade.php new file mode 100644 index 0000000..af3014e --- /dev/null +++ b/resources/views/livewire/admin/confirm-sync-catalogue.blade.php @@ -0,0 +1,17 @@ +
+
+ + + +
+

{{ __('integrations.catalogue_sync_title') }}

+

{{ __('integrations.catalogue_sync_body') }}

+
+
+
+ {{ __('common.cancel') }} + + {{ __('integrations.catalogue_sync_confirm') }} + +
+
diff --git a/resources/views/livewire/admin/integrations.blade.php b/resources/views/livewire/admin/integrations.blade.php index b3b8b36..5f38baf 100644 --- a/resources/views/livewire/admin/integrations.blade.php +++ b/resources/views/livewire/admin/integrations.blade.php @@ -92,27 +92,129 @@ @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. --}} -
+ {{-- Spaltenfluss, nicht Raster. + + Vorher stand hier `grid lg:grid-cols-2 lg:items-start`. Ein Raster legt + Karte 1 und 2 in EINE Reihe, und die Reihe ist so hoch wie die höhere: + neben der Zahlungskarte (zwei Zugangsdaten, Hinweise, Knöpfe) stand der + kurze Katalog-Kasten mit einem halben Bildschirm Leere darunter, und + dasselbe noch einmal eine Reihe tiefer. Bei fünf Karten sehr + unterschiedlicher Höhe ist das nicht zu ordnen, indem man sie umsortiert + — welche wie hoch wird, hängt an Berechtigungen und daran, ob + entsperrt ist. + + `columns` füllt stattdessen von oben nach unten und bricht um, wenn die + Spalte voll ist. `break-inside-avoid` an jeder Karte ist die Zeile, die + es benutzbar macht: ohne sie zerreißt eine Karte mitten im Formular. --}} +
{{-- Zahlungen (Stripe) — vault only, no plain setting belongs here. --}} @if ($canSecrets) -
+

{{ __('integrations.payments_title') }}

{{ __('integrations.payments_body') }}

@if ($unlocked) +
+ {{-- Beide Stripe-Werte auf einer Karte, beide im Tresor, beide + mit Test-/Live-Platz. Vorher stand hier ein blauer Kasten, + der erklärte, warum dieser eine Wert in der Serverdatei + bleiben MUSS — die Begründung hielt nicht (siehe + App\Support\StripeWebhookSecret), und sie kostete den + Betreiber einen Wert, den er nur per Dateieditor setzen + konnte, unter einer Prüfung, die ihn als blockierend + meldete. --}} + +
+ + {{-- Der dritte Wert, den Stripe ausgibt. KEIN Tresoreintrag: er + ist öffentlich, und ihn hinter „nie wieder vollständig + angezeigt" zu setzen wäre Theater — man könnte nicht einmal + nachsehen, welcher eingetragen ist. Also im Klartext, mit + demselben Platz je Betriebsmodus wie die zwei darüber. --}} +
+

{{ __('secrets.item.stripe_publishable_key') }}

+

STRIPE_KEY / STRIPE_KEY_TEST

+ + @php $pkMode = \App\Support\OperatingMode::ofStripeKey($stripePublishableKey); @endphp + @if ($pkMode !== null && $pkMode !== $mode) +

+ {{ __('secrets.publishable_wrong_mode', ['mode' => __('readiness.mode.'.$pkMode->value)]) }} +

+ @endif + +
+ +
+ + + {{ __('secrets.save') }} + +
+ {{ __('secrets.webhook_secret_note') }} +

{{ __('secrets.publishable_note') }}

+ @endif +
+ @endif + + {{-- Der Katalog-Abgleich. Eigene Karte statt eines Knopfes in der + Zahlungskarte darüber: er benutzt zwar den Stripe-Schlüssel, ist aber + nicht dessen Verwaltung — und er hängt an `plans.manage`, nicht am + Tresor-Schloss. Bis hierher konnte er nur in einer Shell laufen, + während die Bereitschaftsseite sein Fehlen als blockierend meldete. --}} + @if ($canCatalogue) +
+
+

{{ __('integrations.catalogue_title') }}

+

{{ __('integrations.catalogue_body') }}

+
+ +
+ {{-- Trockenlauf zuerst, und ohne Rückfrage: er ändert nichts. --}} + + {{ __('integrations.catalogue_dry_run') }} + + + {{ __('integrations.catalogue_sync') }} + +
+ + @if ($catalogueSync === null) +

{{ __('integrations.catalogue_never_run') }}

+ @else +
+

+ + {{ $catalogueSync['ok'] ? __('integrations.catalogue_ok') : __('integrations.catalogue_failed') }} + + + {{ $catalogueSync['dry_run'] ? __('integrations.catalogue_was_dry_run') : __('integrations.catalogue_was_live_run') }} + — {{ $catalogueSync['at']->local()->isoFormat('D. MMM YYYY, HH:mm') }}, + {{ $catalogueSync['by'] }} + +

+ {{-- Wortgleich, nicht zusammengefasst: zwei der Meldungen + dieses Befehls sind ANWEISUNGEN — die Verweigerung + nennt drei Spalten, die geleert werden müssen. Wer sie + zu „fehlgeschlagen" kürzt, schickt den Betreiber + zurück ins Terminal. Eigener Scrollbereich, weil ein + erster Lauf eine Zeile je Produkt und Preis druckt. --}} +
{{ $catalogueSync['output'] }}
+
@endif
@endif {{-- DNS (Hetzner) — token and the zone/Traefik settings that use it. --}} @if ($canSecrets || $canInfra) -
+

{{ __('integrations.dns_title') }}

{{ __('integrations.dns_body') }}

@@ -136,7 +238,7 @@ Domains und Postfächer, gelesen wird ein Postfach über IMAP, egal wer den Server betreibt. --}} @if ($canSecrets || $canInfra) -
+

{{ __('integrations.inbound_title') }}

{{ __('integrations.inbound_body') }}

@@ -189,7 +291,7 @@ {{-- Monitoring — token and where the Kuma bridge is reachable. --}} @if ($canSecrets || $canInfra) -
+

{{ __('integrations.monitoring_title') }}

{{ __('integrations.monitoring_body') }}

@@ -210,10 +312,10 @@ @endif @if ($tab === 'platform') -
+
{{-- VPN/WireGuard — no vault entry: the private hub key stays in .env. --}} @if ($canInfra) -
+

{{ __('integrations.vpn_title') }}

{{ __('integrations.vpn_body') }}

@@ -227,7 +329,7 @@ {{-- SSH-Identität — public half a setting, private half the vault entry. --}} @if ($canSecrets || $canInfra) -
+

{{ __('integrations.ssh_title') }}

{{ __('integrations.ssh_body') }}

@@ -316,7 +418,7 @@ password, because it is the one place on this page that can reach every credential the vault otherwise keeps write-only. --}} @if ($tab === 'env' && $canSecrets) -
+

{{ __('integrations.env_title') }}

{{ __('integrations.env_subtitle') }}

diff --git a/resources/views/livewire/admin/mail.blade.php b/resources/views/livewire/admin/mail.blade.php index e491f38..b9f7d1f 100644 --- a/resources/views/livewire/admin/mail.blade.php +++ b/resources/views/livewire/admin/mail.blade.php @@ -10,9 +10,9 @@ success regardless, since MailboxTester builds its own transport on purpose (a check that honoured MAIL_MAILER=log would report success while writing to a file). --}} - @if (in_array(config('mail.default'), ['log', 'array', null], true)) + @if (! \App\Support\MailDelivery::delivers()) - {{ __('admin.notice.mail_not_delivering', ['mailer' => (string) (config('mail.default') ?? 'null')]) }} + {{ __('admin.notice.mail_not_delivering', ['mailer' => (string) (\App\Support\MailDelivery::transport() ?? 'null')]) }} @endif @@ -26,6 +26,20 @@

{{ __('mail_settings.server_title') }}

{{ __('mail_settings.server_hint') }}

+ {{-- Der Hauptschalter, und er steht ÜBER den Serverfeldern: er + entscheidet, ob die überhaupt benutzt werden. Bis hierher stand er + nur als MAIL_MAILER in der .env — die Bereitschaftsseite meldete + ihn als blockierend, ohne sagen zu können, worauf man ihn stellt. + Zwei Zustände, keine Treiberliste: was tatsächlich sendet, ist + MailboxTransport mit den Feldern darunter. --}} +
+ +
+
diff --git a/resources/views/livewire/admin/readiness.blade.php b/resources/views/livewire/admin/readiness.blade.php index 765ac65..fae2638 100644 --- a/resources/views/livewire/admin/readiness.blade.php +++ b/resources/views/livewire/admin/readiness.blade.php @@ -70,6 +70,19 @@

@endif + {{-- Die zwei Schlüssel, die den Tresor selbst + verschlüsseln. Ohne diesen Satz sieht ein + fehlendes Feld nach einem Versäumnis aus, und + jemand sucht danach. Es ist keins: ein + Schlüssel, den die Konsole setzen könnte, + schützt nichts mehr vor jemandem, der die + Konsole hat. Steht IM Textblock, nicht daneben — + die Zeile ist ein Flex-Container aus Icon, Text + und Aktionen, ein vierter Nachbar zerlegt sie. --}} + @if (in_array($check->key, ['onboarding.secrets_key', 'onboarding.vpn_config_key'], true)) +

{{ __('readiness.env_only') }}

+ @endif + @if ($result !== null)

{{ $result['ok'] ? __('readiness.check_ok') : __('readiness.check_failed') }} @@ -101,6 +114,16 @@

@endif + {{-- Ein abgelehnter Schreibversuch, der NICHT 401/403 war, + sagt nichts über die Berechtigung. Ohne diese Zeile + stünde dort `read_only` und der Betreiber tauschte einen + funktionierenden Token aus. --}} + @if (($result['reason'] ?? null) === 'write_failed') +

+ {{ __('readiness.write_failed', ['status' => $result['status'] ?? '?', 'detail' => $result['detail'] ?? '—']) }} +

+ @endif + @if (($result['reason'] ?? null) === 'zone_not_found')

{{ __('readiness.zone_wanted', ['zone' => $result['zone'] ?? '—']) }} @@ -141,11 +164,29 @@ @endcan @endif - - {{ __('readiness.fix_link') }} - - + {{-- Die zwei Herzschlag-Punkte haben kein Feld. Ihr + „Beheben" zeigte auf den Roheditor der .env, in + dem nichts steht, das hilft — ein gestoppter + Arbeiterprozess ist kein Wert, sondern ein + Prozess. Hier steht deshalb die Aktion, die ihn + wirklich kuriert, statt eines Links. --}} + @if (in_array($check->key, $restartable, true) && $canRestart) + + + {{ __('readiness.restart_workers') }} + + @endif + + @unless (in_array($check->key, $restartable, true)) + + {{ __('readiness.fix_link') }} + + + @endunless

@endforeach diff --git a/tests/Feature/Admin/CatalogueSyncTest.php b/tests/Feature/Admin/CatalogueSyncTest.php new file mode 100644 index 0000000..f743097 --- /dev/null +++ b/tests/Feature/Admin/CatalogueSyncTest.php @@ -0,0 +1,108 @@ +set('admin_access.secrets_key', 'base64:'.base64_encode(random_bytes(32))); +}); + +it('queues the sync instead of running it inside the request', function () { + Queue::fake(); + + Livewire::actingAs(operator('Owner'), 'operator') + ->test(Integrations::class) + ->call('syncCatalogue', true) + ->assertHasNoErrors(); + + Queue::assertPushed(RunCatalogueSync::class, fn ($job) => $job->dryRun === true); +}); + +it('confirms the live run in a modal, and only the live run', function () { + // Der Trockenlauf ändert nichts und braucht keine Rückfrage — genau + // deshalb ist er der erste Knopf. Der echte Lauf legt Objekte in einem + // Zahlungskonto an (R23). + Queue::fake(); + + Livewire::actingAs(operator('Owner'), 'operator') + ->test(ConfirmSyncCatalogue::class) + ->call('confirm') + ->assertDispatched('catalogue-sync-confirmed'); +}); + +it('records what the command printed, word for word', function () { + // Der Trockenlauf zählt, was FEHLEN würde. Sein Text ist das, was der + // Betreiber sonst im Terminal gelesen hätte — gekürzt oder umformuliert + // wäre er eine zweite Quelle für dieselbe Aussage. + (new RunCatalogueSync(dryRun: true, by: 'chef@example.test'))->handle(); + + $last = app(CatalogueSyncStatus::class)->last(); + + expect($last)->not->toBeNull() + ->and($last['dry_run'])->toBeTrue() + ->and($last['by'])->toBe('chef@example.test') + // Die Zeilen, die der Betreiber sonst im Terminal gelesen hätte: was + // angelegt WÜRDE, Zeile für Zeile, plus die Summe darunter. + ->and($last['output'])->toContain('would be created') + ->and($last['output'])->toContain('price start v1 monthly'); +}); + +it('shows the stale-catalogue refusal verbatim rather than a summary of it', function () { + // Die teure Meldung: sie warnt davor, einen Katalog zu leeren, an dem + // laufende Verträge hängen. Wer sie zu „Abgleich fehlgeschlagen" + // zusammenfasst, nimmt dem Betreiber genau die Anweisung weg, die er + // braucht — und die Alternative ist, sie doch wieder im Terminal zu suchen. + // Die Migration legt den Grundkatalog selbst an — eine dieser Familien + // trägt jetzt eine ID aus dem anderen Konto. + PlanFamily::query()->firstOrFail()->update(['stripe_product_id' => 'prod_from_the_other_account']); + StripeCatalogueMode::record(OperatingMode::current()->isTest() ? OperatingMode::Live : OperatingMode::Test); + + (new RunCatalogueSync(dryRun: true, by: 'chef@example.test'))->handle(); + + $last = app(CatalogueSyncStatus::class)->last(); + + expect($last['ok'])->toBeFalse() + ->and($last['output'])->toContain('plan_families.stripe_product_id') + ->and($last['output'])->toContain('Nothing was created.'); +}); + +it('puts the last result on the page', function () { + app(CatalogueSyncStatus::class)->record(ok: true, dryRun: true, output: 'MARKER-42 object(s) would be created', by: 'chef@example.test'); + + Livewire::actingAs(operator('Owner'), 'operator') + ->test(Integrations::class) + ->assertSee('MARKER-42 object(s) would be created'); +}); + +it('does not let an operator without the catalogue capability start a run', function () { + // Am Modal geprüft, nicht an der Seite: ein Modal ist OHNE die + // Route-Middleware der Seite erreichbar (siehe EditPlanFamily), also ist + // das hier die Grenze, die wirklich hält. Die Seite selbst weist Support + // schon in mount() ab — das ist der Test darunter. + Queue::fake(); + + Livewire::actingAs(operator('Support'), 'operator') + ->test(ConfirmSyncCatalogue::class) + ->assertForbidden(); + + Queue::assertNothingPushed(); +}); + +it('does not even show the page to an operator who cannot reach it', function () { + Livewire::actingAs(operator('Support'), 'operator') + ->test(Integrations::class) + ->assertForbidden(); +}); diff --git a/tests/Feature/Admin/CodexFixRoundTest.php b/tests/Feature/Admin/CodexFixRoundTest.php new file mode 100644 index 0000000..7c7b6c2 --- /dev/null +++ b/tests/Feature/Admin/CodexFixRoundTest.php @@ -0,0 +1,152 @@ +set('admin_access.secrets_key', 'base64:'.base64_encode(random_bytes(32))); +}); + +// ---- P1 (1): Der Signaturschlüssel darf NICHT über die Modusgrenze fallen ---- + +it('never lets a stored live signing secret stand in for an empty test slot', function () { + // Jeder andere Tresorwert bezeichnet dasselbe Konto in beiden Modi und darf + // deshalb vom Live-Platz einspringen. Dieser nicht: Stripe stellt je Modus + // ein EIGENES Signaturgeheimnis aus. Ohne diese Trennung prüft der + // Testbetrieb seine Ereignisse mit dem Live-Schlüssel — sie scheitern alle, + // und die Konsole meldet den Platz trotzdem als belegt. + config()->set('services.stripe.webhook_secret', ''); + config()->set('services.stripe.webhook_secret_test', ''); + + app(SecretVault::class)->put( + 'stripe.webhook_secret', 'whsec_live_only', Operator::factory()->create(), OperatingMode::Live + ); + + OperatingMode::set(OperatingMode::Test); + + expect(StripeWebhookSecret::current())->toBe('') + ->and(app(SecretVault::class)->source('stripe.webhook_secret'))->toBe('none'); +}); + +it('still uses the test line from the server file when only the live slot is stored', function () { + // Die Sperre gilt dem gespeicherten Platz, nicht der Serverdatei: dort + // steht je Modus eine eigene Zeile, und die ist für diesen Modus richtig. + config()->set('services.stripe.webhook_secret_test', 'whsec_test_env'); + + app(SecretVault::class)->put( + 'stripe.webhook_secret', 'whsec_live_only', Operator::factory()->create(), OperatingMode::Live + ); + + OperatingMode::set(OperatingMode::Test); + + expect(StripeWebhookSecret::current())->toBe('whsec_test_env') + ->and(app(SecretVault::class)->source('stripe.webhook_secret'))->toBe('environment'); +}); + +it('leaves the ordinary entries falling back as they did', function () { + // Die Trennung gilt NUR dem Signaturschlüssel. Ein DNS-Token bezeichnet + // dasselbe Hetzner-Konto in beiden Modi — dort ist der Rückfall gewollt und + // ausdrücklich getestet (SecretVaultModeTest). + app(SecretVault::class)->put( + 'dns.token', 'token_live', Operator::factory()->create(), OperatingMode::Live + ); + + OperatingMode::set(OperatingMode::Test); + + expect(app(SecretVault::class)->get('dns.token'))->toBe('token_live') + ->and(app(SecretVault::class)->source('dns.token'))->toBe('stored_live'); +}); + +// ---- P1 (2): Alte Record-IDs dürfen keinen Host unlöschbar machen ---- + +it('does not make a host unpurgeable because its record id predates the cloud API', function () { + // Mein Fehler im Review: ich hatte auf DIESER Datenbank null Zeilen gezählt + // und daraus geschlossen, es gebe nirgends welche. Der Live-Server ist eine + // andere Installation. Eine Alt-ID muss den Abbau deshalb durchlassen — + // sonst bleibt der Host für immer im Pool, weil PurgeHost bei jedem Versuch + // an derselben Zeile stirbt. + Settings::set('provisioning.dns_zone', 'probe.example'); + Http::preventStrayRequests(); + Http::fake(); + + expect(fn () => (new HttpHetznerDnsClient)->deleteRecord('rec-123'))->not->toThrow(Throwable::class); + + // Und zwar OHNE einen Aufruf zu bauen, der irgendetwas anderes trifft. + Http::assertNothingSent(); +}); + +it('says the leftover record out loud instead of swallowing it', function () { + // Durchlassen heißt nicht verschweigen: der Eintrag bleibt in der Zone + // stehen, und die einzige Stelle, an der das noch auffallen kann, ist das + // Log. Ein stiller Rücksprung wäre die Attrappe, vor der R19 warnt. + Settings::set('provisioning.dns_zone', 'probe.example'); + Http::fake(); + + Log::shouldReceive('warning') + ->once() + ->withArgs(fn (string $message, array $context = []) => str_contains($message, 'legacy') + && ($context['record_id'] ?? null) === 'rec-123'); + + (new HttpHetznerDnsClient)->deleteRecord('rec-123'); +}); + +it('migrates the instance records it can address to the new format', function () { + // `dns_records` trägt fqdn UND typ neben der ID — daraus lässt sich der + // RRSet-Name exakt bilden. Für Hosts geht das nicht (dort steht nur der + // Kurzname), die bleiben der toleranten Löschung oben überlassen. + Settings::set('provisioning.dns_zone', 'probe.example'); + + $instance = Instance::factory()->create(); + $legacy = DnsRecord::query()->create([ + 'instance_id' => $instance->id, + 'provider' => 'hetzner', + 'record_id' => 'rec-legacy-42', + 'fqdn' => 'berger.probe.example', + 'type' => 'A', + 'value' => '203.0.113.9', + ]); + + app(LegacyRecordIds::class)->migrate(); + + expect($legacy->fresh()->record_id)->toBe('berger/A'); +}); + +it('leaves a record it cannot address alone rather than guessing', function () { + Settings::set('provisioning.dns_zone', 'probe.example'); + + $instance = Instance::factory()->create(); + $foreign = DnsRecord::query()->create([ + 'instance_id' => $instance->id, + 'provider' => 'hetzner', + 'record_id' => 'rec-foreign', + 'fqdn' => 'irgendwo.fremde-zone.test', + 'type' => 'A', + 'value' => '203.0.113.9', + ]); + + app(LegacyRecordIds::class)->migrate(); + + expect($foreign->fresh()->record_id)->toBe('rec-foreign'); +}); + +it('counts as done for a host whose legacy record could not be removed', function () { + Settings::set('provisioning.dns_zone', 'probe.example'); + $s = fakeServices(); + $host = Host::factory()->active()->create(['dns_record_id' => 'rec-legacy-99']); + + // Der Fake spricht dieselbe Sprache wie der echte Client — auch hier. + $s['dns']->deleteRecord($host->dns_record_id); +})->throwsNoExceptions(); diff --git a/tests/Feature/Admin/MailDeliverySwitchTest.php b/tests/Feature/Admin/MailDeliverySwitchTest.php new file mode 100644 index 0000000..3a5466d --- /dev/null +++ b/tests/Feature/Admin/MailDeliverySwitchTest.php @@ -0,0 +1,93 @@ +set('mail.default', 'log'); + Settings::set('mail.transport', 'smtp'); + + expect(MailDelivery::transport())->toBe('smtp') + ->and(MailDelivery::delivers())->toBeTrue(); +}); + +it('falls back to the server file when nothing is stored', function () { + // Eine frische Installation hat die Zeile in der .env und keine + // Einstellung — sie darf dadurch nicht anders senden als vorher. + config()->set('mail.default', 'log'); + Settings::forget('mail.transport'); + + expect(MailDelivery::transport())->toBe('log') + ->and(MailDelivery::delivers())->toBeFalse(); +}); + +it('lets the console turn delivery on, and the readiness check follows', function () { + config()->set('mail.default', 'log'); + + expect(satisfiedDeliveryCheck())->toBeFalse(); + + Livewire::actingAs(operator('Owner'), 'operator') + ->test(MailPage::class) + ->set('confirmablePassword', 'password') + ->call('confirmPassword') + ->set('deliver', true) + ->call('saveServer') + ->assertHasNoErrors(); + + expect(Settings::get('mail.transport'))->toBe('smtp') + ->and(satisfiedDeliveryCheck())->toBeTrue(); +}); + +it('lets the console turn delivery off again, and says so', function () { + Settings::set('mail.transport', 'smtp'); + + Livewire::actingAs(operator('Owner'), 'operator') + ->test(MailPage::class) + ->set('confirmablePassword', 'password') + ->call('confirmPassword') + ->set('deliver', false) + ->call('saveServer') + ->assertHasNoErrors(); + + expect(Settings::get('mail.transport'))->toBe('log') + ->and(satisfiedDeliveryCheck())->toBeFalse(); +}); + +it('actually stops the transport from sending when it is switched off', function () { + // Die Zusicherung, die zählt: die Einstellung muss dort ankommen, wo die + // Entscheidung wirklich fällt. Eine Anzeige, die „wird zugestellt" sagt, + // während MailboxTransport weiter ins Log schreibt, wäre genau die grüne + // Anzeige über roter Grundlage, die dieses Projekt schon zweimal hatte. + config()->set('mail.default', 'smtp'); + Settings::set('mail.transport', 'log'); + + expect((string) new MailboxTransport('system'))->toContain('log'); +}); + +function satisfiedDeliveryCheck(): bool +{ + foreach (DeliveryChecks::all() as $check) { + if ($check->key === 'delivery.mailer_not_log') { + return $check->satisfied; + } + } + + throw new RuntimeException('delivery.mailer_not_log check is gone'); +} diff --git a/tests/Feature/Admin/SecretVaultTest.php b/tests/Feature/Admin/SecretVaultTest.php index 90e0bbf..5db1bf6 100644 --- a/tests/Feature/Admin/SecretVaultTest.php +++ b/tests/Feature/Admin/SecretVaultTest.php @@ -136,14 +136,13 @@ it('hands the Hetzner DNS client the stored token rather than the environment on app(SecretVault::class)->put('dns.token', 'dns-token-from-console', Operator::factory()->create()); Http::fake([ - 'dns.hetzner.com/api/v1/zones*' => Http::response(['zones' => [['id' => 'zone1', 'name' => config('provisioning.dns.zone')]]]), - 'dns.hetzner.com/api/v1/records*' => Http::response(['record' => ['id' => 'rec1']]), + 'api.hetzner.cloud/v1/zones/*/rrsets' => Http::response(['rrset' => ['id' => 'fsn-01/A']], 201), ]); app(HttpHetznerDnsClient::class)->upsertRecord('fsn-01.'.config('provisioning.dns.zone'), 'A', '10.0.0.1'); - Http::assertSent(fn ($request) => $request->hasHeader('Auth-API-Token', 'dns-token-from-console')); - Http::assertNotSent(fn ($request) => $request->hasHeader('Auth-API-Token', 'env-token-should-not-be-used')); + Http::assertSent(fn ($request) => $request->hasHeader('Authorization', 'Bearer dns-token-from-console')); + Http::assertNotSent(fn ($request) => $request->hasHeader('Authorization', 'Bearer env-token-should-not-be-used')); }); it('hands the monitoring client the stored token rather than the environment one', function () { diff --git a/tests/Feature/Admin/SshKeypairTest.php b/tests/Feature/Admin/SshKeypairTest.php new file mode 100644 index 0000000..ce6ddc6 --- /dev/null +++ b/tests/Feature/Admin/SshKeypairTest.php @@ -0,0 +1,123 @@ +set('admin_access.secrets_key', 'base64:'.base64_encode(random_bytes(32))); +}); + +it('mints a key the SSH client itself can load', function () { + // Die eigentliche Zusicherung, und der Grund für phpseclib statt eines + // selbstgeschriebenen OpenSSH-Rahmens: PhpseclibRemoteShell::connectWithKey() + // reicht den gespeicherten Text an genau dieses PublicKeyLoader::load() + // weiter. Was diese Prüfung lädt, ist damit dasselbe, was sich später an + // einem Host anmeldet — ein Format, das nur „nach OpenSSH aussieht", fiele + // erst bei der ersten Host-Übernahme auf. + $pair = Keypair::generate(); + + $loaded = PublicKeyLoader::load($pair->privateKey); + + expect($loaded)->toBeInstanceOf(EC\PrivateKey::class) + ->and($loaded->getCurve())->toBe('Ed25519'); +}); + +it('publishes a public half that is one authorized_keys line and belongs to the private half', function () { + // EstablishSshTrust hängt genau diesen String mit `echo … >> authorized_keys` + // an. Ein Zeilenumbruch darin schriebe zwei kaputte Zeilen; ein öffentlicher + // Teil, der nicht zum privaten gehört, ergäbe eine Übernahme, die beim + // Schlüssel-Login scheitert, nachdem das Passwort schon verworfen wurde. + $pair = Keypair::generate(); + + expect($pair->publicKey)->toStartWith('ssh-ed25519 ') + ->and($pair->publicKey)->not->toContain("\n") + ->and(PublicKeyLoader::load($pair->privateKey)->getPublicKey()->toString('OpenSSH', ['comment' => 'clupilot'])) + ->toBe($pair->publicKey); +}); + +it('never mints the same key twice', function () { + expect(Keypair::generate()->privateKey)->not->toBe(Keypair::generate()->privateKey); +}); + +it('stores the private half in the vault and publishes only the public half', function () { + $owner = operator('Owner'); + + $page = Livewire::actingAs($owner, 'operator') + ->test(Integrations::class) + ->set('confirmablePassword', 'password') + ->call('confirmPassword') + ->call('generateSshKey') + ->assertHasNoErrors(); + + $private = (string) app(SecretVault::class)->get('ssh.private_key'); + $public = ProvisioningSettings::sshPublicKey(); + + expect($private)->toContain('BEGIN OPENSSH PRIVATE KEY') + ->and($public)->toStartWith('ssh-ed25519 ') + // Das Feld auf der Seite zeigt den öffentlichen Teil, damit der + // Betreiber ihn ohne Umweg auf einen Host legen kann. + ->and($page->get('sshPublicKey'))->toBe($public) + ->and(PublicKeyLoader::load($private)->getPublicKey()->toString('OpenSSH', ['comment' => 'clupilot'])) + ->toBe($public); +}); + +it('never lets the generated private half reach the page', function () { + // Derselbe Maßstab wie für jeden anderen Tresorwert: gespeichert wird er, + // angezeigt nie. Ein erzeugter Schlüssel ist dabei der heikelste Fall — er + // entsteht in derselben Anfrage, die die Antwort schreibt. + $owner = operator('Owner'); + + $page = Livewire::actingAs($owner, 'operator') + ->test(Integrations::class) + ->set('confirmablePassword', 'password') + ->call('confirmPassword') + ->call('generateSshKey'); + + $private = (string) app(SecretVault::class)->get('ssh.private_key'); + $body = json_encode($page->snapshot).$page->html(); + + expect($body)->not->toContain('BEGIN OPENSSH PRIVATE KEY'); + + foreach (array_slice(explode("\n", trim($private)), 1, -1) as $zeile) { + expect($body)->not->toContain(trim($zeile)); + } +}); + +it('refuses to generate without a confirmed password', function () { + // Dieselbe Sperre wie Speichern und Vergessen: das Erzeugen ÜBERSCHREIBT + // den Schlüssel, mit dem diese Anwendung jeden Host erreicht. + Livewire::actingAs(operator('Owner'), 'operator') + ->test(Integrations::class) + ->call('generateSshKey') + ->assertForbidden(); +}); + +it('refuses to generate without the capability', function () { + Livewire::actingAs(operator('Admin'), 'operator') + ->test(Integrations::class) + ->call('generateSshKey') + ->assertForbidden(); +}); + +it('confirms in a modal before replacing the identity every host trusts', function () { + // R23, und hier mit echten Folgen: der bisherige öffentliche Teil bleibt in + // den authorized_keys jedes übernommenen Hosts stehen, der neue steht dort + // noch nicht. Zwischen Erzeugen und Ausrollen erreicht die Konsole keine + // einzige Maschine mehr. + Livewire::actingAs(operator('Owner'), 'operator') + ->test(ConfirmGenerateSshKey::class) + ->call('confirm') + ->assertDispatched('ssh-key-generate-confirmed'); +}); diff --git a/tests/Feature/Admin/StripePublishableKeyTest.php b/tests/Feature/Admin/StripePublishableKeyTest.php new file mode 100644 index 0000000..e3fd66f --- /dev/null +++ b/tests/Feature/Admin/StripePublishableKeyTest.php @@ -0,0 +1,94 @@ +toBe('pk_live_xyz'); + + OperatingMode::set(OperatingMode::Test); + expect(StripePublishableKey::current())->toBe('pk_test_abc'); +}); + +it('falls back to the server file, per mode', function () { + config()->set('services.stripe.key', 'pk_live_from_env'); + config()->set('services.stripe.key_test', 'pk_test_from_env'); + + OperatingMode::set(OperatingMode::Test); + expect(StripePublishableKey::current())->toBe('pk_test_from_env'); + + OperatingMode::set(OperatingMode::Live); + expect(StripePublishableKey::current())->toBe('pk_live_from_env'); +}); + +it('recognises which account a publishable key belongs to', function () { + // Damit das Feld nicht bloß ein Ablageplatz ist: derselbe Präfix-Blick, den + // der Secret Key längst bekommt. Ein pk_live im Testplatz ist genau die + // Verwechslung, die beim Einrichten passiert. + expect(OperatingMode::ofStripeKey('pk_test_abc'))->toBe(OperatingMode::Test) + ->and(OperatingMode::ofStripeKey('pk_live_abc'))->toBe(OperatingMode::Live) + ->and(OperatingMode::ofStripeKey('irgendwas'))->toBeNull(); +}); + +it('saves it from the console and shows it in full', function () { + config()->set('admin_access.secrets_key', 'base64:'.base64_encode(random_bytes(32))); + OperatingMode::set(OperatingMode::Test); + + $page = Livewire::actingAs(operator('Owner'), 'operator') + ->test(Integrations::class) + ->set('confirmablePassword', 'password') + ->call('confirmPassword') + ->set('stripePublishableKey', 'pk_test_visible123') + ->call('savePublishableKey') + ->assertHasNoErrors(); + + expect(StripePublishableKey::current())->toBe('pk_test_visible123'); + + // Im Klartext, anders als die zwei Geheimnisse daneben — er ist + // öffentlich. Am Snapshot geprüft, nicht am HTML: `x-ui.input` rendert + // serverseitig kein `value`, Livewire hydriert das Feld aus genau diesem + // Snapshot. Ein Tresorwert steht dort NIE (siehe IntegrationsPageTest, + // „never puts a stored secret into the component snapshot") — dieser + // schon, und das ist der Unterschied, um den es hier geht. + expect(json_encode($page->snapshot))->toContain('pk_test_visible123'); +}); + +it('says when the pasted key belongs to the other account', function () { + config()->set('admin_access.secrets_key', 'base64:'.base64_encode(random_bytes(32))); + OperatingMode::set(OperatingMode::Test); + StripePublishableKey::set('pk_live_wrong_slot'); + + Livewire::actingAs(operator('Owner'), 'operator') + ->test(Integrations::class) + ->set('confirmablePassword', 'password') + ->call('confirmPassword') + ->assertSee(__('secrets.publishable_wrong_mode', [ + 'mode' => __('readiness.mode.live'), + ])); +}); diff --git a/tests/Feature/Admin/StripeWebhookSecretInVaultTest.php b/tests/Feature/Admin/StripeWebhookSecretInVaultTest.php new file mode 100644 index 0000000..be24002 --- /dev/null +++ b/tests/Feature/Admin/StripeWebhookSecretInVaultTest.php @@ -0,0 +1,104 @@ +set('admin_access.secrets_key', 'base64:'.base64_encode(random_bytes(32))); +}); + +it('prefers the stored signing secret over the server file', function () { + config()->set('services.stripe.webhook_secret_test', 'whsec_from_env'); + OperatingMode::set(OperatingMode::Test); + + app(SecretVault::class)->put('stripe.webhook_secret', 'whsec_from_console', Operator::factory()->create()); + + expect(StripeWebhookSecret::current())->toBe('whsec_from_console'); +}); + +it('keeps the two modes apart, stored just as in the server file', function () { + // Der Grund, warum es überhaupt zwei Werte gibt: Stripe stellt je + // Betriebsmodus ein eigenes Signaturgeheimnis aus. Ein Platz, der auf den + // anderen zurückfällt, würde ein Testereignis mit dem Live-Schlüssel + // prüfen — es scheitert, aber die Konsole sagt „gesetzt". + $by = Operator::factory()->create(); + $vault = app(SecretVault::class); + + $vault->put('stripe.webhook_secret', 'whsec_test_slot', $by, OperatingMode::Test); + $vault->put('stripe.webhook_secret', 'whsec_live_slot', $by, OperatingMode::Live); + + OperatingMode::set(OperatingMode::Test); + expect(StripeWebhookSecret::current())->toBe('whsec_test_slot'); + + OperatingMode::set(OperatingMode::Live); + expect(StripeWebhookSecret::current())->toBe('whsec_live_slot'); +}); + +it('still reads the server file when nothing is stored, per mode', function () { + // Eine bestehende Installation hat beide Zeilen in der .env und darf sich + // durch diesen Umzug nicht ändern — sonst weist sie ab morgen jedes + // eingehende Zahlungsereignis ab. + config()->set('services.stripe.webhook_secret', 'whsec_live_env'); + config()->set('services.stripe.webhook_secret_test', 'whsec_test_env'); + + OperatingMode::set(OperatingMode::Test); + expect(StripeWebhookSecret::current())->toBe('whsec_test_env'); + + OperatingMode::set(OperatingMode::Live); + expect(StripeWebhookSecret::current())->toBe('whsec_live_env'); +}); + +it('does not call an unset slot "from the server file" when the server file is empty for that mode', function () { + // Die Herkunftsplakette auf der Karte muss zum Wert gehören, der WIRKLICH + // gilt — genau der Fehler, den source() für dns.token schon einmal hatte. + config()->set('services.stripe.webhook_secret', 'whsec_live_env'); + config()->set('services.stripe.webhook_secret_test', ''); + OperatingMode::set(OperatingMode::Test); + + expect(app(SecretVault::class)->source('stripe.webhook_secret'))->toBe('none') + ->and(StripeWebhookSecret::current())->toBe(''); +}); + +it('reports the source as the server file when that mode does have a line', function () { + config()->set('services.stripe.webhook_secret_test', 'whsec_test_env'); + OperatingMode::set(OperatingMode::Test); + + expect(app(SecretVault::class)->source('stripe.webhook_secret'))->toBe('environment'); +}); + +it('offers the signing secret as a field on the payments card', function () { + Livewire::actingAs(operator('Owner'), 'operator') + ->test(Integrations::class) + ->set('confirmablePassword', 'password') + ->call('confirmPassword') + ->assertSee('STRIPE_WEBHOOK_SECRET'); +}); + +it('sends the readiness check to that card instead of the raw .env editor', function () { + foreach (BillingChecks::all() as $check) { + if ($check->key === 'billing.stripe_webhook_secret') { + expect($check->tab)->toBe('services'); + + return; + } + } + + throw new RuntimeException('billing.stripe_webhook_secret check is gone'); +}); diff --git a/tests/Feature/Billing/WithdrawalTest.php b/tests/Feature/Billing/WithdrawalTest.php index 324b04a..516cd98 100644 --- a/tests/Feature/Billing/WithdrawalTest.php +++ b/tests/Feature/Billing/WithdrawalTest.php @@ -123,7 +123,9 @@ function withdrawableContract(array $customerState = []): array DnsRecord::query()->create([ 'instance_id' => $instance->id, 'provider' => 'hetzner', - 'record_id' => 'rec_withdraw', + // Ein RRSet wird über {name}/{typ} adressiert; eine erfundene Record-ID + // aus der alten API würde beim Abbau werfen, statt aufzuräumen. + 'record_id' => $instance->subdomain.'/A', 'fqdn' => $instance->subdomain.'.clupilot.cloud', 'type' => 'A', 'value' => '203.0.113.9', diff --git a/tests/Feature/Provisioning/HetznerCloudDnsTest.php b/tests/Feature/Provisioning/HetznerCloudDnsTest.php new file mode 100644 index 0000000..9e210d9 --- /dev/null +++ b/tests/Feature/Provisioning/HetznerCloudDnsTest.php @@ -0,0 +1,174 @@ +set('provisioning.dns.token', 'cloud-token'); + Http::preventStrayRequests(); +}); + +it('creates an A rrset through the cloud API and returns its {name}/{type} id', function () { + Http::fake([ + 'api.hetzner.cloud/v1/zones/probe.example/rrsets' => Http::response([ + 'rrset' => ['id' => 'berger/A', 'name' => 'berger', 'type' => 'A'], + ], 201), + ]); + + $id = (new HttpHetznerDnsClient)->upsertRecord('berger.probe.example', 'A', '203.0.113.9'); + + // Der zurückgegebene Wert ist genau das, was deleteRecord() später wieder + // auseinandernehmen muss — und genau das, was die API als `rrset.id` + // ausliefert. Eine Zusammensetzung aus Name und Typ, dokumentiert in + // cloud.spec.json und am Live-Konto nachgemessen. + expect($id)->toBe('berger/A'); + + Http::assertSent(function ($request) { + return $request->method() === 'POST' + && $request->url() === 'https://api.hetzner.cloud/v1/zones/probe.example/rrsets' + // Bearer, nicht mehr `Auth-API-Token` — der alte Kopf wird von der + // Cloud-API schlicht ignoriert und der Aufruf als unangemeldet + // abgewiesen. + && $request->hasHeader('Authorization', 'Bearer cloud-token') + && ! $request->hasHeader('Auth-API-Token') + && $request['type'] === 'A' + && $request['records'] === [['value' => '203.0.113.9']]; + }); +}); + +it('sends the name without the zone suffix', function () { + // Falle 1, und sie ist still: die API weist einen Namen, der auf den + // Zonennamen endet, NICHT zurück. Am Live-Konto gemessen — `POST` mit + // `name: probe.probe.example` kam mit 201 zurück und hätte + // `probe.probe.example.probe.example` in die Zone gelegt. Kein Fehler, + // keine Meldung, nur eine Adresse, die niemand auflöst. Diese Zusicherung + // ist das Einzige, was den Fall überhaupt bemerkt. + Http::fake(['api.hetzner.cloud/*' => Http::response(['rrset' => ['id' => 'berger/A']], 201)]); + + (new HttpHetznerDnsClient)->upsertRecord('berger.probe.example', 'A', '203.0.113.9'); + + Http::assertSent(fn ($request) => $request['name'] === 'berger'); +}); + +it('names the zone apex @ and lower-cases what it sends', function () { + Http::fake(['api.hetzner.cloud/*' => Http::response(['rrset' => ['id' => '@/A']], 201)]); + + $client = new HttpHetznerDnsClient; + + expect($client->upsertRecord('probe.example', 'A', '203.0.113.9'))->toBe('@/A'); + Http::assertSent(fn ($request) => $request['name'] === '@'); + + $client->upsertRecord('BERGER.Probe.Example', 'A', '203.0.113.9'); + Http::assertSent(fn ($request) => $request['name'] === 'berger'); +}); + +it('replaces the records of an rrset that already exists', function () { + // Am Live-Konto gemessen: ein zweites POST auf einen vorhandenen RRSet + // antwortet mit 409 `uniqueness_error`. Das Ersetzen ist eine eigene + // Aktion — und mit ihr wird das Schreiben idempotent, was der alte Client + // mit Suchen-und-Aktualisieren über hundert Seiten nachbauen musste. + Http::fake([ + 'api.hetzner.cloud/v1/zones/probe.example/rrsets' => Http::response([ + 'error' => ['code' => 'uniqueness_error', 'message' => 'RRSet(s) already exist(s)'], + ], 409), + 'api.hetzner.cloud/v1/zones/probe.example/rrsets/berger/A/actions/set_records' => Http::response([ + 'rrset' => ['id' => 'berger/A'], + ], 201), + ]); + + $id = (new HttpHetznerDnsClient)->upsertRecord('berger.probe.example', 'A', '203.0.113.10'); + + expect($id)->toBe('berger/A'); + + Http::assertSent(fn ($request) => $request->method() === 'POST' + && $request->url() === 'https://api.hetzner.cloud/v1/zones/probe.example/rrsets/berger/A/actions/set_records' + && $request['records'] === [['value' => '203.0.113.10']]); +}); + +it('refuses an fqdn that does not sit in the configured zone', function () { + // Die Kehrseite derselben stillen Annahme: `fsn-01.clupilot.com` in der + // Zone `probe.example` ist kein Tippfehler, den die API abfängt — sie legt + // `fsn-01.clupilot.com.probe.example` an und meldet Erfolg. Was hier nicht + // hineingehört, wird abgelehnt, statt veröffentlicht zu werden. + Http::fake(); + + expect(fn () => (new HttpHetznerDnsClient)->upsertRecord('fsn-01.clupilot.com', 'A', '203.0.113.9')) + ->toThrow(RuntimeException::class); + + Http::assertNothingSent(); +}); + +it('deletes an rrset by its name and type', function () { + Http::fake(['api.hetzner.cloud/*' => Http::response([], 201)]); + + (new HttpHetznerDnsClient)->deleteRecord('berger/A'); + + Http::assertSent(fn ($request) => $request->method() === 'DELETE' + && $request->url() === 'https://api.hetzner.cloud/v1/zones/probe.example/rrsets/berger/A'); +}); + +it('treats an already-deleted rrset as done', function () { + // Ein verlorener Rücklauf oder ein Wiederholungslauf nach einem späteren + // Fehlschlag darf einen Eintrag nicht unaufräumbar machen — und seinen + // Host nicht unlöschbar. Am Live-Konto gemessen: 404 `not_found`. + Http::fake([ + 'api.hetzner.cloud/*' => Http::response([ + 'error' => ['code' => 'not_found', 'message' => 'RRSet(s) not found'], + ], 404), + ]); + + (new HttpHetznerDnsClient)->deleteRecord('berger/A'); +})->throwsNoExceptions(); + +it('hands back the same id from the fake as from the real client', function () { + // Sonst beweist jeder Test, der über FakeHetznerDnsClient läuft, das + // Gegenteil von dem, was er behauptet: `rec-1` wandert in + // `hosts.dns_record_id`, PurgeHost und clupilot:prune-host-dns räumen damit + // im Test sauber auf — und im Betrieb steht dort `{name}/{typ}` und der + // echte Client wirft. Ein Fake, der eine andere Sprache spricht als das + // Original, ist eine grüne Anzeige über einer roten Grundlage. + Http::fake(['api.hetzner.cloud/*' => Http::response(['rrset' => ['id' => 'berger/A']], 201)]); + + $echt = (new HttpHetznerDnsClient)->upsertRecord('berger.probe.example', 'A', '203.0.113.9'); + $fake = (new FakeHetznerDnsClient)->upsertRecord('berger.probe.example', 'A', '203.0.113.9'); + + expect($fake)->toBe($echt); +}); + +it('refuses in the fake the same out-of-zone fqdn the real client refuses', function () { + expect(fn () => (new FakeHetznerDnsClient)->upsertRecord('fsn-01.clupilot.com', 'A', '203.0.113.9')) + ->toThrow(RuntimeException::class); +}); + +it('lets an id from the old record API pass without building a broken path', function () { + // Alte Record-IDs sind mit der Cloud-Konsole nicht kompatibel. Sie ergäben + // `/rrsets/rec-123/` mit leerem Typ — einen Aufruf, der irgendetwas anderes + // trifft und dabei erfolgreich aussieht. Also wird nichts geschickt. + // + // Geworfen wird aber auch nicht, und das war die Korrektur aus dem + // Codex-Review (P1): PurgeHost ruft deleteRecord(), BEVOR es die Zeile + // löscht, die die ID hält. Ein Wurf hier machte jeden Host mit einer + // Alt-ID dauerhaft unlöschbar — jeder Versuch stirbt an derselben Zeile. + // Der Eintrag bleibt in der Zone stehen; gemeldet wird er im Log + // (siehe CodexFixRoundTest), nicht verschwiegen. + Http::fake(); + + (new HttpHetznerDnsClient)->deleteRecord('rec-123'); + + Http::assertNothingSent(); +}); diff --git a/tests/Feature/Provisioning/HostStepsTest.php b/tests/Feature/Provisioning/HostStepsTest.php index 812fdbc..8facac7 100644 --- a/tests/Feature/Provisioning/HostStepsTest.php +++ b/tests/Feature/Provisioning/HostStepsTest.php @@ -1187,17 +1187,17 @@ it('still removes a legacy public Hetzner record when a pre-fix host is purged', // for those, independent of clupilot:prune-host-dns, which only handles // hosts that are NOT being deleted. $s = fakeServices(); - $recordId = $s['dns']->upsertRecord('fsn-legacy.node.clupilot.com', 'A', '10.66.0.55'); + $recordId = $s['dns']->upsertRecord('fsn-legacy.node.clupilot.cloud', 'A', '10.66.0.55'); $host = Host::factory()->active()->create([ 'datacenter' => 'fsn', 'wg_ip' => '10.66.0.55', 'dns_name' => 'fsn-legacy', 'dns_record_id' => $recordId, ]); - expect($s['dns']->records)->toHaveKey('fsn-legacy.node.clupilot.com'); + expect($s['dns']->records)->toHaveKey('fsn-legacy.node.clupilot.cloud'); (new PurgeHost($host->uuid))->handle(); - expect($s['dns']->records)->not->toHaveKey('fsn-legacy.node.clupilot.com'); + expect($s['dns']->records)->not->toHaveKey('fsn-legacy.node.clupilot.cloud'); }); it('builds a valid DNS label even from an awkward datacenter code', function () { diff --git a/tests/Feature/Provisioning/PruneHostDnsTest.php b/tests/Feature/Provisioning/PruneHostDnsTest.php index 8d1238d..b8b85e0 100644 --- a/tests/Feature/Provisioning/PruneHostDnsTest.php +++ b/tests/Feature/Provisioning/PruneHostDnsTest.php @@ -2,6 +2,11 @@ use App\Models\Host; +// Der Altbestand lag in der KONFIGURIERTEN Zone (im Test `clupilot.cloud`) — +// etwas anderes konnte der Client nie schreiben, und seit dem Umzug auf die +// Cloud-API weist er einen fremden Namen auch ab, statt ihn stillschweigend an +// die Zone anzuhängen. Vorher stand hier `.com`, ein Wert, den es so nie gab. + it('reports nothing to do when no host carries a public record id', function () { fakeServices(); Host::factory()->active()->create(['dns_record_id' => null]); @@ -13,29 +18,29 @@ it('reports nothing to do when no host carries a public record id', function () it('without --force only reports what it would delete, and changes nothing', function () { $s = fakeServices(); - $recordId = $s['dns']->upsertRecord('fsn-legacy.node.clupilot.com', 'A', '10.66.0.55'); + $recordId = $s['dns']->upsertRecord('fsn-legacy.node.clupilot.cloud', 'A', '10.66.0.55'); $host = Host::factory()->active()->create(['dns_record_id' => $recordId]); $this->artisan('clupilot:prune-host-dns')->assertSuccessful(); expect($host->fresh()->dns_record_id)->toBe($recordId) - ->and($s['dns']->records)->toHaveKey('fsn-legacy.node.clupilot.com'); + ->and($s['dns']->records)->toHaveKey('fsn-legacy.node.clupilot.cloud'); }); it('with --force deletes the record and clears the column', function () { $s = fakeServices(); - $recordId = $s['dns']->upsertRecord('fsn-legacy.node.clupilot.com', 'A', '10.66.0.55'); + $recordId = $s['dns']->upsertRecord('fsn-legacy.node.clupilot.cloud', 'A', '10.66.0.55'); $host = Host::factory()->active()->create(['dns_record_id' => $recordId]); $this->artisan('clupilot:prune-host-dns --force')->assertSuccessful(); expect($host->fresh()->dns_record_id)->toBeNull() - ->and($s['dns']->records)->not->toHaveKey('fsn-legacy.node.clupilot.com'); + ->and($s['dns']->records)->not->toHaveKey('fsn-legacy.node.clupilot.cloud'); }); it('with --force leaves the id in place when the provider refuses the deletion', function () { $s = fakeServices(); - $recordId = $s['dns']->upsertRecord('fsn-legacy.node.clupilot.com', 'A', '10.66.0.55'); + $recordId = $s['dns']->upsertRecord('fsn-legacy.node.clupilot.cloud', 'A', '10.66.0.55'); $host = Host::factory()->active()->create(['dns_record_id' => $recordId]); $s['dns']->failDelete = true; diff --git a/tests/Feature/Provisioning/ServicesTest.php b/tests/Feature/Provisioning/ServicesTest.php index 9890816..3f76334 100644 --- a/tests/Feature/Provisioning/ServicesTest.php +++ b/tests/Feature/Provisioning/ServicesTest.php @@ -9,9 +9,7 @@ use App\Services\Ssh\CommandResult; use App\Services\Ssh\FakeRemoteShell; use App\Services\Wireguard\FakeWireguardHub; use App\Services\Wireguard\LocalWireguardHub; -use App\Support\ProvisioningSettings; use Illuminate\Support\Facades\File; -use Illuminate\Support\Facades\Http; it('scripts remote command output and records calls (FakeRemoteShell)', function () { $shell = new FakeRemoteShell; @@ -110,61 +108,11 @@ it('reports node capacity for a host (FakeProxmoxClient)', function () { ->and($client->nodeStorage('pve')[0]['total'])->toBeGreaterThan(0); }); -it('treats an already-deleted DNS record as done', function () { - Http::fake([ - '*' => Http::response(['error' => 'not found'], 404), - ]); - - // A lost response, or a retry after a later step failed, must not make the - // record impossible to clean up — and its host impossible to purge. - (new HttpHetznerDnsClient)->deleteRecord('rec-123'); -})->throwsNoExceptions(); - -it('finds an existing DNS record past the first page instead of creating a second one', function () { - // Hetzner pages `GET /records` at a hundred entries, and this asked only for - // the first page. Past a hundred records the lookup missed an entry that was - // plainly there, fell through to POST, and Hetzner accepted a SECOND A record - // for the same name: two addresses round-robined for one instance, one of them - // a host the machine is not on, so the cloud was up about half the time. The - // `address` and `plan-change` pipelines upsert on every run, so it compounded. - $filler = fn (int $from) => collect(range($from, $from + 99)) - ->map(fn (int $i) => ['id' => 'rec-'.$i, 'name' => 'other-'.$i, 'type' => 'A']) - ->all(); - - // Matched by reading the page out of the query rather than with a URL glob: - // `page=1` is a substring of `per_page=100`, so a pattern would answer page - // two with page one's records and the test would pass against the bug. - Http::fake(function ($request) use ($filler) { - parse_str((string) parse_url($request->url(), PHP_URL_QUERY), $query); - - if (str_contains($request->url(), '/zones')) { - return Http::response(['zones' => [['id' => 'zone-1']]]); - } - - if ($request->method() === 'GET') { - $page = (int) ($query['page'] ?? 1); - - return Http::response([ - 'records' => $page === 2 - ? array_merge($filler(101), [['id' => 'rec-berger', 'name' => 'berger', 'type' => 'A']]) - : $filler(1), - 'meta' => ['pagination' => ['page' => $page, 'per_page' => 100, 'last_page' => 2]], - ]); - } - - // A POST is the bug: the record exists and has to be updated. - return Http::response(['record' => ['id' => 'rec-new']], 201); - }); - - $id = (new HttpHetznerDnsClient)->upsertRecord( - 'berger.'.ProvisioningSettings::dnsZone(), 'A', '203.0.113.9' - ); - - expect($id)->toBe('rec-berger'); - Http::assertSent(fn ($request) => $request->method() === 'PUT' - && str_contains($request->url(), '/records/rec-berger')); - Http::assertNotSent(fn ($request) => $request->method() === 'POST'); -}); +// Der Hetzner-DNS-Client steht in HetznerCloudDnsTest — seit dem Umzug auf die +// Cloud-API (RRSets statt einzelner Records) gehört dazu mehr als zwei Fälle. +// Die alte Seitenlauf-Falle, die hier stand, kann es nicht mehr geben: es gibt +// keinen Lookup mehr, den sie überspringen könnte. Warum das so ist, steht im +// Kopfkommentar von HttpHetznerDnsClient. it('writes and removes a real hostsdir entry on disk (FileHostDnsDirectory)', function () { $dir = sys_get_temp_dir().'/clupilot-dns-hosts-test-'.uniqid(); diff --git a/tests/Feature/Readiness/ActiveChecksTest.php b/tests/Feature/Readiness/ActiveChecksTest.php index 66565ae..99529f7 100644 --- a/tests/Feature/Readiness/ActiveChecksTest.php +++ b/tests/Feature/Readiness/ActiveChecksTest.php @@ -1,6 +1,8 @@ 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), + 'api.hetzner.cloud/v1/zones' => Http::response(['zones' => [['id' => 42, 'name' => 'probe.example']]]), + 'api.hetzner.cloud/v1/zones/probe.example/rrsets' => Http::response([ + 'rrset' => ['id' => '_clupilot-write-probe-abc/TXT'], + ], 201), + 'api.hetzner.cloud/v1/zones/probe.example/rrsets/*' => Http::response([], 201), ]); $result = (new DnsTokenCheck)->run('rw-token'); @@ -120,18 +124,55 @@ it('confirms write access by writing a probe record and removing it again', func ->and($result['reason'])->toBe('writable') ->and($result['probe_removed'])->toBeTrue(); + // Bearer, nicht mehr `Auth-API-Token`: die Cloud-API ignoriert den alten + // Kopf und weist den Aufruf als unangemeldet ab. Http::assertSent(fn ($request) => $request->method() === 'POST' - && str_contains((string) $request->url(), '/records') - && $request->hasHeader('Auth-API-Token', 'rw-token')); + && str_contains((string) $request->url(), '/zones/probe.example/rrsets') + && $request->hasHeader('Authorization', 'Bearer rw-token')); + + // Gelöscht wird über {name}/{typ} — es gibt keine Record-ID mehr, an der + // man den Probeeintrag festhalten könnte. Und zwar über GENAU den Namen, + // der eben geschrieben wurde: aus der Antwort gegriffen wäre die Prüfung + // blind dafür, dass sie einen fremden Eintrag wegräumt oder ihren eigenen + // stehen lässt. + $geschrieben = Http::recorded(fn ($request) => $request->method() === 'POST')->first()[0]['name']; + + expect($geschrieben)->toStartWith('_clupilot-write-probe-'); + Http::assertSent(fn ($request) => $request->method() === 'DELETE' - && str_contains((string) $request->url(), '/records/rec-probe-1')); + && str_ends_with((string) $request->url(), "/rrsets/{$geschrieben}/TXT")); +}); + +it('escapes the probe TXT value in double quotes', function () { + // Am Live-Konto gemessen, und teuer gewesen wäre es still: ein TXT-Wert + // ohne Anführungszeichen wird mit 422 `invalid_input` abgewiesen + // („TXT records must be fully escaped with double quotes"). Der Token + // wäre dadurch als `read_only` gemeldet worden — eine Aussage über eine + // Berechtigung, hergeleitet aus einem Formatfehler im eigenen Aufruf. + Http::fake([ + 'api.hetzner.cloud/v1/zones' => Http::response(['zones' => [['id' => 42, 'name' => 'probe.example']]]), + 'api.hetzner.cloud/v1/zones/probe.example/rrsets' => Http::response(['rrset' => ['id' => 'x/TXT']], 201), + 'api.hetzner.cloud/v1/zones/probe.example/rrsets/*' => Http::response([], 201), + ]); + + (new DnsTokenCheck)->run('rw-token'); + + Http::assertSent(function ($request) { + if ($request->method() !== 'POST' || ! str_ends_with((string) $request->url(), '/rrsets')) { + return false; + } + + $value = $request['records'][0]['value'] ?? ''; + + return str_starts_with($value, '"') && str_ends_with($value, '"'); + }); }); 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']]]), + 'api.hetzner.cloud/v1/zones' => Http::response(['zones' => [['id' => 9, 'name' => 'someone-elses-zone.example']]]), ]); $result = (new DnsTokenCheck)->run('some-token'); @@ -149,8 +190,10 @@ it('tells a read-only token apart from one that can write', function () { // 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), + 'api.hetzner.cloud/v1/zones' => Http::response(['zones' => [['id' => 42, 'name' => 'probe.example']]]), + 'api.hetzner.cloud/v1/zones/probe.example/rrsets' => Http::response([ + 'error' => ['code' => 'forbidden', 'message' => 'insufficient permissions'], + ], 403), ]); $result = (new DnsTokenCheck)->run('read-only-token'); @@ -163,6 +206,31 @@ it('tells a read-only token apart from one that can write', function () { Http::assertNotSent(fn ($request) => $request->method() === 'DELETE'); }); +it('does not call a rejected write a read-only token', function (int $status, string $code) { + // Dasselbe Muster wie bei der Zonenliste, eine Stufe weiter: nur 401 und + // 403 sagen etwas über die BERECHTIGUNG. Ein 422 sagt, dass der Aufruf + // falsch geformt war, ein 409 dass schon etwas da liegt, ein 503 dass + // Hetzner gerade nicht mag — und keiner davon ist ein Grund, dem Betreiber + // zu raten, einen funktionierenden Token auszutauschen. + Http::fake([ + 'api.hetzner.cloud/v1/zones' => Http::response(['zones' => [['id' => 42, 'name' => 'probe.example']]]), + 'api.hetzner.cloud/v1/zones/probe.example/rrsets' => Http::response([ + 'error' => ['code' => $code, 'message' => 'nope'], + ], $status), + ]); + + $result = (new DnsTokenCheck)->run('rw-token'); + + expect($result['ok'])->toBeFalse() + ->and($result['reason'])->toBe('write_failed') + ->and($result['status'])->toBe($status) + ->and($result['detail'])->toBe('nope'); +})->with([ + [422, 'invalid_input'], + [409, 'uniqueness_error'], + [503, 'unavailable'], +]); + /** * Fix-Runde (Befund 2): Das GET auf /zones ist gegen Throwable abgesichert * (Reason "unreachable"), der Schreibversuch danach war es nicht — ein @@ -174,8 +242,8 @@ it('tells a read-only token apart from one that can write', function () { */ 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 () { + 'api.hetzner.cloud/v1/zones' => Http::response(['zones' => [['id' => 42, 'name' => 'probe.example']]]), + 'api.hetzner.cloud/v1/zones/probe.example/rrsets' => function () { throw new ConnectionException('Could not resolve host'); }, ]); @@ -195,9 +263,11 @@ it('reports unreachable when the write attempt itself cannot reach the network', */ 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 () { + 'api.hetzner.cloud/v1/zones' => Http::response(['zones' => [['id' => 42, 'name' => 'probe.example']]]), + 'api.hetzner.cloud/v1/zones/probe.example/rrsets' => Http::response([ + 'rrset' => ['id' => '_clupilot-write-probe-orphan/TXT'], + ], 201), + 'api.hetzner.cloud/v1/zones/probe.example/rrsets/*' => function () { throw new ConnectionException('Could not resolve host'); }, ]); @@ -207,7 +277,13 @@ it('still proves write access when the cleanup delete cannot reach the network', expect($result['ok'])->toBeTrue() ->and($result['reason'])->toBe('writable') ->and($result['probe_removed'])->toBeFalse() - ->and($result['leftover_record_id'])->toBe('rec-orphaned'); + // Der RRSet-Name statt einer Record-ID: das ist die Adresse, unter der + // jemand den liegengebliebenen Eintrag von Hand wiederfindet. + // Dass sie auf denselben Eintrag zeigt, der geschrieben wurde, hält + // der Erfolgsfall oben fest — hier lässt sich der DELETE nicht + // beobachten, weil eine geworfene Attrappe nicht aufgezeichnet wird. + ->and($result['leftover_record_id'])->toStartWith('_clupilot-write-probe-') + ->and($result['leftover_record_id'])->toEndWith('/TXT'); }); it('does not attempt anything without a token or a zone', function () { @@ -398,9 +474,9 @@ it('is satisfied when no plan version is published yet', function () { * R19 in ihrer teuersten Form. */ it('lets a failed measurement beat a green badge', function () { - $operator = App\Models\Operator::factory()->role('Owner')->create(); + $operator = Operator::factory()->role('Owner')->create(); - $seite = Livewire::actingAs($operator, 'operator')->test(App\Livewire\Admin\Readiness::class); + $seite = Livewire::actingAs($operator, 'operator')->test(Readiness::class); // Ohne Messung entscheidet die passive Prüfung — wie bisher. $seite->assertOk(); @@ -422,8 +498,8 @@ it('lets a failed measurement beat a green badge', function () { it('names the zone it wanted and the zones it found', function () { $ergebnis = ['ok' => false, 'reason' => 'zone_not_found', 'zone' => 'clupilot.cloud', 'available' => ['clupilot.com', 'example.org']]; - Livewire::actingAs(App\Models\Operator::factory()->role('Owner')->create(), 'operator') - ->test(App\Livewire\Admin\Readiness::class) + Livewire::actingAs(Operator::factory()->role('Owner')->create(), 'operator') + ->test(Readiness::class) ->set('results', ['provisioning.dns_token' => $ergebnis]) ->assertSee(__('readiness.zone_wanted', ['zone' => 'clupilot.cloud'])) ->assertSee('clupilot.com, example.org'); @@ -443,9 +519,9 @@ it('does not call a failed zone list an empty account', function (int $status) { // Ein Datensatz statt einer Schleife: `Http::fake()` ERGÄNZT die Attrappen, // es ersetzt sie nicht — in einer Schleife hätte die erste alle folgenden // beantwortet, und der Test hätte dreimal dasselbe geprüft. - Http::fake(['dns.hetzner.com/api/v1/zones' => Http::response('nope', $status)]); + Http::fake(['api.hetzner.cloud/v1/zones' => Http::response('nope', $status)]); - $ergebnis = (new App\Services\Dns\DnsTokenCheck)->run('ein-token'); + $ergebnis = (new DnsTokenCheck)->run('ein-token'); expect($ergebnis['ok'])->toBeFalse() ->and($ergebnis['reason'])->toBe('zone_list_failed') @@ -457,9 +533,9 @@ it('does not call a failed zone list an empty account', function (int $status) { * dass der Token zu einem Projekt ohne Zonen gehört. */ it('still calls a genuinely empty account empty', function () { - Http::fake(['dns.hetzner.com/api/v1/zones' => Http::response(['zones' => []], 200)]); + Http::fake(['api.hetzner.cloud/v1/zones' => Http::response(['zones' => []], 200)]); - $ergebnis = (new App\Services\Dns\DnsTokenCheck)->run('ein-token'); + $ergebnis = (new DnsTokenCheck)->run('ein-token'); expect($ergebnis['reason'])->toBe('zone_not_found') ->and($ergebnis['available'])->toBe([]); @@ -474,9 +550,9 @@ it('still calls a genuinely empty account empty', function () { * die hat kein `zones`, und `?? []` machte daraus null Zonen. */ it('does not read someone else answering as an empty account', function (string $body, int $status) { - Http::fake(['dns.hetzner.com/api/v1/zones' => Http::response($body, $status, ['Content-Type' => 'text/html'])]); + Http::fake(['api.hetzner.cloud/v1/zones' => Http::response($body, $status, ['Content-Type' => 'text/html'])]); - $ergebnis = (new App\Services\Dns\DnsTokenCheck)->run('ein-token'); + $ergebnis = (new DnsTokenCheck)->run('ein-token'); expect($ergebnis['reason'])->toBe('zone_list_unreadable') ->and($ergebnis)->not->toHaveKey('available'); diff --git a/tests/Feature/Readiness/RestartWorkersTest.php b/tests/Feature/Readiness/RestartWorkersTest.php new file mode 100644 index 0000000..86b43ad --- /dev/null +++ b/tests/Feature/Readiness/RestartWorkersTest.php @@ -0,0 +1,75 @@ + now()->toIso8601String()]) + ); +} + +/** + * Die zwei Betriebspunkte sind keine Einstellungen: „läuft der Zeitplaner", + * „läuft der Bereitstellungs-Worker". Zu stellen gibt es nichts — die Kur ist + * ein Neustart, und den konnte die Konsole längst anfordern, nur nicht von + * dieser Seite aus. Der „Beheben"-Link zeigte auf den Roheditor der .env, wo + * nichts steht, das hilft. + */ +beforeEach(function () { + // Dieselbe geteilte storage/app/deploy-Ablage wie UpdateButtonTest und + // IntegrationsPageTest: Pest fährt alle Dateien in EINEM Prozess, ein + // Rest aus dem zuvor gelaufenen entschiede sonst, ob der Agent hier als + // lebendig gilt. + File::deleteDirectory(storage_path('app/deploy')); +}); + +it('asks the agent to restart the workers', function () { + // Der Agent muss sich gemeldet haben, sonst nimmt der Kanal nichts an. + agentIsAlive(); + + Livewire::actingAs(operator('Owner'), 'operator') + ->test(Readiness::class) + ->call('restartWorkers') + ->assertHasNoErrors(); + + expect(app(UpdateChannel::class)->state()['requested_at'])->not->toBeNull(); +}); + +it('says so honestly when no agent is there to hear it', function () { + // Kein Herzschlag: die Anfrage landet in einer Datei, die niemand liest. + // Ein „wird neu gestartet" wäre hier genau die zuversichtliche Anzeige + // über der roten Grundlage, die dieses Projekt wiederholt gekostet hat. + Livewire::actingAs(operator('Owner'), 'operator') + ->test(Readiness::class) + ->call('restartWorkers') + ->assertDispatched('notify', message: __('readiness.restart_no_agent')); + + expect(app(UpdateChannel::class)->state()['requested_at'])->toBeNull(); +}); + +it('does not let an operator without the capability reach the page at all', function () { + // `Support` hat weder hosts.manage noch secrets.manage und wird schon in + // mount() abgewiesen — die Aktion selbst prüft hosts.manage noch einmal + // (siehe restartWorkers), weil eine Berechtigung, die nur die Seite + // bewacht, keine ist. + agentIsAlive(); + + Livewire::actingAs(operator('Support'), 'operator') + ->test(Readiness::class) + ->assertForbidden(); + + expect(app(UpdateChannel::class)->state()['requested_at'])->toBeNull(); +}); + +it('offers the button on the two heartbeat entries', function () { + Livewire::actingAs(operator('Owner'), 'operator') + ->test(Readiness::class) + ->assertSee(__('readiness.restart_workers')); +}); diff --git a/tests/Feature/ReadinessPageTest.php b/tests/Feature/ReadinessPageTest.php index 32a4be5..76d62d1 100644 --- a/tests/Feature/ReadinessPageTest.php +++ b/tests/Feature/ReadinessPageTest.php @@ -121,7 +121,7 @@ it('covers every entry in the secret registry', function () { it('sends every check to the tab or page where its field actually lives', function () { $expectedTab = [ 'billing.stripe_secret' => 'services', - 'billing.webhook_secret' => 'env', + 'billing.stripe_webhook_secret' => 'services', 'billing.company_details' => 'company', 'billing.invoice_series' => 'company', 'billing.catalogue_synced' => 'services',