From 2b70c9226a929c843e44cca70eaefab0edc2b65d Mon Sep 17 00:00:00 2001 From: nexxo Date: Sat, 25 Jul 2026 23:36:38 +0200 Subject: [PATCH] fix(traffic): release the throttle at month end; close the Livewire bypass MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two from Codex: - A throttled instance kept its NIC limit into the new month: the new period row starts unthrottled, so neither branch of enforce() fired and nothing ever took the limit off. A customer would have stayed slow through a month they had already paid for. - PublicSiteGate exempted livewire/* wholesale. That endpoint is shared by the console and the portal, so a signed-in customer could keep driving portal components — billing included — while the portal was supposed to be offline. Operators pass the normal check anyway, which is all the console needed. Co-Authored-By: Claude Opus 4.8 --- app/Http/Middleware/PublicSiteGate.php | 13 +++++++--- .../Jobs/CollectInstanceTraffic.php | 25 +++++++++++++++++++ tests/Feature/PublicSiteGateTest.php | 20 +++++++++++++++ tests/Feature/TrafficTest.php | 22 ++++++++++++++++ 4 files changed, 76 insertions(+), 4 deletions(-) diff --git a/app/Http/Middleware/PublicSiteGate.php b/app/Http/Middleware/PublicSiteGate.php index ced7323..e9637bf 100644 --- a/app/Http/Middleware/PublicSiteGate.php +++ b/app/Http/Middleware/PublicSiteGate.php @@ -23,11 +23,16 @@ use Symfony\Component\HttpFoundation\Response; class PublicSiteGate { /** - * Paths that must keep working regardless: the console itself (otherwise - * the switch could not be flipped back), Livewire's endpoint that drives it, - * Stripe's webhook, and the health check. + * Paths that must keep working regardless: the console itself (otherwise the + * switch could not be flipped back), Stripe's webhook, and the health check. + * + * Livewire is deliberately NOT in this list. Its endpoint is shared by the + * console and the portal, so exempting it would leave a signed-in customer + * able to drive portal components — including billing — while the portal is + * supposed to be offline. Operators pass the check below anyway, which is + * what the console actually needs. */ - private const ALWAYS_ALLOWED = ['admin', 'admin/*', 'livewire/*', 'webhooks/*', 'up', 'robots.txt']; + private const ALWAYS_ALLOWED = ['admin', 'admin/*', 'webhooks/*', 'up', 'robots.txt']; public function handle(Request $request, Closure $next): Response { diff --git a/app/Provisioning/Jobs/CollectInstanceTraffic.php b/app/Provisioning/Jobs/CollectInstanceTraffic.php index a4eb3e3..59de491 100644 --- a/app/Provisioning/Jobs/CollectInstanceTraffic.php +++ b/app/Provisioning/Jobs/CollectInstanceTraffic.php @@ -74,6 +74,13 @@ class CollectInstanceTraffic implements ShouldBeUnique, ShouldQueue ['last_netin' => $netin, 'last_netout' => $netout, 'sampled_at' => now()], ); + // The month rolled over: the new row starts unthrottled, but the NIC + // still carries last month's limit and nothing below would ever take it + // off — a customer would stay slow into a month they have paid for. + if ($row->wasRecentlyCreated) { + $this->releasePreviousPeriodThrottle($client, $instance); + } + // A fresh row starts from this sample: whatever the VM transferred // before we started counting this month is not ours to bill. if (! $row->wasRecentlyCreated) { @@ -88,6 +95,24 @@ class CollectInstanceTraffic implements ShouldBeUnique, ShouldQueue $this->enforce($client, $instance, $row); } + private function releasePreviousPeriodThrottle($client, Instance $instance): void + { + $previous = InstanceTraffic::query() + ->where('instance_id', $instance->id) + ->where('period', '!=', InstanceTraffic::currentPeriod()) + ->where('throttled', true) + ->latest('period') + ->first(); + + if ($previous === null) { + return; + } + + $client->setNetworkRate($instance->host->node ?? 'pve', $instance->vmid, null); + $previous->update(['throttled' => false, 'throttled_at' => null]); + Log::info('throttle released for the new period', ['instance' => $instance->uuid]); + } + /** Counter went backwards → the VM restarted, so the current value is the delta. */ private function delta(int $current, int $previous): int { diff --git a/tests/Feature/PublicSiteGateTest.php b/tests/Feature/PublicSiteGateTest.php index 1bafc74..1f0b60e 100644 --- a/tests/Feature/PublicSiteGateTest.php +++ b/tests/Feature/PublicSiteGateTest.php @@ -97,3 +97,23 @@ it('keeps serving when the settings table is unavailable', function () { $this->get('/')->assertOk(); }); + +it('does not leave the portal drivable through Livewire while hidden', function () { + Settings::set('site.public', false); + + // A customer with a live session must not be able to keep using the portal + // through the shared Livewire endpoint while it is supposed to be offline. + $this->actingAs(User::factory()->create()) + ->withServerVariables(['REMOTE_ADDR' => '203.0.113.50']) + ->post('/livewire/update', []) + ->assertStatus(503); + + // Operators keep working — that is what the console needs. Whatever + // Livewire makes of an empty payload, the gate must not be what answers. + $status = $this->actingAs(operator('Owner')) + ->withServerVariables(['REMOTE_ADDR' => '203.0.113.50']) + ->post('/livewire/update', []) + ->getStatusCode(); + + expect($status)->not->toBe(503); +}); diff --git a/tests/Feature/TrafficTest.php b/tests/Feature/TrafficTest.php index 802765b..24ea9f9 100644 --- a/tests/Feature/TrafficTest.php +++ b/tests/Feature/TrafficTest.php @@ -157,3 +157,25 @@ it('shows allowances in the units they are sold in', function () { ->and(App\Support\Bytes::human(1_500_000))->toBe('1,5 MB') ->and(App\Support\Bytes::human(512))->toBe('512 B'); }); + +it('lets a throttled instance run again when the new month starts', function () { + [$pve, , $instance] = trafficSetup(); + + // Last month ended with the allowance exhausted and the NIC limited. + InstanceTraffic::create([ + 'instance_id' => $instance->id, + 'period' => now()->subMonth()->format('Y-m'), + 'tx_bytes' => 9_999_999_999_999, + 'throttled' => true, + 'throttled_at' => now()->subDays(3), + ]); + $pve->networkRates[1042] = 0.25; + + $pve->counters[1042] = ['netin' => 0, 'netout' => 0]; + (new CollectInstanceTraffic)->handle($pve); + + // The new period starts clean — and the limit must actually come off the + // NIC, not just out of the database. + expect($pve->networkRates[1042])->toBeNull() + ->and(InstanceTraffic::query()->where('throttled', true)->count())->toBe(0); +});