opnsense-nordvpn-plugin/tests/run.php

190 lines
7.6 KiB
PHP
Raw Normal View History

<?php
// SPDX-License-Identifier: BSD-2-Clause
// 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;
use OPNsense\NordVPN\TunnelHealth;
$tests = [];
$test = static function (string $name, callable $body) use (&$tests): void {
$tests[$name] = $body;
};
$check = static function (bool $condition, string $message = 'assertion failed'): void {
if (!$condition) {
throw new RuntimeException($message);
}
};
$key = base64_encode(str_repeat("\0", 32));
$test('credentials use the NordVPN access-token authorization scheme', static function () use ($check, $key): void {
$api = new NordApi(static function ($path, $query, $authorization) use ($check, $key): array {
$check($path === '/v1/users/services/credentials');
$check($query === []);
$check($authorization === 'Bearer token:synthetic-token');
return ['nordlynx_private_key' => $key];
});
$check($api->credentials('synthetic-token') === $key);
});
$test('recommendations apply the country allowlist', static function () use ($check, $key): void {
$requests = [];
$api = new NordApi(static function ($path, $query) use (&$requests, $key): array {
$requests[] = [$path, $query];
if ($path === '/v1/servers/countries') {
return [['id' => 99, 'code' => 'nl', 'name' => 'Netherlands']];
}
return [[
'hostname' => 'nl.example.invalid',
'station' => '198.51.100.10',
'locations' => [['country' => ['code' => 'nl']]],
'technologies' => [[
'id' => 35,
'pivot' => ['status' => 'online'],
'metadata' => [['name' => 'public_key', 'value' => $key]],
]],
]];
});
$servers = $api->recommendations(['NL']);
$check(count($servers) === 1);
$check($servers[0]['country'] === 'NL');
$check($requests[1][1]['filters[country_id]'] === 99);
$check($requests[1][1]['filters[servers_technologies]'] === 35);
});
$test('an empty country allowlist queries recommendations directly', static function () use ($check): void {
$api = new NordApi(static function ($path): array {
if ($path === '/v1/servers/countries') {
throw new RuntimeException('countries should not be queried');
}
return [];
});
$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'],
'public' => ['203.0.113.0/24'],
'nested' => ['private'],
]);
$check($inspector->includesPrivateDestinations('any', false));
$check($inspector->includesPrivateDestinations('nested', false));
$check(!$inspector->includesPrivateDestinations('public', false));
$check(!$inspector->includesPrivateDestinations('2001:db8::/32', false));
$check(!$inspector->includesPrivateDestinations('private', true));
$check($inspector->includesPrivateDestinations('10.0.0.0/8', true));
});
$test('status writes atomically and retains defaults', static function () use ($check): void {
$path = sys_get_temp_dir() . '/nordvpn-status-' . bin2hex(random_bytes(8)) . '.json';
$store = new StatusStore($path);
$check($store->read()['available'] === false);
$store->write(['available' => true, 'state' => 'available']);
$status = $store->read();
$check($status['available'] === true);
$check($status['updated_at'] > 0);
unlink($path);
});
$test('tunnel health requires a recent handshake', static function () use ($check): void {
$runner = new SystemRunner(static fn() => "public-key\t900\n");
$recent = new TunnelHealth($runner, 180, static fn() => 1000);
$stale = new TunnelHealth($runner, 50, static fn() => 1000);
$check($recent->check('wg0')['available']);
$check(!$stale->check('wg0')['available']);
$check(!$recent->check('invalid')['available']);
});
$test('WireGuard apply follows the OPNsense registration sequence', static function () use ($check): void {
$commands = [];
$runner = new SystemRunner(static function ($command) use (&$commands): string {
$commands[] = $command;
return '';
});
$runner->applyWireGuard();
$check($commands === [
['/usr/local/sbin/configctl', 'interface', 'invoke', 'registration'],
['/usr/local/sbin/configctl', 'template', 'reload', 'OPNsense/Wireguard'],
['/usr/local/opnsense/scripts/wireguard/wg-service-control.php', '-a', 'configure'],
]);
});
$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 {
$body();
echo "ok - {$name}\n";
} catch (Throwable $exception) {
$failures++;
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);