2026-08-02 23:17:05 +09:00
|
|
|
<?php
|
|
|
|
|
|
|
|
|
|
// SPDX-License-Identifier: BSD-2-Clause
|
|
|
|
|
// SPDX-FileCopyrightText: 2026 ms2k
|
|
|
|
|
|
|
|
|
|
require_once __DIR__ . '/../src/opnsense/mvc/app/library/OPNsense/NordVPN/NordApi.php';
|
2026-08-03 01:04:11 +09:00
|
|
|
require_once __DIR__ . '/../src/opnsense/mvc/app/library/OPNsense/NordVPN/EndpointStore.php';
|
2026-08-02 23:17:05 +09:00
|
|
|
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;
|
2026-08-03 01:04:11 +09:00
|
|
|
use OPNsense\NordVPN\EndpointStore;
|
2026-08-02 23:17:05 +09:00
|
|
|
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([]) === []);
|
|
|
|
|
});
|
|
|
|
|
|
2026-08-03 01:04:11 +09:00
|
|
|
$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);
|
|
|
|
|
});
|
|
|
|
|
|
2026-08-02 23:17:05 +09:00
|
|
|
$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'],
|
|
|
|
|
]);
|
|
|
|
|
});
|
|
|
|
|
|
2026-08-03 01:04:11 +09:00
|
|
|
$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],
|
|
|
|
|
]);
|
|
|
|
|
});
|
|
|
|
|
|
2026-08-02 23:17:05 +09:00
|
|
|
$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");
|
|
|
|
|
}
|
|
|
|
|
}
|
2026-08-03 01:04:11 +09:00
|
|
|
$command = escapeshellarg(PHP_BINARY) . ' ' . escapeshellarg(__DIR__ . '/reconciler.php');
|
|
|
|
|
passthru($command, $status);
|
|
|
|
|
if ($status !== 0) {
|
|
|
|
|
$failures++;
|
|
|
|
|
}
|
2026-08-02 23:17:05 +09:00
|
|
|
exit($failures === 0 ? 0 : 1);
|