Serialize NordVPN bootstrap and reconciliation

This commit is contained in:
ms2k 2026-08-07 01:04:03 +09:00
commit 6b2049ccf7
4 changed files with 74 additions and 2 deletions

View file

@ -10,6 +10,7 @@ use OPNsense\Core\Backend;
use OPNsense\NordVPN\EnrollmentInspector;
use OPNsense\NordVPN\ManagedConfiguration;
use OPNsense\NordVPN\NordApi;
use OPNsense\NordVPN\ReconciliationLock;
use OPNsense\NordVPN\Reconciler;
use OPNsense\NordVPN\Settings;
use OPNsense\NordVPN\StatusStore;
@ -23,7 +24,9 @@ class ServiceController extends ApiControllerBase
return ['status' => 'failed', 'message' => 'POST is required.'];
}
$token = trim((string)($this->request->getPost('token') ?? ''));
$lock = new ReconciliationLock();
try {
$lock->acquire();
$api = new NordApi();
$settings = new Settings();
$countries = array_values(array_filter(explode(',', (string)$settings->general->countries)));
@ -53,6 +56,8 @@ class ServiceController extends ApiControllerBase
} catch (\Throwable $exception) {
unset($token);
return ['status' => 'failed', 'message' => 'Bootstrap failed. Check the token and plugin configuration.'];
} finally {
$lock->release();
}
}

View file

@ -0,0 +1,45 @@
<?php
// SPDX-License-Identifier: BSD-2-Clause
// SPDX-FileCopyrightText: 2026 ms2k
namespace OPNsense\NordVPN;
class ReconciliationLock
{
private string $path;
private $handle;
public function __construct(string $path = '/var/run/nordvpn.lock')
{
$this->path = $path;
}
public function acquire(): void
{
$handle = fopen($this->path, 'c');
if ($handle === false) {
throw new \RuntimeException('Unable to create the NordVPN operation lock.');
}
if (!flock($handle, LOCK_EX | LOCK_NB)) {
fclose($handle);
throw new \RuntimeException('A NordVPN operation is already running.');
}
$this->handle = $handle;
}
public function release(): void
{
if (!is_resource($this->handle)) {
return;
}
flock($this->handle, LOCK_UN);
fclose($this->handle);
$this->handle = null;
}
public function __destruct()
{
$this->release();
}
}

View file

@ -77,13 +77,13 @@
$('#bootstrapAct').click(function() {
const token = $('#accessToken').val();
$('#accessToken').val('');
$('#bootstrapAct').addClass('disabled');
$('#bootstrapAct').addClass('disabled').prop('disabled', true);
$('#bootstrap_progress').addClass('fa fa-spinner fa-pulse');
ajaxCall('/api/nordvpn/service/bootstrap', {token: token}, function(data) {
$('#bootstrapMsg').removeClass('hidden alert-success alert-danger');
$('#bootstrapMsg').addClass(data.status === 'ok' ? 'alert-success' : 'alert-danger');
$('#bootstrapMsg').text(data.status === 'ok' ? '{{ lang._("Bootstrap completed") }}' : data.message);
$('#bootstrapAct').removeClass('disabled');
$('#bootstrapAct').removeClass('disabled').prop('disabled', false);
$('#bootstrap_progress').removeClass('fa fa-spinner fa-pulse');
refreshStatus();
});

View file

@ -3,6 +3,7 @@
use OPNsense\NordVPN\EndpointStore;
use OPNsense\NordVPN\EnrollmentInspector;
use OPNsense\NordVPN\NordApi;
use OPNsense\NordVPN\ReconciliationLock;
use OPNsense\NordVPN\StatusStore;
use PHPUnit\Framework\TestCase;
@ -58,4 +59,25 @@ final class StoreAndInspectorTest extends TestCase
unlink($path);
}
public function testReconciliationLockRejectsConcurrentOperations(): void
{
$path = sys_get_temp_dir() . '/nordvpn-lock-' . bin2hex(random_bytes(8));
$first = new ReconciliationLock($path);
$second = new ReconciliationLock($path);
$first->acquire();
try {
$second->acquire();
$this->fail('A concurrent operation acquired the lock.');
} catch (RuntimeException $exception) {
$this->assertSame('A NordVPN operation is already running.', $exception->getMessage());
} finally {
$first->release();
}
$second->acquire();
$second->release();
unlink($path);
}
}