Add NordVPN discovery and tunnel health primitives

Implement authenticated credential vending, country-filtered recommendations, atomic status storage, WireGuard health checks, native apply sequencing, RFC 1918 enrollment inspection, and synthetic tests.
This commit is contained in:
ms2k 2026-08-02 23:17:05 +09:00
commit 1018b9e69f

132
tests/run.php Normal file
View file

@ -0,0 +1,132 @@
<?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/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\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('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'],
]);
});
$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");
}
}
exit($failures === 0 ? 0 : 1);