Reconcile endpoint-qualified firewall policy
This commit is contained in:
parent
df99e50da4
commit
6ab168e041
2 changed files with 298 additions and 17 deletions
103
tests/reconciler.php
Normal file
103
tests/reconciler.php
Normal file
|
|
@ -0,0 +1,103 @@
|
|||
<?php
|
||||
|
||||
// SPDX-License-Identifier: BSD-2-Clause
|
||||
// SPDX-FileCopyrightText: 2026 ms2k
|
||||
|
||||
namespace OPNsense\NordVPN;
|
||||
|
||||
class Settings
|
||||
{
|
||||
public object $general;
|
||||
public object $managed;
|
||||
|
||||
public function __construct()
|
||||
{
|
||||
$this->general = (object)['countries' => ''];
|
||||
$this->managed = (object)[
|
||||
'server_country' => 'NL',
|
||||
'server_hostname' => 'nl.example.invalid',
|
||||
];
|
||||
}
|
||||
}
|
||||
|
||||
$root = dirname(__DIR__);
|
||||
foreach (['NordApi', 'EndpointStore', 'StatusStore', 'SystemRunner', 'TunnelHealth', 'ManagedConfiguration', 'Reconciler'] as $class) {
|
||||
require_once $root . '/src/opnsense/mvc/app/library/OPNsense/NordVPN/' . $class . '.php';
|
||||
}
|
||||
|
||||
class TestConfiguration extends ManagedConfiguration
|
||||
{
|
||||
private Settings $testSettings;
|
||||
|
||||
public function __construct()
|
||||
{
|
||||
$this->testSettings = new Settings();
|
||||
}
|
||||
|
||||
public function settings(): Settings
|
||||
{
|
||||
return $this->testSettings;
|
||||
}
|
||||
|
||||
public function interfaceName(): ?string
|
||||
{
|
||||
return 'wg0';
|
||||
}
|
||||
}
|
||||
|
||||
class AvailableHealth extends TunnelHealth
|
||||
{
|
||||
public function __construct()
|
||||
{
|
||||
}
|
||||
|
||||
public function check(string $interface): array
|
||||
{
|
||||
return ['available' => true, 'last_handshake' => 1000];
|
||||
}
|
||||
}
|
||||
|
||||
class FailOnceRunner extends SystemRunner
|
||||
{
|
||||
public int $firewallCalls = 0;
|
||||
|
||||
public function applyFirewall(): void
|
||||
{
|
||||
$this->firewallCalls++;
|
||||
if ($this->firewallCalls === 1) {
|
||||
throw new \RuntimeException('Synthetic firewall failure.');
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
$path = sys_get_temp_dir() . '/nordvpn-retry-' . bin2hex(random_bytes(8)) . '.json';
|
||||
$status = new StatusStore($path);
|
||||
$runner = new FailOnceRunner();
|
||||
$reconciler = new Reconciler(
|
||||
new NordApi(static fn() => []),
|
||||
new TestConfiguration(),
|
||||
new AvailableHealth(),
|
||||
$status,
|
||||
$runner
|
||||
);
|
||||
|
||||
try {
|
||||
$reconciler->run();
|
||||
throw new \RuntimeException('The first firewall failure was not reported.');
|
||||
} catch (\RuntimeException $exception) {
|
||||
if ($exception->getMessage() !== 'Synthetic firewall failure.') {
|
||||
throw $exception;
|
||||
}
|
||||
}
|
||||
|
||||
if (empty($status->read()['firewall_pending'])) {
|
||||
throw new \RuntimeException('The failed firewall apply was not retained as pending.');
|
||||
}
|
||||
|
||||
$reconciler->run();
|
||||
if ($runner->firewallCalls !== 2 || !empty($status->read()['firewall_pending'])) {
|
||||
throw new \RuntimeException('The pending firewall apply was not completed on retry.');
|
||||
}
|
||||
|
||||
unlink($path);
|
||||
echo "ok - reconciliation retries a failed firewall apply\n";
|
||||
|
|
@ -4,12 +4,14 @@
|
|||
// SPDX-FileCopyrightText: 2026 ms2k
|
||||
|
||||
require_once __DIR__ . '/../src/opnsense/mvc/app/library/OPNsense/NordVPN/NordApi.php';
|
||||
require_once __DIR__ . '/../src/opnsense/mvc/app/library/OPNsense/NordVPN/EndpointStore.php';
|
||||
require_once __DIR__ . '/../src/opnsense/mvc/app/library/OPNsense/NordVPN/EnrollmentInspector.php';
|
||||
require_once __DIR__ . '/../src/opnsense/mvc/app/library/OPNsense/NordVPN/StatusStore.php';
|
||||
require_once __DIR__ . '/../src/opnsense/mvc/app/library/OPNsense/NordVPN/SystemRunner.php';
|
||||
require_once __DIR__ . '/../src/opnsense/mvc/app/library/OPNsense/NordVPN/TunnelHealth.php';
|
||||
|
||||
use OPNsense\NordVPN\EnrollmentInspector;
|
||||
use OPNsense\NordVPN\EndpointStore;
|
||||
use OPNsense\NordVPN\NordApi;
|
||||
use OPNsense\NordVPN\StatusStore;
|
||||
use OPNsense\NordVPN\SystemRunner;
|
||||
|
|
@ -71,6 +73,44 @@ $test('an empty country allowlist queries recommendations directly', static func
|
|||
$check($api->recommendations([]) === []);
|
||||
});
|
||||
|
||||
$test('endpoint discovery paginates the complete NordLynx address set', static function () use ($check): void {
|
||||
$requests = [];
|
||||
$api = new NordApi(static function ($path, $query) use (&$requests): array {
|
||||
$requests[] = [$path, $query];
|
||||
if ($query['offset'] === 0) {
|
||||
return array_map(
|
||||
static fn($index) => ['station' => sprintf('198.51.%d.%d', intdiv($index, 250), $index % 250 + 1)],
|
||||
range(0, 499)
|
||||
);
|
||||
}
|
||||
return [['station' => '203.0.113.10'], ['station' => 'invalid']];
|
||||
});
|
||||
$addresses = $api->endpointAddresses();
|
||||
$check(count($addresses) === 501);
|
||||
$check($requests[0][0] === '/v1/servers');
|
||||
$check($requests[0][1]['limit'] === 500);
|
||||
$check($requests[1][1]['offset'] === 500);
|
||||
$check($requests[0][1]['filters[servers_technologies]'] === 35);
|
||||
});
|
||||
|
||||
$test('endpoint refresh writes an atomic reusable table', static function () use ($check): void {
|
||||
$directory = sys_get_temp_dir() . '/nordvpn-endpoints-' . bin2hex(random_bytes(8));
|
||||
$path = $directory . '/endpoints.txt';
|
||||
$requests = 0;
|
||||
$api = new NordApi(static function () use (&$requests): array {
|
||||
$requests++;
|
||||
return [['station' => '203.0.113.10']];
|
||||
});
|
||||
$store = new EndpointStore($path);
|
||||
$check($store->refreshIfStale($api));
|
||||
$check($store->available());
|
||||
$check(file_get_contents($path) === "203.0.113.10\n");
|
||||
$check(!$store->refreshIfStale($api));
|
||||
$check($requests === 1);
|
||||
unlink($path);
|
||||
rmdir($directory);
|
||||
});
|
||||
|
||||
$test('RFC 1918 warning follows destination selector semantics', static function () use ($check): void {
|
||||
$inspector = new EnrollmentInspector([
|
||||
'private' => ['10.0.0.0/8', '172.16.0.0/12', '192.168.0.0/16'],
|
||||
|
|
@ -119,6 +159,19 @@ $test('WireGuard apply follows the OPNsense registration sequence', static funct
|
|||
]);
|
||||
});
|
||||
|
||||
$test('firewall apply reloads rules before evicting plugin states', static function () use ($check): void {
|
||||
$commands = [];
|
||||
$runner = new SystemRunner(static function ($command) use (&$commands): string {
|
||||
$commands[] = $command;
|
||||
return '';
|
||||
});
|
||||
$runner->applyFirewall();
|
||||
$check($commands === [
|
||||
['/usr/local/etc/rc.filter_configure'],
|
||||
['/sbin/pfctl', '-k', 'label', '-k', SystemRunner::FIREWALL_LABEL],
|
||||
]);
|
||||
});
|
||||
|
||||
$failures = 0;
|
||||
foreach ($tests as $name => $body) {
|
||||
try {
|
||||
|
|
@ -129,4 +182,9 @@ foreach ($tests as $name => $body) {
|
|||
fwrite(STDERR, "not ok - {$name}: {$exception->getMessage()}\n");
|
||||
}
|
||||
}
|
||||
$command = escapeshellarg(PHP_BINARY) . ' ' . escapeshellarg(__DIR__ . '/reconciler.php');
|
||||
passthru($command, $status);
|
||||
if ($status !== 0) {
|
||||
$failures++;
|
||||
}
|
||||
exit($failures === 0 ? 0 : 1);
|
||||
|
|
|
|||
Loading…
Reference in a new issue