fix(traffic): release the throttle at month end; close the Livewire bypass

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 <noreply@anthropic.com>
feat/portal-design
nexxo 2026-07-25 23:36:38 +02:00
parent c1e81808a7
commit 2b70c9226a
4 changed files with 76 additions and 4 deletions

View File

@ -23,11 +23,16 @@ use Symfony\Component\HttpFoundation\Response;
class PublicSiteGate class PublicSiteGate
{ {
/** /**
* Paths that must keep working regardless: the console itself (otherwise * Paths that must keep working regardless: the console itself (otherwise the
* the switch could not be flipped back), Livewire's endpoint that drives it, * switch could not be flipped back), Stripe's webhook, and the health check.
* 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 public function handle(Request $request, Closure $next): Response
{ {

View File

@ -74,6 +74,13 @@ class CollectInstanceTraffic implements ShouldBeUnique, ShouldQueue
['last_netin' => $netin, 'last_netout' => $netout, 'sampled_at' => now()], ['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 // A fresh row starts from this sample: whatever the VM transferred
// before we started counting this month is not ours to bill. // before we started counting this month is not ours to bill.
if (! $row->wasRecentlyCreated) { if (! $row->wasRecentlyCreated) {
@ -88,6 +95,24 @@ class CollectInstanceTraffic implements ShouldBeUnique, ShouldQueue
$this->enforce($client, $instance, $row); $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. */ /** Counter went backwards → the VM restarted, so the current value is the delta. */
private function delta(int $current, int $previous): int private function delta(int $current, int $previous): int
{ {

View File

@ -97,3 +97,23 @@ it('keeps serving when the settings table is unavailable', function () {
$this->get('/')->assertOk(); $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);
});

View File

@ -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(1_500_000))->toBe('1,5 MB')
->and(App\Support\Bytes::human(512))->toBe('512 B'); ->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);
});