From 507636f38feb3c21a08fe7f2eee2da9457512da3 Mon Sep 17 00:00:00 2001 From: nexxo Date: Tue, 4 Aug 2026 14:50:46 +0200 Subject: [PATCH] Die abgebuchte Domain wird jetzt wirklich von der Maschine genommen MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Der Registereintrag nannte den falschen Grund: das Deaktivieren startet sehr wohl eine Provisionierung. CustomDomainAccess::deactivate() ruft seit Langem ReapplyInstanceAddress, das legt einen Lauf der `address`-Pipeline an und schickt AdvanceRunJob auf die provisioning-Warteschlange; ConfigureNextcloud loescht dort trusted_domains 2 und ConfigureDnsAndTls schreibt den Router ohne den Namen neu. Das ist gebaut und geprueft. Der Schaden war trotzdem echt, nur eine Tuer weiter. Erreicht wurde deactivate() allein ueber PlanChange::settleCustomDomain, also ueber den Paketwechsel. Der zweite und haeufigere Weg, auf dem das Recht endet — der Kunde bucht das Modul in der Abrechnung ab, clupilot:end-cancelled-addons haelt den Termin am Ende des bezahlten Zeitraums — ging an dieser Stelle vorbei: BookAddon::cancel() lieferte Speicher nach und sprach mit Stripe, fragte aber niemanden nach der Adresse. Die Domain verschwand aus jeder Ansicht und blieb auf der Maschine stehen. BookAddon::cancel() fragt jetzt CustomDomainAccess::enforce() — die ganze Regel, nicht den Modulschluessel: wer von Team auf Business aufgestuft hat und sein altes Modul loswird, behaelt die Domain, weil das Paket sie selbst traegt. Und der Anstoss darf die Entscheidung nicht kippen. deactivate() faengt jetzt einen Fehlschlag der Nachfuehrung ab und schreibt ihn als Fehler ins Log: die Wahrheit steht in der Datenbank, die Maschine zieht nach, und eine Kuendigung haengt nicht daran, ob ein fremder Host gerade antwortet. Die Gegenrichtung brauchte nichts: der Entzug loescht die Domain-Spalte, also traegt der Kunde sie nach der Neubuchung neu ein und weist sie neu nach — und genau dort haengt seit jeher der Lauf, der sie wieder ausliefert. Ein Test haelt das fest, damit es keine Einbahnstrasse wird. Registereintrag gestrichen. Rot gesehen: ohne den settleCustomDomain-Aufruf fallen drei der vier neuen Tests; ohne das try/catch faellt der vierte. Co-Authored-By: Claude Opus 5 --- app/Actions/BookAddon.php | 49 +++++- app/Services/Billing/CustomDomainAccess.php | 49 +++++- app/Support/OpenWork.php | 7 - tests/Feature/CustomDomainServingTest.php | 169 +++++++++++++++++++- 4 files changed, 259 insertions(+), 15 deletions(-) diff --git a/app/Actions/BookAddon.php b/app/Actions/BookAddon.php index e9f1093..1e8bee9 100644 --- a/app/Actions/BookAddon.php +++ b/app/Actions/BookAddon.php @@ -385,11 +385,56 @@ class BookAddon }); $this->deliverStorage($cancelled->subscription, (string) $cancelled->addon_key); + $this->settleCustomDomain($cancelled->subscription, (string) $cancelled->addon_key); $this->billThroughStripe($cancelled->subscription); return $cancelled; } + /** + * Eine eigene Domain, die niemand mehr bezahlt, wirklich von der Maschine + * nehmen. + * + * Das Gegenstück zu deliverStorage(): dort wird aus einem Paket + * Plattenplatz, hier hört eine Adresse auf, eine zu sein. Solange nur die + * Zeile in `subscription_addons` gekündigt war, nannte Traefik den Namen + * weiter und Nextcloud vertraute ihm weiter — der Kunde zahlte nicht mehr + * und bekam die Leistung trotzdem, und legte er die Domain später woanders + * hin, zeigte unser Host weiter darauf. + * + * Der Paketwechsel hatte diesen Griff längst (PlanChange::settleCustomDomain + * über CustomDomainAccess::enforce()). Die Kündigung des Moduls selbst hatte + * ihn nicht — und das ist der zweite, häufigere Weg, auf dem das Recht auf + * eine eigene Domain verfällt: der Kunde bucht sie in der Abrechnung ab, + * clupilot:end-cancelled-addons hält den Termin am Ende des bezahlten + * Zeitraums, und danach fragte niemand mehr nach der Adresse. + * + * Gefragt wird hier nicht „war das die Domain?", sondern die ganze Regel: + * enforce() lässt alles stehen, solange der Vertrag die Domain nach dieser + * Kündigung immer noch trägt. Ein Kunde, der von Team auf Business + * aufgestuft hat und sein altes Modul loswird, verliert nichts, weil das + * Paket sie jetzt selbst enthält — ein blindes Abräumen an dieser Stelle + * würde ihm die Adresse nehmen, für die er weiter bezahlt. + * + * enforce() kündigt seinerseits noch laufende Buchungen desselben Moduls + * durch genau diese Aktion. Das ist nur erreichbar, wenn ein Vertrag mehr + * als eine hält (was die Buchungssperre verhindert), und jede Runde kündigt + * eine davon — es endet also, und zwar nach der ersten. + * + * Nach der Transaktion und aus demselben Grund wie die beiden Nachbarn: den + * Lauf, den enforce() anstösst, nimmt ein Arbeiter in einem anderen Prozess + * auf, und einer, der zuerst da wäre, läse die Buchung so, wie sie einen + * Moment vorher war. + */ + private function settleCustomDomain(?Subscription $subscription, string $addonKey): void + { + if ($addonKey !== CustomDomainAccess::ADDON || $subscription === null) { + return; + } + + app(CustomDomainAccess::class)->enforce($subscription->refresh()); + } + /** * Make a change to the storage packs real on the machine. * @@ -397,7 +442,9 @@ class BookAddon * BE: a pack is a hundred gigabytes the disk, the guest's filesystem and * Nextcloud's quota all have to grow to, and cancelling one is the same * sentence read backwards. Every other module is a licence, a support tier - * or a flag — nothing on the machine moves when one is booked. + * or a flag — nothing on the machine moves when one is booked. Die eigene + * Domain ist die eine Ausnahme in der Gegenrichtung: gebucht ändert sie + * nichts, gekündigt schon — siehe settleCustomDomain() unten. * * Deliberately AFTER the transaction has committed, never inside it. The run * this starts is picked up by a queue worker in another process, and a diff --git a/app/Services/Billing/CustomDomainAccess.php b/app/Services/Billing/CustomDomainAccess.php index 8ce612f..b22062d 100644 --- a/app/Services/Billing/CustomDomainAccess.php +++ b/app/Services/Billing/CustomDomainAccess.php @@ -9,7 +9,9 @@ use App\Models\Instance; use App\Models\PlanFamily; use App\Models\Subscription; use App\Support\ProvisioningSettings; +use Illuminate\Support\Facades\Log; use Illuminate\Support\Number; +use Throwable; /** * Whether a customer may have their own domain — asked and answered once. @@ -246,10 +248,16 @@ final class CustomDomainAccess /** * Make the state match the rule, after a change has landed. * - * Called by PlanChange when a plan change takes effect. Two things happen, - * in this order: the module stops being charged for, and the domain stops - * being presented. Charging first, because a customer who has lost a - * feature and is still billed for it has a complaint we would deserve. + * Called from the two moments the right can end. PlanChange calls it when a + * plan change takes effect, and BookAddon::cancel() when the module itself + * stops — der zweite Weg, und der häufigere: der Kunde bucht die Domain in + * der Abrechnung ab, und ohne diesen Aufruf bliebe sie auf der Maschine + * stehen, obwohl niemand sie mehr bezahlt. + * + * Two things happen, in this order: the module stops being charged for, and + * the domain stops being presented. Charging first, because a customer who + * has lost a feature and is still billed for it has a complaint we would + * deserve. * * Returns whether anything was actually taken away. */ @@ -316,12 +324,43 @@ final class CustomDomainAccess ])->save(); if ($wasVerified) { - app(ReapplyInstanceAddress::class)($instance); + $this->stopServing($instance); } return true; } + /** + * Die Maschine nachziehen lassen — und daran nicht scheitern. + * + * Diese Klasse wird aus Vertrags- und Zahlungsentscheidungen heraus + * gerufen: aus einem Paketwechsel, der bereits gebucht und bezahlt ist + * (ApplyPlanChange), und aus einer Modulkündigung, deren Termin gerade + * gehalten wurde (BookAddon::cancel). Beide sind geschrieben, wenn es + * hierher kommt. Ein Host, der nicht erreichbar ist, oder eine + * Warteschlange, die gerade nicht antwortet, darf diese Entscheidung nicht + * zurückdrehen — sonst hinge eine Kündigung daran, dass eine fremde Maschine + * antwortet. Der Zustand in der Datenbank ist die Wahrheit; die Maschine + * zieht nach. + * + * Darum geschrieben und nicht geschwiegen: startet der Lauf nicht, bleibt + * die Adresse auf der Maschine stehen, und nichts sucht sie je wieder — die + * Spalte ist eine Zeile weiter oben geleert worden, also findet die + * nächtliche Prüfung (clupilot:verify-domains) sie nicht mehr. Diese + * Meldung ist die einzige Spur, die ein Betreiber davon bekommt. + */ + private function stopServing(Instance $instance): void + { + try { + app(ReapplyInstanceAddress::class)($instance); + } catch (Throwable $e) { + Log::error('Die entzogene Adresse konnte nicht von der Maschine genommen werden.', [ + 'instance' => $instance->uuid, + 'error' => $e->getMessage(), + ]); + } + } + /** * The machine this contract pays for. * diff --git a/app/Support/OpenWork.php b/app/Support/OpenWork.php index 7ecb352..6251f0d 100644 --- a/app/Support/OpenWork.php +++ b/app/Support/OpenWork.php @@ -49,13 +49,6 @@ final class OpenWork 'missing' => 'Kostet 22,80 € im Monat und verspricht mehr gleichzeitige Bearbeiter und erweiterte Office-Funktionen. Es gibt keine Collabora-Konfiguration, die vom gebuchten Modul abhängt.', 'risk' => 'Wie oben: bezahlt und nicht erbracht. Die Paketsperre für Start ist inzwischen gesetzt, das Modul selbst tut weiterhin nichts.', ], - [ - 'title' => 'Eine entzogene Domain bleibt auf der Maschine stehen', - 'state' => self::PARTIAL, - 'where' => 'App\\Services\\Billing\\CustomDomainAccess', - 'missing' => 'Das Deaktivieren setzt den Zustand in der Datenbank, startet aber keine Provisionierung. Traefik und Nextclouds trusted_domains nennen die Adresse weiter, bis ConfigureDnsAndTls und ConfigureNextcloud erneut laufen.', - 'risk' => 'Eine Adresse, die der Kunde nicht mehr bezahlt, antwortet weiter.', - ], [ 'title' => 'Die Zertifikats-Spec beschreibt einen Weg, den es nicht gibt', 'state' => self::PLANNED, diff --git a/tests/Feature/CustomDomainServingTest.php b/tests/Feature/CustomDomainServingTest.php index 3ae01af..4afaa38 100644 --- a/tests/Feature/CustomDomainServingTest.php +++ b/tests/Feature/CustomDomainServingTest.php @@ -1,11 +1,14 @@ active()->create(['datacenter' => 'fsn', 'node' => 'pve']); - $order = Order::factory()->withSubscription()->create(['datacenter' => 'fsn', 'plan' => 'business']); + $order = Order::factory()->withSubscription()->create(['datacenter' => 'fsn', 'plan' => $plan]); $instance = Instance::factory()->create(array_merge([ 'order_id' => $order->id, @@ -434,6 +437,168 @@ it('makes the domain an address as soon as the customer’s own check finds the ->and(ProvisioningRun::query()->where('pipeline', 'address')->count())->toBe(1); }); +/* +| Der zweite Weg, auf dem das Recht auf eine eigene Domain endet. +| +| Der Paketwechsel war gebaut und geprüft (siehe „stops serving the domain when +| the package stops allowing one" oben). Die Kündigung des Moduls war es nicht: +| BookAddon::cancel() setzte `cancelled_at`, damit war die Domain in jeder +| Ansicht weg — und auf der Maschine stand sie weiter. Traefik nannte den Namen, +| Nextcloud vertraute ihm, das Zertifikat blieb. Ein Kunde, der nicht mehr zahlt, +| wurde weiter bedient; und legte er die Domain später woanders hin, zeigte +| unser Host weiter darauf. +*/ + +/** Ein Team-Vertrag, der die eigene Domain als Modul gebucht hat und bereits ausgeliefert bekommt. */ +function bookedDomainModule(): array +{ + $fixture = servedInstance([ + 'custom_domain' => 'cloud.berger.at', + 'domain_token' => 'tok123', + 'domain_verified_at' => now(), + 'domain_cert_ok' => true, + 'routed_hostnames' => [platformAddress(), 'cloud.berger.at'], + ], 'berger', 'team'); + + $contract = Subscription::query()->where('order_id', $fixture['order']->id)->sole(); + $addon = app(BookAddon::class)($contract, CustomDomainAccess::ADDON); + + return [...$fixture, 'contract' => $contract, 'addon' => $addon]; +} + +it('nimmt die eigene Domain von der Maschine, sobald das gekündigte Modul endet', function () { + $s = fakeServices(); + Queue::fake(); + + $fixture = bookedDomainModule(); + + // Ausgangslage: gebucht, bewiesen, ausgeliefert. + expect(app(CustomDomainAccess::class)->allows($fixture['contract']->refresh()))->toBeTrue() + ->and($fixture['instance']->fresh()->domainIsServed())->toBeTrue(); + + // Der Kunde kündigt monatlich; der bezahlte Zeitraum läuft ab, und der + // nächtliche Lauf hält den Termin. Genau der Weg, den das Portal geht. + app(BookAddon::class)->cancelAtPeriodEnd($fixture['addon']); + SubscriptionAddon::query()->whereKey($fixture['addon']->getKey())->update(['cancels_at' => now()->subDay()]); + + $this->artisan('clupilot:end-cancelled-addons')->assertSuccessful(); + + // Die Datenbank sagt es, und ein Lauf ist unterwegs, der es der Maschine sagt. + expect($fixture['instance']->fresh()->custom_domain)->toBeNull() + ->and($fixture['contract']->addons()->active()->count())->toBe(0); + + $run = ProvisioningRun::query()->where('pipeline', 'address')->latest('id')->first(); + expect($run)->not->toBeNull(); + + app(ConfigureNextcloud::class)->execute($run); + app(ConfigureDnsAndTls::class)->execute($run); + + // Und erst hier ist die Adresse wirklich weg: aus dem Router, aus + // trusted_domains, und als beantwortete Adresse des Kunden. + expect($s['traefik']->serves('berger', 'cloud.berger.at'))->toBeFalse() + ->and($s['traefik']->hostnames['berger'])->toBe([platformAddress()]) + ->and($s['pve']->guestRan('config:system:delete trusted_domains 2'))->toBeTrue() + ->and($fixture['instance']->fresh()->domain_cert_ok)->toBeFalse() + ->and($fixture['instance']->fresh()->address(ProvisioningSettings::dnsZone()))->toBe(platformAddress()); +}); + +it('lässt die Domain stehen, wenn das Paket sie nach der Kündigung selbst trägt', function () { + Queue::fake(); + + // Team gebucht, danach auf Business aufgestuft: das Modul läuft weiter mit + // und ist dort überflüssig, weil das Paket die Domain enthält. Wer es + // kündigt, spart neun Euro — und verliert nichts. Ein Abräumen, das nur auf + // den Modulschlüssel schaut statt auf die Regel, nähme ihm hier die + // Adresse, für die er weiter bezahlt. + $fixture = servedInstance([ + 'custom_domain' => 'cloud.berger.at', + 'domain_token' => 'tok123', + 'domain_verified_at' => now(), + 'domain_cert_ok' => true, + 'routed_hostnames' => [platformAddress(), 'cloud.berger.at'], + ]); + + $contract = Subscription::query()->where('order_id', $fixture['order']->id)->sole(); + + expect(app(CustomDomainAccess::class)->includedInPlan($contract))->toBeTrue(); + + $addon = SubscriptionAddon::create([ + 'subscription_id' => $contract->id, + 'addon_key' => CustomDomainAccess::ADDON, + 'price_cents' => 900, + 'currency' => 'EUR', + 'quantity' => 1, + 'booked_at' => now(), + ]); + + app(BookAddon::class)->cancel($addon); + + expect($fixture['instance']->fresh()->custom_domain)->toBe('cloud.berger.at') + ->and($fixture['instance']->fresh()->domainIsServed())->toBeTrue() + ->and(ProvisioningRun::query()->where('pipeline', 'address')->count())->toBe(0); +}); + +it('kippt die Kündigung nicht, wenn die Maschine gerade nicht erreichbar ist', function () { + Queue::fake(); + + $fixture = bookedDomainModule(); + + // Der Tunnel steht nicht, die Warteschlange antwortet nicht — irgendetwas + // zwischen uns und dem Host. Die Kündigung ist eine Vertragsentscheidung + // und darf daran nicht hängen. + app()->bind(ReapplyInstanceAddress::class, fn () => new class extends ReapplyInstanceAddress + { + public function __invoke(?Instance $instance): ?ProvisioningRun + { + throw new RuntimeException('Host nicht erreichbar'); + } + }); + + app(BookAddon::class)->cancel($fixture['addon']); + + expect($fixture['addon']->fresh()->cancelled_at)->not->toBeNull() + // Die Wahrheit steht trotzdem geschrieben: die Adresse ist entzogen, + // die Maschine zieht nach. + ->and($fixture['instance']->fresh()->custom_domain)->toBeNull() + ->and(app(CustomDomainAccess::class)->allows($fixture['contract']->refresh()))->toBeFalse(); +}); + +it('bedient die Domain wieder, wenn der Kunde das Modul erneut bucht', function () { + // Die Gegenrichtung. Ohne sie wäre das eine Einbahnstrasse: entzogen wird + // die Adresse von der Maschine genommen, und neu gebucht käme sie nie + // zurück. + Queue::fake(); + + $fixture = bookedDomainModule(); + $user = signIntoPortal($fixture); + + app(BookAddon::class)->cancel($fixture['addon']); + + // Ohne Modul ist die Seite zu — auch für einen alten Tab. + Livewire::actingAs($user)->test(CustomDomain::class)->assertStatus(403); + + ProvisioningRun::query()->where('pipeline', 'address')->update(['status' => ProvisioningRun::STATUS_COMPLETED]); + app(BookAddon::class)($fixture['contract']->refresh(), CustomDomainAccess::ADDON); + + // Die Domain selbst ist mit dem Entzug gelöscht worden, also trägt der + // Kunde sie neu ein und weist sie neu nach — derselbe Weg wie beim ersten + // Mal, und genau dort hängt der Lauf, der sie wieder ausliefert. + bindDomainResolver(['_clupilot-challenge.cloud.berger.at' => [['txt' => 'cp-verify=neu123']]]); + + Livewire::actingAs($user)->test(CustomDomain::class) + ->set('domain', 'cloud.berger.at') + ->call('save') + ->assertHasNoErrors(); + + $fixture['instance']->fresh()->update(['domain_token' => 'neu123']); + + Livewire::actingAs($user)->test(CustomDomain::class)->call('checkNow'); + + expect($fixture['instance']->fresh()->domainIsVerified())->toBeTrue() + ->and(ProvisioningRun::query()->where('pipeline', 'address')->where('status', '!=', ProvisioningRun::STATUS_COMPLETED)->count()) + ->toBe(1); +}); + it('has nothing to re-apply for an instance with no machine', function () { Queue::fake();