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
6 changed files with 614 additions and 0 deletions

View file

@ -0,0 +1,137 @@
<?php
// SPDX-License-Identifier: BSD-2-Clause
// SPDX-FileCopyrightText: 2026 ms2k
namespace OPNsense\NordVPN;
class EnrollmentInspector
{
private const PRIVATE_NETWORKS = ['10.0.0.0/8', '172.16.0.0/12', '192.168.0.0/16'];
private array $aliases;
public function __construct(?array $aliases = null)
{
$this->aliases = $aliases ?? $this->loadAliases();
}
public function includesPrivateDestinations(string $destination, bool $inverted): bool
{
$items = $this->expand($this->split($destination));
if ($inverted) {
foreach (self::PRIVATE_NETWORKS as $privateNetwork) {
if (!$this->isCovered($privateNetwork, $items)) {
return true;
}
}
return false;
}
foreach ($items as $item) {
if ($item === 'any') {
return true;
}
if ($this->isIpv6Network($item)) {
continue;
}
if (!$this->isIpv4Network($item)) {
return true;
}
foreach (self::PRIVATE_NETWORKS as $privateNetwork) {
if ($this->overlaps($item, $privateNetwork)) {
return true;
}
}
}
return false;
}
private function loadAliases(): array
{
if (!class_exists('\OPNsense\Firewall\Alias')) {
return [];
}
$result = [];
foreach ((new \OPNsense\Firewall\Alias(true))->aliasIterator() as $alias) {
if (($alias['type'] ?? '') !== 'port') {
$result[(string)$alias['name']] = array_map('strval', $alias['content'] ?? []);
}
}
return $result;
}
private function expand(array $items, array $seen = []): array
{
$result = [];
foreach ($items as $item) {
if (isset($this->aliases[$item]) && !isset($seen[$item])) {
if (empty($this->aliases[$item])) {
$result[] = $item;
continue;
}
$nextSeen = $seen;
$nextSeen[$item] = true;
$result = array_merge($result, $this->expand($this->aliases[$item], $nextSeen));
} else {
$result[] = $item;
}
}
return array_values(array_unique($result));
}
private function split(string $value): array
{
$items = array_values(array_filter(array_map('trim', explode(',', $value)), 'strlen'));
return empty($items) ? ['any'] : $items;
}
private function isCovered(string $network, array $coveringNetworks): bool
{
foreach ($coveringNetworks as $coveringNetwork) {
if ($coveringNetwork === 'any') {
return true;
}
if ($this->isIpv4Network($coveringNetwork) && $this->contains($coveringNetwork, $network)) {
return true;
}
}
return false;
}
private function overlaps(string $left, string $right): bool
{
[$leftStart, $leftEnd] = $this->range($left);
[$rightStart, $rightEnd] = $this->range($right);
return $leftStart <= $rightEnd && $rightStart <= $leftEnd;
}
private function contains(string $container, string $network): bool
{
[$containerStart, $containerEnd] = $this->range($container);
[$networkStart, $networkEnd] = $this->range($network);
return $containerStart <= $networkStart && $containerEnd >= $networkEnd;
}
private function range(string $network): array
{
[$address, $prefix] = array_pad(explode('/', $network, 2), 2, '32');
$address = (int)sprintf('%u', ip2long($address));
$mask = (int)$prefix === 0 ? 0 : (0xffffffff << (32 - (int)$prefix)) & 0xffffffff;
$start = $address & $mask;
return [$start, $start | (~$mask & 0xffffffff)];
}
private function isIpv4Network(string $network): bool
{
[$address, $prefix] = array_pad(explode('/', $network, 2), 2, '32');
return filter_var($address, FILTER_VALIDATE_IP, FILTER_FLAG_IPV4) !== false &&
ctype_digit($prefix) && (int)$prefix >= 0 && (int)$prefix <= 32;
}
private function isIpv6Network(string $network): bool
{
[$address, $prefix] = array_pad(explode('/', $network, 2), 2, '128');
return filter_var($address, FILTER_VALIDATE_IP, FILTER_FLAG_IPV6) !== false &&
ctype_digit($prefix) && (int)$prefix >= 0 && (int)$prefix <= 128;
}
}

View file

@ -0,0 +1,185 @@
<?php
// SPDX-License-Identifier: BSD-2-Clause
// SPDX-FileCopyrightText: 2026 ms2k
namespace OPNsense\NordVPN;
class NordApi
{
private const BASE_URL = 'https://api.nordvpn.com';
private const WIREGUARD_TECHNOLOGY_ID = 35;
private $transport;
public function __construct(?callable $transport = null)
{
$this->transport = $transport;
}
public function credentials(string $token): string
{
if ($token === '') {
throw new \InvalidArgumentException('A NordVPN Access Token is required.');
}
$data = $this->request('/v1/users/services/credentials', [], 'Bearer token:' . $token);
$key = trim((string)($data['nordlynx_private_key'] ?? ''));
$this->validateWireGuardKey($key, 'NordVPN returned an invalid NordLynx private key.');
return $key;
}
public function countries(): array
{
$result = [];
foreach ($this->request('/v1/servers/countries') as $country) {
$code = strtoupper((string)($country['code'] ?? ''));
$name = trim((string)($country['name'] ?? ''));
$id = filter_var($country['id'] ?? null, FILTER_VALIDATE_INT);
if ($id !== false && preg_match('/^[A-Z]{2}$/', $code) && $name !== '') {
$result[$code] = ['id' => $id, 'code' => $code, 'name' => $name];
}
}
ksort($result);
return array_values($result);
}
public function recommendations(array $countryCodes = [], int $limit = 5): array
{
$limit = max(1, min(20, $limit));
$countryIds = [];
if (!empty($countryCodes)) {
$countriesByCode = [];
foreach ($this->countries() as $country) {
$countriesByCode[$country['code']] = $country;
}
foreach ($countryCodes as $code) {
$code = strtoupper(trim((string)$code));
if (!isset($countriesByCode[$code])) {
throw new \RuntimeException(sprintf('NordVPN does not recognize country %s.', $code));
}
$countryIds[$code] = $countriesByCode[$code]['id'];
}
}
$queries = empty($countryIds) ? [['limit' => $limit]] : [];
foreach ($countryIds as $id) {
$queries[] = ['limit' => $limit, 'filters[country_id]' => $id];
}
$result = [];
foreach ($queries as $query) {
$query['filters[servers.status]'] = 'online';
$query['filters[servers_technologies]'] = self::WIREGUARD_TECHNOLOGY_ID;
$query['filters[servers_technologies][pivot][status]'] = 'online';
foreach ($this->request('/v1/servers/recommendations', $query) as $server) {
$candidate = $this->candidate($server);
if ($candidate !== null) {
$result[$candidate['hostname']] = $candidate;
}
}
}
return array_slice(array_values($result), 0, $limit);
}
private function candidate(array $server): ?array
{
$hostname = strtolower(trim((string)($server['hostname'] ?? '')));
$address = trim((string)($server['station'] ?? ''));
if (!filter_var($hostname, FILTER_VALIDATE_DOMAIN, FILTER_FLAG_HOSTNAME) ||
!filter_var($address, FILTER_VALIDATE_IP, FILTER_FLAG_IPV4)) {
return null;
}
$country = '';
foreach (($server['locations'] ?? []) as $location) {
$country = strtoupper((string)($location['country']['code'] ?? ''));
if (preg_match('/^[A-Z]{2}$/', $country)) {
break;
}
}
$publicKey = '';
foreach (($server['technologies'] ?? []) as $technology) {
if ((int)($technology['id'] ?? 0) !== self::WIREGUARD_TECHNOLOGY_ID ||
($technology['pivot']['status'] ?? '') !== 'online') {
continue;
}
foreach (($technology['metadata'] ?? []) as $metadata) {
if (($metadata['name'] ?? '') === 'public_key') {
$publicKey = trim((string)($metadata['value'] ?? ''));
break 2;
}
}
}
if (!preg_match('/^[A-Z]{2}$/', $country)) {
return null;
}
try {
$this->validateWireGuardKey($publicKey, '');
} catch (\RuntimeException $exception) {
return null;
}
return [
'hostname' => $hostname,
'address' => $address,
'country' => $country,
'public_key' => $publicKey,
];
}
private function validateWireGuardKey(string $key, string $message): void
{
$decoded = base64_decode($key, true);
if ($decoded === false || strlen($decoded) !== 32) {
throw new \RuntimeException($message);
}
}
private function request(string $path, array $query = [], ?string $authorization = null): array
{
if ($this->transport !== null) {
$response = ($this->transport)($path, $query, $authorization);
if (!is_array($response)) {
throw new \RuntimeException('NordVPN returned an invalid response.');
}
return $response;
}
$url = self::BASE_URL . $path;
if (!empty($query)) {
$url .= '?' . http_build_query($query);
}
$headers = ['Accept: application/json', 'User-Agent: OPNsense-NordVPN'];
if ($authorization !== null) {
$headers[] = 'Authorization: ' . $authorization;
}
$handle = curl_init($url);
curl_setopt_array($handle, [
CURLOPT_HTTPHEADER => $headers,
CURLOPT_RETURNTRANSFER => true,
CURLOPT_CONNECTTIMEOUT => 5,
CURLOPT_TIMEOUT => 15,
CURLOPT_FOLLOWLOCATION => false,
CURLOPT_PROTOCOLS => CURLPROTO_HTTPS,
CURLOPT_SSL_VERIFYPEER => true,
CURLOPT_SSL_VERIFYHOST => 2,
]);
$body = curl_exec($handle);
$status = curl_getinfo($handle, CURLINFO_RESPONSE_CODE);
$error = curl_error($handle);
curl_close($handle);
if ($body === false || $status < 200 || $status >= 300) {
throw new \RuntimeException($error !== '' ? $error : sprintf('NordVPN API returned HTTP %d.', $status));
}
if (strlen($body) > 8 * 1024 * 1024) {
throw new \RuntimeException('NordVPN returned an oversized response.');
}
$data = json_decode($body, true, 128, JSON_THROW_ON_ERROR);
if (!is_array($data)) {
throw new \RuntimeException('NordVPN returned an invalid response.');
}
return $data;
}
}

View file

@ -0,0 +1,59 @@
<?php
// SPDX-License-Identifier: BSD-2-Clause
// SPDX-FileCopyrightText: 2026 ms2k
namespace OPNsense\NordVPN;
class StatusStore
{
private string $path;
public function __construct(string $path = '/var/run/nordvpn.json')
{
$this->path = $path;
}
public function read(): array
{
$data = is_file($this->path) ? json_decode((string)file_get_contents($this->path), true) : null;
return is_array($data) ? $data : $this->defaults();
}
public function write(array $status): void
{
$data = array_merge($this->defaults(), $this->read(), $status, ['updated_at' => time()]);
$directory = dirname($this->path);
if (!is_dir($directory)) {
throw new \RuntimeException('Status directory does not exist.');
}
$temporary = tempnam($directory, '.nordvpn.');
if ($temporary === false) {
throw new \RuntimeException('Unable to create status file.');
}
chmod($temporary, 0600);
try {
$encoded = json_encode($data, JSON_THROW_ON_ERROR | JSON_UNESCAPED_SLASHES);
if (file_put_contents($temporary, $encoded . "\n", LOCK_EX) === false || !rename($temporary, $this->path)) {
throw new \RuntimeException('Unable to write status file.');
}
} finally {
if (is_file($temporary)) {
unlink($temporary);
}
}
}
private function defaults(): array
{
return [
'available' => false,
'state' => 'unavailable',
'server_hostname' => '',
'server_country' => '',
'last_handshake' => 0,
'message' => '',
'updated_at' => 0,
];
}
}

View file

@ -0,0 +1,55 @@
<?php
// SPDX-License-Identifier: BSD-2-Clause
// SPDX-FileCopyrightText: 2026 ms2k
namespace OPNsense\NordVPN;
class SystemRunner
{
private $executor;
public function __construct(?callable $executor = null)
{
$this->executor = $executor;
}
public function run(array $command): string
{
if ($this->executor !== null) {
return (string)($this->executor)($command);
}
$pipes = [];
$process = proc_open($command, [1 => ['pipe', 'w'], 2 => ['pipe', 'w']], $pipes);
if (!is_resource($process)) {
throw new \RuntimeException('Unable to start system command.');
}
$stdout = stream_get_contents($pipes[1]);
$stderr = stream_get_contents($pipes[2]);
fclose($pipes[1]);
fclose($pipes[2]);
$status = proc_close($process);
if ($status !== 0) {
throw new \RuntimeException(trim($stderr) ?: sprintf('System command exited with status %d.', $status));
}
return trim($stdout);
}
public function applyWireGuard(): void
{
$this->run(['/usr/local/sbin/configctl', 'interface', 'invoke', 'registration']);
$this->run(['/usr/local/sbin/configctl', 'template', 'reload', 'OPNsense/Wireguard']);
$this->run(['/usr/local/opnsense/scripts/wireguard/wg-service-control.php', '-a', 'configure']);
}
public function applyRouting(): void
{
$this->run(['/usr/local/etc/rc.routing_configure']);
}
public function applyFirewall(): void
{
$this->run(['/usr/local/etc/rc.filter_configure']);
}
}

View file

@ -0,0 +1,46 @@
<?php
// SPDX-License-Identifier: BSD-2-Clause
// SPDX-FileCopyrightText: 2026 ms2k
namespace OPNsense\NordVPN;
class TunnelHealth
{
private SystemRunner $runner;
private int $maximumAge;
private $clock;
public function __construct(SystemRunner $runner, int $maximumAge = 180, ?callable $clock = null)
{
$this->runner = $runner;
$this->maximumAge = $maximumAge;
$this->clock = $clock ?? 'time';
}
public function check(string $interface): array
{
if (!preg_match('/^wg[0-9]+$/', $interface)) {
return ['available' => false, 'last_handshake' => 0];
}
try {
$output = $this->runner->run(['/usr/bin/wg', 'show', $interface, 'latest-handshakes']);
} catch (\RuntimeException $exception) {
return ['available' => false, 'last_handshake' => 0];
}
$latest = 0;
foreach (preg_split('/\R/', $output) as $line) {
$parts = preg_split('/\s+/', trim($line));
if (count($parts) === 2 && ctype_digit($parts[1])) {
$latest = max($latest, (int)$parts[1]);
}
}
$clock = $this->clock;
$now = (int)$clock();
return [
'available' => $latest > 0 && $latest <= $now && $now - $latest <= $this->maximumAge,
'last_handshake' => $latest,
];
}
}

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