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:
parent
7184f595ba
commit
1018b9e69f
6 changed files with 614 additions and 0 deletions
|
|
@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
185
src/opnsense/mvc/app/library/OPNsense/NordVPN/NordApi.php
Normal file
185
src/opnsense/mvc/app/library/OPNsense/NordVPN/NordApi.php
Normal 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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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,
|
||||||
|
];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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']);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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
132
tests/run.php
Normal 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);
|
||||||
Loading…
Reference in a new issue