Manage NordLynx provisioning and failover
Create plugin-owned WireGuard, interface, and gateway objects, then retain healthy servers or reconcile through eligible recommendations until a handshake succeeds.
This commit is contained in:
parent
1018b9e69f
commit
6c4cdebdb1
2 changed files with 308 additions and 0 deletions
|
|
@ -0,0 +1,191 @@
|
||||||
|
<?php
|
||||||
|
|
||||||
|
// SPDX-License-Identifier: BSD-2-Clause
|
||||||
|
// SPDX-FileCopyrightText: 2026 ms2k
|
||||||
|
|
||||||
|
namespace OPNsense\NordVPN;
|
||||||
|
|
||||||
|
use OPNsense\Core\Config;
|
||||||
|
use OPNsense\Routing\Gateways;
|
||||||
|
use OPNsense\Wireguard\Client;
|
||||||
|
use OPNsense\Wireguard\General;
|
||||||
|
use OPNsense\Wireguard\Server;
|
||||||
|
|
||||||
|
class ManagedConfiguration
|
||||||
|
{
|
||||||
|
public const INTERFACE = 'nordvpn';
|
||||||
|
public const GATEWAY = 'NORDVPN_WG';
|
||||||
|
public const TUNNEL_ADDRESS = '10.5.0.2/16';
|
||||||
|
public const GATEWAY_ADDRESS = '10.5.0.1';
|
||||||
|
public const PORT = '51820';
|
||||||
|
private Settings $settings;
|
||||||
|
|
||||||
|
public function __construct(?Settings $settings = null)
|
||||||
|
{
|
||||||
|
$this->settings = $settings ?? new Settings();
|
||||||
|
}
|
||||||
|
|
||||||
|
public function provision(string $privateKey, array $candidate): void
|
||||||
|
{
|
||||||
|
$this->validateKey($privateKey, 'Invalid NordLynx private key.');
|
||||||
|
$this->validateCandidate($candidate);
|
||||||
|
|
||||||
|
$clients = new Client();
|
||||||
|
$peer = $this->managedNode($clients, 'clients.client', (string)$this->settings->managed->peer_uuid);
|
||||||
|
if ($peer === null) {
|
||||||
|
$this->assertNameAvailable($clients->clients->client->iterateItems(), 'NordVPN-managed-peer');
|
||||||
|
$peer = $clients->clients->client->add();
|
||||||
|
$this->settings->managed->peer_uuid = $peer->getAttribute('uuid');
|
||||||
|
}
|
||||||
|
$this->setPeer($peer, $candidate);
|
||||||
|
$clients->serializeToConfig();
|
||||||
|
|
||||||
|
$servers = new Server();
|
||||||
|
$instance = $this->managedNode($servers, 'servers.server', (string)$this->settings->managed->instance_uuid);
|
||||||
|
if ($instance === null) {
|
||||||
|
$this->assertNameAvailable($servers->servers->server->iterateItems(), 'NordVPN-managed');
|
||||||
|
$instance = $servers->servers->server->add();
|
||||||
|
$this->settings->managed->instance_uuid = $instance->getAttribute('uuid');
|
||||||
|
}
|
||||||
|
$instance->setNodes([
|
||||||
|
'enabled' => '1',
|
||||||
|
'name' => 'NordVPN-managed',
|
||||||
|
'privkey' => $privateKey,
|
||||||
|
'tunneladdress' => self::TUNNEL_ADDRESS,
|
||||||
|
'disableroutes' => '1',
|
||||||
|
'gateway' => self::GATEWAY_ADDRESS,
|
||||||
|
'peers' => (string)$this->settings->managed->peer_uuid,
|
||||||
|
]);
|
||||||
|
// WireGuard caches the peer relation before both new objects exist in this request.
|
||||||
|
$servers->serializeToConfig(false, true);
|
||||||
|
|
||||||
|
$general = new General();
|
||||||
|
$general->enabled = '1';
|
||||||
|
$general->serializeToConfig();
|
||||||
|
|
||||||
|
$this->ensureInterface((string)$instance->interface);
|
||||||
|
|
||||||
|
$gateways = new Gateways();
|
||||||
|
$gateway = $this->managedNode($gateways, 'gateway_item', (string)$this->settings->managed->gateway_uuid);
|
||||||
|
if ($gateway === null) {
|
||||||
|
$this->assertNameAvailable($gateways->gateway_item->iterateItems(), self::GATEWAY);
|
||||||
|
$gateway = $gateways->gateway_item->add();
|
||||||
|
$this->settings->managed->gateway_uuid = $gateway->getAttribute('uuid');
|
||||||
|
}
|
||||||
|
$gateway->setNodes([
|
||||||
|
'disabled' => '0',
|
||||||
|
'name' => self::GATEWAY,
|
||||||
|
'descr' => 'NordVPN managed WireGuard gateway',
|
||||||
|
'interface' => self::INTERFACE,
|
||||||
|
'ipprotocol' => 'inet',
|
||||||
|
'gateway' => self::GATEWAY_ADDRESS,
|
||||||
|
'fargw' => '0',
|
||||||
|
'monitor_disable' => '1',
|
||||||
|
'priority' => '255',
|
||||||
|
'weight' => '1',
|
||||||
|
]);
|
||||||
|
$gateways->serializeToConfig(false, true);
|
||||||
|
|
||||||
|
$this->settings->managed->server_hostname = $candidate['hostname'];
|
||||||
|
$this->settings->managed->server_country = $candidate['country'];
|
||||||
|
$this->settings->serializeToConfig();
|
||||||
|
Config::getInstance()->save(['description' => 'Update NordVPN managed tunnel']);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function updateCandidate(array $candidate): void
|
||||||
|
{
|
||||||
|
$this->validateCandidate($candidate);
|
||||||
|
$clients = new Client();
|
||||||
|
$peer = $this->managedNode($clients, 'clients.client', (string)$this->settings->managed->peer_uuid);
|
||||||
|
if ($peer === null) {
|
||||||
|
throw new \RuntimeException('The managed WireGuard peer is missing.');
|
||||||
|
}
|
||||||
|
$this->setPeer($peer, $candidate);
|
||||||
|
$clients->serializeToConfig();
|
||||||
|
$this->settings->managed->server_hostname = $candidate['hostname'];
|
||||||
|
$this->settings->managed->server_country = $candidate['country'];
|
||||||
|
$this->settings->serializeToConfig();
|
||||||
|
Config::getInstance()->save(['description' => 'Change NordVPN managed server']);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function interfaceName(): ?string
|
||||||
|
{
|
||||||
|
$servers = new Server();
|
||||||
|
$instance = $this->managedNode($servers, 'servers.server', (string)$this->settings->managed->instance_uuid);
|
||||||
|
return $instance === null ? null : (string)$instance->interface;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function settings(): Settings
|
||||||
|
{
|
||||||
|
return $this->settings;
|
||||||
|
}
|
||||||
|
|
||||||
|
private function setPeer($peer, array $candidate): void
|
||||||
|
{
|
||||||
|
$peer->setNodes([
|
||||||
|
'enabled' => '1',
|
||||||
|
'name' => 'NordVPN-managed-peer',
|
||||||
|
'pubkey' => $candidate['public_key'],
|
||||||
|
'tunneladdress' => '0.0.0.0/0',
|
||||||
|
'serveraddress' => $candidate['address'],
|
||||||
|
'serverport' => self::PORT,
|
||||||
|
'keepalive' => '25',
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
private function ensureInterface(string $device): void
|
||||||
|
{
|
||||||
|
$interfaces = Config::getInstance()->object()->interfaces;
|
||||||
|
$interface = $interfaces->{self::INTERFACE} ?? null;
|
||||||
|
if ($interface !== null && empty((string)$interface->internal_dynamic)) {
|
||||||
|
throw new \RuntimeException('The NordVPN interface identifier is already in use.');
|
||||||
|
}
|
||||||
|
if ($interface === null) {
|
||||||
|
$interface = $interfaces->addChild(self::INTERFACE);
|
||||||
|
}
|
||||||
|
$interface->internal_dynamic = '1';
|
||||||
|
$interface->descr = 'NordVPN';
|
||||||
|
$interface->enable = '1';
|
||||||
|
$interface->if = $device;
|
||||||
|
$interface->ipaddr = 'none';
|
||||||
|
$interface->ipaddrv6 = 'none';
|
||||||
|
$interface->networks = '10.5.0.0/16';
|
||||||
|
$interface->type = 'none';
|
||||||
|
$interface->virtual = '1';
|
||||||
|
}
|
||||||
|
|
||||||
|
private function managedNode($model, string $reference, string $uuid)
|
||||||
|
{
|
||||||
|
return $uuid === '' ? null : $model->getNodeByReference($reference . '.' . $uuid);
|
||||||
|
}
|
||||||
|
|
||||||
|
private function assertNameAvailable(iterable $nodes, string $name): void
|
||||||
|
{
|
||||||
|
foreach ($nodes as $node) {
|
||||||
|
if ((string)$node->name === $name) {
|
||||||
|
throw new \RuntimeException(sprintf('An unmanaged object named %s already exists.', $name));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private function validateCandidate(array $candidate): void
|
||||||
|
{
|
||||||
|
$hostname = (string)($candidate['hostname'] ?? '');
|
||||||
|
$address = (string)($candidate['address'] ?? '');
|
||||||
|
$country = (string)($candidate['country'] ?? '');
|
||||||
|
if (!filter_var($hostname, FILTER_VALIDATE_DOMAIN, FILTER_FLAG_HOSTNAME) ||
|
||||||
|
!filter_var($address, FILTER_VALIDATE_IP, FILTER_FLAG_IPV4) ||
|
||||||
|
!preg_match('/^[A-Z]{2}$/', $country)) {
|
||||||
|
throw new \InvalidArgumentException('Invalid NordVPN server candidate.');
|
||||||
|
}
|
||||||
|
$this->validateKey((string)($candidate['public_key'] ?? ''), 'Invalid NordVPN server public key.');
|
||||||
|
}
|
||||||
|
|
||||||
|
private function validateKey(string $key, string $message): void
|
||||||
|
{
|
||||||
|
$decoded = base64_decode($key, true);
|
||||||
|
if ($decoded === false || strlen($decoded) !== 32) {
|
||||||
|
throw new \InvalidArgumentException($message);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
117
src/opnsense/mvc/app/library/OPNsense/NordVPN/Reconciler.php
Normal file
117
src/opnsense/mvc/app/library/OPNsense/NordVPN/Reconciler.php
Normal file
|
|
@ -0,0 +1,117 @@
|
||||||
|
<?php
|
||||||
|
|
||||||
|
// SPDX-License-Identifier: BSD-2-Clause
|
||||||
|
// SPDX-FileCopyrightText: 2026 ms2k
|
||||||
|
|
||||||
|
namespace OPNsense\NordVPN;
|
||||||
|
|
||||||
|
class Reconciler
|
||||||
|
{
|
||||||
|
private NordApi $api;
|
||||||
|
private ManagedConfiguration $configuration;
|
||||||
|
private TunnelHealth $health;
|
||||||
|
private StatusStore $status;
|
||||||
|
private SystemRunner $runner;
|
||||||
|
private $pause;
|
||||||
|
|
||||||
|
public function __construct(
|
||||||
|
?NordApi $api = null,
|
||||||
|
?ManagedConfiguration $configuration = null,
|
||||||
|
?TunnelHealth $health = null,
|
||||||
|
?StatusStore $status = null,
|
||||||
|
?SystemRunner $runner = null,
|
||||||
|
?callable $pause = null
|
||||||
|
) {
|
||||||
|
$this->api = $api ?? new NordApi();
|
||||||
|
$this->configuration = $configuration ?? new ManagedConfiguration();
|
||||||
|
$this->runner = $runner ?? new SystemRunner();
|
||||||
|
$this->health = $health ?? new TunnelHealth($this->runner);
|
||||||
|
$this->status = $status ?? new StatusStore();
|
||||||
|
$this->pause = $pause ?? 'sleep';
|
||||||
|
}
|
||||||
|
|
||||||
|
public function run(): array
|
||||||
|
{
|
||||||
|
$settings = $this->configuration->settings();
|
||||||
|
$interface = $this->configuration->interfaceName();
|
||||||
|
if ($interface === null) {
|
||||||
|
return $this->publish([
|
||||||
|
'available' => false,
|
||||||
|
'state' => 'not_bootstrapped',
|
||||||
|
'message' => 'Bootstrap is required.',
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
$countryCodes = $this->values((string)$settings->general->countries);
|
||||||
|
$currentCountry = (string)$settings->managed->server_country;
|
||||||
|
$health = $this->health->check($interface);
|
||||||
|
if ($health['available'] && (empty($countryCodes) || in_array($currentCountry, $countryCodes, true))) {
|
||||||
|
return $this->publish(array_merge($health, [
|
||||||
|
'state' => 'available',
|
||||||
|
'server_hostname' => (string)$settings->managed->server_hostname,
|
||||||
|
'server_country' => $currentCountry,
|
||||||
|
'message' => '',
|
||||||
|
]));
|
||||||
|
}
|
||||||
|
|
||||||
|
$this->publish([
|
||||||
|
'available' => false,
|
||||||
|
'state' => 'reconciling',
|
||||||
|
'server_hostname' => (string)$settings->managed->server_hostname,
|
||||||
|
'server_country' => $currentCountry,
|
||||||
|
'last_handshake' => $health['last_handshake'],
|
||||||
|
'message' => 'Trying eligible NordVPN servers.',
|
||||||
|
]);
|
||||||
|
|
||||||
|
try {
|
||||||
|
$currentHostname = (string)$settings->managed->server_hostname;
|
||||||
|
foreach ($this->api->recommendations($countryCodes, 20) as $candidate) {
|
||||||
|
if ($candidate['hostname'] === $currentHostname) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
$this->configuration->updateCandidate($candidate);
|
||||||
|
$this->runner->applyWireGuard();
|
||||||
|
($this->pause)(5);
|
||||||
|
$health = $this->health->check((string)$this->configuration->interfaceName());
|
||||||
|
if ($health['available']) {
|
||||||
|
$this->runner->applyRouting();
|
||||||
|
return $this->publish(array_merge($health, [
|
||||||
|
'state' => 'available',
|
||||||
|
'server_hostname' => $candidate['hostname'],
|
||||||
|
'server_country' => $candidate['country'],
|
||||||
|
'message' => '',
|
||||||
|
]));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return $this->publish([
|
||||||
|
'available' => false,
|
||||||
|
'state' => 'unavailable',
|
||||||
|
'message' => 'No eligible NordVPN server established a handshake.',
|
||||||
|
]);
|
||||||
|
} catch (\Throwable $exception) {
|
||||||
|
return $this->publish([
|
||||||
|
'available' => false,
|
||||||
|
'state' => 'error',
|
||||||
|
'message' => $exception->getMessage(),
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private function publish(array $next): array
|
||||||
|
{
|
||||||
|
$previous = $this->status->read();
|
||||||
|
$this->status->write($next);
|
||||||
|
if ((bool)$previous['available'] !== (bool)$next['available']) {
|
||||||
|
$this->runner->applyFirewall();
|
||||||
|
}
|
||||||
|
return $this->status->read();
|
||||||
|
}
|
||||||
|
|
||||||
|
private function values(string $csv): array
|
||||||
|
{
|
||||||
|
return array_values(array_unique(array_filter(array_map(
|
||||||
|
static fn($value) => strtoupper(trim($value)),
|
||||||
|
explode(',', $csv)
|
||||||
|
))));
|
||||||
|
}
|
||||||
|
}
|
||||||
Loading…
Reference in a new issue