Reconcile endpoint-qualified firewall policy

This commit is contained in:
ms2k 2026-08-03 01:04:11 +09:00
commit 6ab168e041
9 changed files with 298 additions and 17 deletions

View file

@ -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',
]; ];
} }

View file

@ -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>

View file

@ -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);
}
}
}
}

View file

@ -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'] ?? '')));

View file

@ -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();
} }

View file

@ -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,
]; ];
} }

View file

@ -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
View 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";

View file

@ -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);