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
parent
c1e81808a7
commit
2b70c9226a
|
|
@ -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
|
||||||
{
|
{
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
{
|
{
|
||||||
|
|
|
||||||
|
|
@ -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);
|
||||||
|
});
|
||||||
|
|
|
||||||
|
|
@ -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);
|
||||||
|
});
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue