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:
ms2k 2026-08-02 23:17:18 +09:00
commit 6c4cdebdb1
2 changed files with 308 additions and 0 deletions

View file

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

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