general = (object)['countries' => '']; $this->managed = (object)[ 'server_country' => 'NL', 'server_hostname' => 'nl.example.invalid', ]; } } $root = dirname(__DIR__); foreach (['NordApi', 'EndpointStore', 'StatusStore', 'SystemRunner', 'TunnelHealth', 'ManagedConfiguration', 'Reconciler'] as $class) { require_once $root . '/src/opnsense/mvc/app/library/OPNsense/NordVPN/' . $class . '.php'; } class TestConfiguration extends ManagedConfiguration { private Settings $testSettings; public function __construct() { $this->testSettings = new Settings(); } public function settings(): Settings { return $this->testSettings; } public function interfaceName(): ?string { return 'wg0'; } public function repairGatewayInterface(): bool { return false; } } class AvailableHealth extends TunnelHealth { public function __construct() { } public function check(string $interface): array { return ['available' => true, 'last_handshake' => 1000]; } } class FailOnceRunner extends SystemRunner { public int $firewallCalls = 0; public function applyFirewall(): void { $this->firewallCalls++; if ($this->firewallCalls === 1) { throw new \RuntimeException('Synthetic firewall failure.'); } } } class RepairConfiguration extends TestConfiguration { public function repairGatewayInterface(): bool { return true; } } class RepairRunner extends SystemRunner { public int $routingCalls = 0; public int $firewallCalls = 0; public function applyRouting(): void { $this->routingCalls++; } public function applyFirewall(): void { $this->firewallCalls++; } } class FailoverConfiguration extends TestConfiguration { public bool $candidateUpdated = false; public int $repairCalls = 0; public function updateCandidate(array $candidate): void { $this->candidateUpdated = true; } public function repairGatewayInterface(): bool { if (!$this->candidateUpdated) { return false; } $this->candidateUpdated = false; $this->repairCalls++; return true; } } class RecoveringHealth extends TunnelHealth { private int $checks = 0; public function __construct() { } public function check(string $interface): array { $this->checks++; return ['available' => $this->checks > 1, 'last_handshake' => $this->checks > 1 ? 1000 : 0]; } } class FailoverRunner extends SystemRunner { public int $wireGuardCalls = 0; public int $routingCalls = 0; public int $firewallCalls = 0; public function applyWireGuard(): void { $this->wireGuardCalls++; } public function applyRouting(): void { $this->routingCalls++; } public function applyFirewall(): void { $this->firewallCalls++; } } $repairPath = sys_get_temp_dir() . '/nordvpn-repair-' . bin2hex(random_bytes(8)) . '.json'; $repairRunner = new RepairRunner(); (new Reconciler( new NordApi(static fn() => []), new RepairConfiguration(), new AvailableHealth(), new StatusStore($repairPath), $repairRunner ))->run(); if ($repairRunner->routingCalls !== 1 || $repairRunner->firewallCalls !== 2) { throw new \RuntimeException('Gateway interface repair was not applied.'); } unlink($repairPath); echo "ok - reconciliation applies gateway interface repair\n"; $failoverPath = sys_get_temp_dir() . '/nordvpn-failover-' . bin2hex(random_bytes(8)) . '.json'; $failoverConfiguration = new FailoverConfiguration(); $failoverRunner = new FailoverRunner(); $publicKey = base64_encode(str_repeat("\0", 32)); (new Reconciler( new NordApi(static fn() => [[ 'hostname' => 'nl2.example.invalid', 'station' => '198.51.100.2', 'locations' => [['country' => ['code' => 'NL']]], 'technologies' => [[ 'id' => 35, 'pivot' => ['status' => 'online'], 'metadata' => [['name' => 'public_key', 'value' => $publicKey]], ]], ]]), $failoverConfiguration, new RecoveringHealth(), new StatusStore($failoverPath), $failoverRunner, static fn($seconds) => null ))->run(); if ($failoverRunner->wireGuardCalls !== 1 || $failoverConfiguration->repairCalls !== 1 || $failoverRunner->routingCalls !== 1 || $failoverRunner->firewallCalls !== 1) { throw new \RuntimeException('Server failover did not repair the gateway interface.'); } unlink($failoverPath); echo "ok - server failover repairs the gateway interface\n"; $path = sys_get_temp_dir() . '/nordvpn-retry-' . bin2hex(random_bytes(8)) . '.json'; $status = new StatusStore($path); $runner = new FailOnceRunner(); $reconciler = new Reconciler( new NordApi(static fn() => []), new TestConfiguration(), new AvailableHealth(), $status, $runner ); try { $reconciler->run(); throw new \RuntimeException('The first firewall failure was not reported.'); } catch (\RuntimeException $exception) { if ($exception->getMessage() !== 'Synthetic firewall failure.') { throw $exception; } } if (empty($status->read()['firewall_pending'])) { throw new \RuntimeException('The failed firewall apply was not retained as pending.'); } $reconciler->run(); if ($runner->firewallCalls !== 2 || !empty($status->read()['firewall_pending'])) { throw new \RuntimeException('The pending firewall apply was not completed on retry.'); } unlink($path); echo "ok - reconciliation retries a failed firewall apply\n";