Reconcile endpoint-qualified firewall policy
This commit is contained in:
parent
df99e50da4
commit
6ab168e041
9 changed files with 298 additions and 17 deletions
|
|
@ -4,9 +4,11 @@
|
||||||
// SPDX-FileCopyrightText: 2026 ms2k
|
// SPDX-FileCopyrightText: 2026 ms2k
|
||||||
|
|
||||||
use OPNsense\Firewall\Plugin;
|
use OPNsense\Firewall\Plugin;
|
||||||
|
use OPNsense\NordVPN\EndpointStore;
|
||||||
use OPNsense\NordVPN\ManagedConfiguration;
|
use OPNsense\NordVPN\ManagedConfiguration;
|
||||||
use OPNsense\NordVPN\Settings;
|
use OPNsense\NordVPN\Settings;
|
||||||
use OPNsense\NordVPN\StatusStore;
|
use OPNsense\NordVPN\StatusStore;
|
||||||
|
use OPNsense\NordVPN\SystemRunner;
|
||||||
|
|
||||||
function nordvpn_cron()
|
function nordvpn_cron()
|
||||||
{
|
{
|
||||||
|
|
@ -56,10 +58,13 @@ function nordvpn_firewall(Plugin $firewall)
|
||||||
$status = (new StatusStore())->read();
|
$status = (new StatusStore())->read();
|
||||||
$available = !empty($status['available']);
|
$available = !empty($status['available']);
|
||||||
|
|
||||||
if ((string)$settings->general->exempt_wireguard === '1') {
|
if ((string)$settings->general->exempt_wireguard === '1' &&
|
||||||
|
is_file(EndpointStore::PATH) && filesize(EndpointStore::PATH) > 0) {
|
||||||
|
$firewall->registerTable(EndpointStore::TABLE, false, EndpointStore::PATH);
|
||||||
foreach ($interfaces as $interface) {
|
foreach ($interfaces as $interface) {
|
||||||
$rule = nordvpn_filter_rule($interface, 'any', 'any', false, false);
|
$rule = nordvpn_filter_rule($interface, 'any', 'any', false, false);
|
||||||
$rule['protocol'] = 'udp';
|
$rule['protocol'] = 'udp';
|
||||||
|
$rule['to'] = '<' . EndpointStore::TABLE . '>';
|
||||||
$rule['to_port'] = ManagedConfiguration::PORT;
|
$rule['to_port'] = ManagedConfiguration::PORT;
|
||||||
$rule['descr'] = 'NordVPN WireGuard direct egress';
|
$rule['descr'] = 'NordVPN WireGuard direct egress';
|
||||||
if ($directGateway !== '') {
|
if ($directGateway !== '') {
|
||||||
|
|
@ -71,6 +76,7 @@ function nordvpn_firewall(Plugin $firewall)
|
||||||
$rule['direction'] = 'out';
|
$rule['direction'] = 'out';
|
||||||
$rule['floating'] = true;
|
$rule['floating'] = true;
|
||||||
$rule['protocol'] = 'udp';
|
$rule['protocol'] = 'udp';
|
||||||
|
$rule['to'] = '<' . EndpointStore::TABLE . '>';
|
||||||
$rule['to_port'] = ManagedConfiguration::PORT;
|
$rule['to_port'] = ManagedConfiguration::PORT;
|
||||||
$rule['descr'] = 'NordVPN appliance WireGuard direct egress';
|
$rule['descr'] = 'NordVPN appliance WireGuard direct egress';
|
||||||
if ($directGateway !== '') {
|
if ($directGateway !== '') {
|
||||||
|
|
@ -125,6 +131,7 @@ function nordvpn_filter_rule(string $interface, string $source, string $destinat
|
||||||
'to' => $destination,
|
'to' => $destination,
|
||||||
'to_not' => $destinationNot,
|
'to_not' => $destinationNot,
|
||||||
'statetype' => 'keep',
|
'statetype' => 'keep',
|
||||||
|
'label' => SystemRunner::FIREWALL_LABEL,
|
||||||
'#ref' => 'ui/nordvpn',
|
'#ref' => 'ui/nordvpn',
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -58,8 +58,8 @@
|
||||||
</field>
|
</field>
|
||||||
<field>
|
<field>
|
||||||
<id>settings.general.exempt_wireguard</id>
|
<id>settings.general.exempt_wireguard</id>
|
||||||
<label>Exempt UDP port 51820</label>
|
<label>Exempt NordVPN WireGuard</label>
|
||||||
<type>checkbox</type>
|
<type>checkbox</type>
|
||||||
<help>Route outbound UDP destination port 51820 through the direct egress gateway.</help>
|
<help>Route UDP port 51820 to NordVPN NordLynx servers through the direct egress gateway.</help>
|
||||||
</field>
|
</field>
|
||||||
</form>
|
</form>
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,60 @@
|
||||||
|
<?php
|
||||||
|
|
||||||
|
// SPDX-License-Identifier: BSD-2-Clause
|
||||||
|
// SPDX-FileCopyrightText: 2026 ms2k
|
||||||
|
|
||||||
|
namespace OPNsense\NordVPN;
|
||||||
|
|
||||||
|
class EndpointStore
|
||||||
|
{
|
||||||
|
public const PATH = '/var/db/nordvpn/endpoints.txt';
|
||||||
|
public const TABLE = 'nordvpn_endpoints';
|
||||||
|
private const MAXIMUM_AGE = 21600;
|
||||||
|
private string $path;
|
||||||
|
private $clock;
|
||||||
|
|
||||||
|
public function __construct(string $path = self::PATH, ?callable $clock = null)
|
||||||
|
{
|
||||||
|
$this->path = $path;
|
||||||
|
$this->clock = $clock ?? 'time';
|
||||||
|
}
|
||||||
|
|
||||||
|
public function available(): bool
|
||||||
|
{
|
||||||
|
return is_file($this->path) && filesize($this->path) > 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function refreshIfStale(NordApi $api): bool
|
||||||
|
{
|
||||||
|
$modified = is_file($this->path) ? filemtime($this->path) : false;
|
||||||
|
if ($modified !== false && ($this->clock)() - $modified < self::MAXIMUM_AGE) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
$this->write($api->endpointAddresses());
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
private function write(array $addresses): void
|
||||||
|
{
|
||||||
|
$directory = dirname($this->path);
|
||||||
|
if (!is_dir($directory) && !mkdir($directory, 0755, true) && !is_dir($directory)) {
|
||||||
|
throw new \RuntimeException('Unable to create the NordVPN endpoint directory.');
|
||||||
|
}
|
||||||
|
$temporary = tempnam($directory, '.endpoints.');
|
||||||
|
if ($temporary === false) {
|
||||||
|
throw new \RuntimeException('Unable to create the NordVPN endpoint file.');
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
$content = implode("\n", $addresses) . "\n";
|
||||||
|
if (file_put_contents($temporary, $content, LOCK_EX) === false ||
|
||||||
|
!chmod($temporary, 0644) ||
|
||||||
|
!rename($temporary, $this->path)) {
|
||||||
|
throw new \RuntimeException('Unable to write the NordVPN endpoint file.');
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
if (is_file($temporary)) {
|
||||||
|
unlink($temporary);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -8,6 +8,8 @@ namespace OPNsense\NordVPN;
|
||||||
class NordApi
|
class NordApi
|
||||||
{
|
{
|
||||||
private const BASE_URL = 'https://api.nordvpn.com';
|
private const BASE_URL = 'https://api.nordvpn.com';
|
||||||
|
private const ENDPOINT_PAGE_LIMIT = 500;
|
||||||
|
private const ENDPOINT_PAGE_MAXIMUM = 40;
|
||||||
private const WIREGUARD_TECHNOLOGY_ID = 35;
|
private const WIREGUARD_TECHNOLOGY_ID = 35;
|
||||||
private $transport;
|
private $transport;
|
||||||
|
|
||||||
|
|
@ -82,6 +84,35 @@ class NordApi
|
||||||
return array_slice(array_values($result), 0, $limit);
|
return array_slice(array_values($result), 0, $limit);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public function endpointAddresses(): array
|
||||||
|
{
|
||||||
|
$result = [];
|
||||||
|
for ($page = 0; $page < self::ENDPOINT_PAGE_MAXIMUM; $page++) {
|
||||||
|
$servers = $this->request('/v1/servers', [
|
||||||
|
'limit' => self::ENDPOINT_PAGE_LIMIT,
|
||||||
|
'offset' => $page * self::ENDPOINT_PAGE_LIMIT,
|
||||||
|
'filters[servers.status]' => 'online',
|
||||||
|
'filters[servers_technologies]' => self::WIREGUARD_TECHNOLOGY_ID,
|
||||||
|
'filters[servers_technologies][pivot][status]' => 'online',
|
||||||
|
]);
|
||||||
|
foreach ($servers as $server) {
|
||||||
|
$address = trim((string)($server['station'] ?? ''));
|
||||||
|
if (filter_var($address, FILTER_VALIDATE_IP, FILTER_FLAG_IPV4)) {
|
||||||
|
$result[$address] = true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (count($servers) < self::ENDPOINT_PAGE_LIMIT) {
|
||||||
|
$addresses = array_keys($result);
|
||||||
|
sort($addresses, SORT_NATURAL);
|
||||||
|
if (empty($addresses)) {
|
||||||
|
throw new \RuntimeException('NordVPN returned no NordLynx endpoint addresses.');
|
||||||
|
}
|
||||||
|
return $addresses;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
throw new \RuntimeException('NordVPN endpoint pagination exceeded the safety limit.');
|
||||||
|
}
|
||||||
|
|
||||||
private function candidate(array $server): ?array
|
private function candidate(array $server): ?array
|
||||||
{
|
{
|
||||||
$hostname = strtolower(trim((string)($server['hostname'] ?? '')));
|
$hostname = strtolower(trim((string)($server['hostname'] ?? '')));
|
||||||
|
|
|
||||||
|
|
@ -12,6 +12,7 @@ class Reconciler
|
||||||
private TunnelHealth $health;
|
private TunnelHealth $health;
|
||||||
private StatusStore $status;
|
private StatusStore $status;
|
||||||
private SystemRunner $runner;
|
private SystemRunner $runner;
|
||||||
|
private EndpointStore $endpoints;
|
||||||
private $pause;
|
private $pause;
|
||||||
|
|
||||||
public function __construct(
|
public function __construct(
|
||||||
|
|
@ -20,7 +21,8 @@ class Reconciler
|
||||||
?TunnelHealth $health = null,
|
?TunnelHealth $health = null,
|
||||||
?StatusStore $status = null,
|
?StatusStore $status = null,
|
||||||
?SystemRunner $runner = null,
|
?SystemRunner $runner = null,
|
||||||
?callable $pause = null
|
?callable $pause = null,
|
||||||
|
?EndpointStore $endpoints = null
|
||||||
) {
|
) {
|
||||||
$this->api = $api ?? new NordApi();
|
$this->api = $api ?? new NordApi();
|
||||||
$this->configuration = $configuration ?? new ManagedConfiguration();
|
$this->configuration = $configuration ?? new ManagedConfiguration();
|
||||||
|
|
@ -28,6 +30,7 @@ class Reconciler
|
||||||
$this->health = $health ?? new TunnelHealth($this->runner);
|
$this->health = $health ?? new TunnelHealth($this->runner);
|
||||||
$this->status = $status ?? new StatusStore();
|
$this->status = $status ?? new StatusStore();
|
||||||
$this->pause = $pause ?? 'sleep';
|
$this->pause = $pause ?? 'sleep';
|
||||||
|
$this->endpoints = $endpoints ?? new EndpointStore();
|
||||||
}
|
}
|
||||||
|
|
||||||
public function run(): array
|
public function run(): array
|
||||||
|
|
@ -42,6 +45,14 @@ class Reconciler
|
||||||
]);
|
]);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
$endpointsChanged = false;
|
||||||
|
if ((string)($settings->general->exempt_wireguard ?? '0') === '1') {
|
||||||
|
try {
|
||||||
|
$endpointsChanged = $this->endpoints->refreshIfStale($this->api);
|
||||||
|
} catch (\Throwable) {
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
$countryCodes = $this->values((string)$settings->general->countries);
|
$countryCodes = $this->values((string)$settings->general->countries);
|
||||||
$currentCountry = (string)$settings->managed->server_country;
|
$currentCountry = (string)$settings->managed->server_country;
|
||||||
$health = $this->health->check($interface);
|
$health = $this->health->check($interface);
|
||||||
|
|
@ -51,7 +62,7 @@ class Reconciler
|
||||||
'server_hostname' => (string)$settings->managed->server_hostname,
|
'server_hostname' => (string)$settings->managed->server_hostname,
|
||||||
'server_country' => $currentCountry,
|
'server_country' => $currentCountry,
|
||||||
'message' => '',
|
'message' => '',
|
||||||
]));
|
]), $endpointsChanged);
|
||||||
}
|
}
|
||||||
|
|
||||||
$this->publish([
|
$this->publish([
|
||||||
|
|
@ -61,10 +72,11 @@ class Reconciler
|
||||||
'server_country' => $currentCountry,
|
'server_country' => $currentCountry,
|
||||||
'last_handshake' => $health['last_handshake'],
|
'last_handshake' => $health['last_handshake'],
|
||||||
'message' => 'Trying eligible NordVPN servers.',
|
'message' => 'Trying eligible NordVPN servers.',
|
||||||
]);
|
], $endpointsChanged);
|
||||||
|
|
||||||
try {
|
try {
|
||||||
$currentHostname = (string)$settings->managed->server_hostname;
|
$currentHostname = (string)$settings->managed->server_hostname;
|
||||||
|
$next = null;
|
||||||
foreach ($this->api->recommendations($countryCodes, 20) as $candidate) {
|
foreach ($this->api->recommendations($countryCodes, 20) as $candidate) {
|
||||||
if ($candidate['hostname'] === $currentHostname) {
|
if ($candidate['hostname'] === $currentHostname) {
|
||||||
continue;
|
continue;
|
||||||
|
|
@ -75,34 +87,41 @@ class Reconciler
|
||||||
$health = $this->health->check((string)$this->configuration->interfaceName());
|
$health = $this->health->check((string)$this->configuration->interfaceName());
|
||||||
if ($health['available']) {
|
if ($health['available']) {
|
||||||
$this->runner->applyRouting();
|
$this->runner->applyRouting();
|
||||||
return $this->publish(array_merge($health, [
|
$next = array_merge($health, [
|
||||||
'state' => 'available',
|
'state' => 'available',
|
||||||
'server_hostname' => $candidate['hostname'],
|
'server_hostname' => $candidate['hostname'],
|
||||||
'server_country' => $candidate['country'],
|
'server_country' => $candidate['country'],
|
||||||
'message' => '',
|
'message' => '',
|
||||||
]));
|
]);
|
||||||
|
break;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return $this->publish([
|
if ($next === null) {
|
||||||
|
$next = [
|
||||||
'available' => false,
|
'available' => false,
|
||||||
'state' => 'unavailable',
|
'state' => 'unavailable',
|
||||||
'message' => 'No eligible NordVPN server established a handshake.',
|
'message' => 'No eligible NordVPN server established a handshake.',
|
||||||
]);
|
];
|
||||||
|
}
|
||||||
} catch (\Throwable $exception) {
|
} catch (\Throwable $exception) {
|
||||||
return $this->publish([
|
$next = [
|
||||||
'available' => false,
|
'available' => false,
|
||||||
'state' => 'error',
|
'state' => 'error',
|
||||||
'message' => $exception->getMessage(),
|
'message' => $exception->getMessage(),
|
||||||
]);
|
];
|
||||||
}
|
}
|
||||||
|
return $this->publish($next);
|
||||||
}
|
}
|
||||||
|
|
||||||
private function publish(array $next): array
|
private function publish(array $next, bool $firewallChanged = false): array
|
||||||
{
|
{
|
||||||
$previous = $this->status->read();
|
$previous = $this->status->read();
|
||||||
|
$next['firewall_pending'] = $firewallChanged || !empty($previous['firewall_pending']) ||
|
||||||
|
(bool)$previous['available'] !== (bool)$next['available'];
|
||||||
$this->status->write($next);
|
$this->status->write($next);
|
||||||
if ((bool)$previous['available'] !== (bool)$next['available']) {
|
if ($next['firewall_pending']) {
|
||||||
$this->runner->applyFirewall();
|
$this->runner->applyFirewall();
|
||||||
|
$this->status->write(['firewall_pending' => false]);
|
||||||
}
|
}
|
||||||
return $this->status->read();
|
return $this->status->read();
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -53,6 +53,7 @@ class StatusStore
|
||||||
'server_country' => '',
|
'server_country' => '',
|
||||||
'last_handshake' => 0,
|
'last_handshake' => 0,
|
||||||
'message' => '',
|
'message' => '',
|
||||||
|
'firewall_pending' => false,
|
||||||
'updated_at' => 0,
|
'updated_at' => 0,
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -7,6 +7,7 @@ namespace OPNsense\NordVPN;
|
||||||
|
|
||||||
class SystemRunner
|
class SystemRunner
|
||||||
{
|
{
|
||||||
|
public const FIREWALL_LABEL = 'nordvpn-egress';
|
||||||
private $executor;
|
private $executor;
|
||||||
|
|
||||||
public function __construct(?callable $executor = null)
|
public function __construct(?callable $executor = null)
|
||||||
|
|
@ -51,5 +52,6 @@ class SystemRunner
|
||||||
public function applyFirewall(): void
|
public function applyFirewall(): void
|
||||||
{
|
{
|
||||||
$this->run(['/usr/local/etc/rc.filter_configure']);
|
$this->run(['/usr/local/etc/rc.filter_configure']);
|
||||||
|
$this->run(['/sbin/pfctl', '-k', 'label', '-k', self::FIREWALL_LABEL]);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
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
|
// 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/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/EnrollmentInspector.php';
|
||||||
require_once __DIR__ . '/../src/opnsense/mvc/app/library/OPNsense/NordVPN/StatusStore.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/SystemRunner.php';
|
||||||
require_once __DIR__ . '/../src/opnsense/mvc/app/library/OPNsense/NordVPN/TunnelHealth.php';
|
require_once __DIR__ . '/../src/opnsense/mvc/app/library/OPNsense/NordVPN/TunnelHealth.php';
|
||||||
|
|
||||||
use OPNsense\NordVPN\EnrollmentInspector;
|
use OPNsense\NordVPN\EnrollmentInspector;
|
||||||
|
use OPNsense\NordVPN\EndpointStore;
|
||||||
use OPNsense\NordVPN\NordApi;
|
use OPNsense\NordVPN\NordApi;
|
||||||
use OPNsense\NordVPN\StatusStore;
|
use OPNsense\NordVPN\StatusStore;
|
||||||
use OPNsense\NordVPN\SystemRunner;
|
use OPNsense\NordVPN\SystemRunner;
|
||||||
|
|
@ -71,6 +73,44 @@ $test('an empty country allowlist queries recommendations directly', static func
|
||||||
$check($api->recommendations([]) === []);
|
$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 {
|
$test('RFC 1918 warning follows destination selector semantics', static function () use ($check): void {
|
||||||
$inspector = new EnrollmentInspector([
|
$inspector = new EnrollmentInspector([
|
||||||
'private' => ['10.0.0.0/8', '172.16.0.0/12', '192.168.0.0/16'],
|
'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;
|
$failures = 0;
|
||||||
foreach ($tests as $name => $body) {
|
foreach ($tests as $name => $body) {
|
||||||
try {
|
try {
|
||||||
|
|
@ -129,4 +182,9 @@ foreach ($tests as $name => $body) {
|
||||||
fwrite(STDERR, "not ok - {$name}: {$exception->getMessage()}\n");
|
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);
|
exit($failures === 0 ? 0 : 1);
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue