Expose NordVPN management in the WebGUI

Add settings, dynamic country selection, transient access-token bootstrap, tunnel status, RFC 1918 warnings, manual reconciliation, menu placement, and ACL coverage.
This commit is contained in:
ms2k 2026-08-02 23:17:52 +09:00
commit c1cb653b44
7 changed files with 320 additions and 0 deletions

View file

@ -0,0 +1,85 @@
<?php
// SPDX-License-Identifier: BSD-2-Clause
// SPDX-FileCopyrightText: 2026 ms2k
namespace OPNsense\NordVPN\Api;
use OPNsense\Base\ApiControllerBase;
use OPNsense\Core\Backend;
use OPNsense\NordVPN\EnrollmentInspector;
use OPNsense\NordVPN\ManagedConfiguration;
use OPNsense\NordVPN\NordApi;
use OPNsense\NordVPN\Reconciler;
use OPNsense\NordVPN\Settings;
use OPNsense\NordVPN\StatusStore;
use OPNsense\NordVPN\SystemRunner;
class ServiceController extends ApiControllerBase
{
public function bootstrapAction(): array
{
if (!$this->request->isPost()) {
return ['status' => 'failed', 'message' => 'POST is required.'];
}
$token = trim((string)($this->request->getPost('token') ?? ''));
try {
$api = new NordApi();
$settings = new Settings();
$countries = array_values(array_filter(explode(',', (string)$settings->general->countries)));
$candidates = $api->recommendations($countries, 20);
if (empty($candidates)) {
throw new \RuntimeException('NordVPN returned no eligible servers.');
}
$configuration = new ManagedConfiguration($settings);
$configuration->provision($api->credentials($token), $candidates[0]);
unset($token);
$runner = new SystemRunner();
(new StatusStore())->write([
'available' => false,
'state' => 'reconciling',
'server_hostname' => $candidates[0]['hostname'],
'server_country' => $candidates[0]['country'],
'message' => 'Waiting for the managed tunnel.',
]);
$runner->applyFirewall();
$runner->applyWireGuard();
$runner->applyRouting();
$runner->applyFirewall();
sleep(5);
$result = (new Reconciler($api, $configuration, null, null, $runner))->run();
return ['status' => 'ok', 'tunnel' => $result];
} catch (\Throwable $exception) {
unset($token);
return ['status' => 'failed', 'message' => 'Bootstrap failed. Check the token and plugin configuration.'];
}
}
public function countriesAction(): array
{
try {
return ['status' => 'ok', 'countries' => (new NordApi())->countries()];
} catch (\Throwable $exception) {
return ['status' => 'failed', 'message' => $exception->getMessage(), 'countries' => []];
}
}
public function statusAction(): array
{
$settings = new Settings();
$warning = (new EnrollmentInspector())->includesPrivateDestinations(
(string)$settings->general->destination,
(string)$settings->general->destination_not === '1'
);
return ['status' => 'ok', 'tunnel' => (new StatusStore())->read(), 'rfc1918_warning' => $warning];
}
public function reconcileAction(): array
{
if (!$this->request->isPost()) {
return ['status' => 'failed'];
}
(new Backend())->configdRun('nordvpn reconcile', true);
return ['status' => 'ok'];
}
}

View file

@ -0,0 +1,28 @@
<?php
// SPDX-License-Identifier: BSD-2-Clause
// SPDX-FileCopyrightText: 2026 ms2k
namespace OPNsense\NordVPN\Api;
use OPNsense\Base\ApiMutableModelControllerBase;
use OPNsense\Core\Backend;
class SettingsController extends ApiMutableModelControllerBase
{
protected static $internalModelName = 'settings';
protected static $internalModelClass = 'OPNsense\NordVPN\Settings';
public function reconfigureAction()
{
if (!$this->request->isPost()) {
return ['status' => 'failed'];
}
$backend = new Backend();
$backend->configdRun('cron restart');
$backend->configdRun('filter reload');
$backend->configdRun('nordvpn reconcile', true);
return ['status' => 'ok'];
}
}

View file

@ -0,0 +1,15 @@
<?php
// SPDX-License-Identifier: BSD-2-Clause
// SPDX-FileCopyrightText: 2026 ms2k
namespace OPNsense\NordVPN;
class IndexController extends \OPNsense\Base\IndexController
{
public function indexAction()
{
$this->view->settingsForm = $this->getForm('settings');
$this->view->pick('OPNsense/NordVPN/index');
}
}

View file

@ -0,0 +1,65 @@
<form>
<field>
<type>header</type>
<label>General</label>
</field>
<field>
<id>settings.general.countries</id>
<label>Eligible countries</label>
<type>select_multiple</type>
<style>tokenize</style>
<help>Leave empty to permit every country returned by NordVPN recommendations.</help>
</field>
<field>
<type>header</type>
<label>NordVPN egress enrollment</label>
</field>
<field>
<id>settings.general.interfaces</id>
<label>Interfaces</label>
<type>select_multiple</type>
</field>
<field>
<id>settings.general.source</id>
<label>Source</label>
<type>select_multiple</type>
<style>tokenize</style>
</field>
<field>
<id>settings.general.source_not</id>
<label>Invert source</label>
<type>checkbox</type>
</field>
<field>
<id>settings.general.destination</id>
<label>Destination</label>
<type>select_multiple</type>
<style>tokenize</style>
</field>
<field>
<id>settings.general.destination_not</id>
<label>Invert destination</label>
<type>checkbox</type>
</field>
<field>
<type>header</type>
<label>Failure and direct egress</label>
</field>
<field>
<id>settings.general.direct_gateway</id>
<label>Direct egress gateway</label>
<type>dropdown</type>
<help>Leave empty to use the system default gateway.</help>
</field>
<field>
<id>settings.general.failure_policy</id>
<label>Failure policy</label>
<type>dropdown</type>
</field>
<field>
<id>settings.general.exempt_wireguard</id>
<label>Exempt UDP port 51820</label>
<type>checkbox</type>
<help>Route outbound UDP destination port 51820 through the direct egress gateway.</help>
</field>
</form>

View file

@ -0,0 +1,9 @@
<acl>
<page-nordvpn>
<name>VPN: NordVPN</name>
<patterns>
<pattern>ui/nordvpn/*</pattern>
<pattern>api/nordvpn/*</pattern>
</patterns>
</page-nordvpn>
</acl>

View file

@ -0,0 +1,5 @@
<menu>
<VPN order="70" cssClass="fa fa-lock">
<NordVPN order="40" url="/ui/nordvpn" cssClass="fa fa-random fa-fw"/>
</VPN>
</menu>

View file

@ -0,0 +1,113 @@
<script>
$(document).ready(function() {
function loadSettings() {
ajaxCall('/api/nordvpn/service/countries', {}, function(data) {
if (data.status === 'ok') {
const field = $('#settings\\.general\\.countries');
$.each(data.countries, function(_, country) {
field.append($('<option>', {value: country.code, text: country.name}));
});
}
mapDataToFormUI({'frm_NordVPNSettings': '/api/nordvpn/settings/get'}).done(function() {
formatTokenizersUI();
$('.selectpicker').selectpicker('refresh');
});
});
}
function refreshStatus() {
ajaxCall('/api/nordvpn/service/status', {}, function(data) {
if (data.status !== 'ok') {
return;
}
const tunnel = data.tunnel;
$('#tunnelState').text(tunnel.state || 'unavailable');
$('#tunnelServer').text(tunnel.server_hostname || '-');
$('#tunnelCountry').text(tunnel.server_country || '-');
$('#tunnelMessage').text(tunnel.message || '');
$('#rfc1918Warning').toggleClass('hidden', !data.rfc1918_warning);
});
}
$('#applyAct').click(function() {
$('#applyAct').addClass('disabled');
$('#applyAct_progress').addClass('fa fa-spinner fa-pulse');
saveFormToEndpoint('/api/nordvpn/settings/set', 'frm_NordVPNSettings', function() {
ajaxCall('/api/nordvpn/settings/reconfigure', {}, function(data) {
$('#responseMsg').toggleClass('hidden', data.status !== 'ok');
const message = data.status === 'ok' ?
'{{ lang._("Settings applied") }}' : '{{ lang._("Apply failed") }}';
$('#responseMsg').text(message);
$('#applyAct').removeClass('disabled');
$('#applyAct_progress').removeClass('fa fa-spinner fa-pulse');
refreshStatus();
});
});
});
$('#bootstrapAct').click(function() {
const token = $('#accessToken').val();
$('#accessToken').val('');
$('#bootstrapAct').addClass('disabled');
$('#bootstrap_progress').addClass('fa fa-spinner fa-pulse');
ajaxCall('/api/nordvpn/service/bootstrap', {token: token}, function(data) {
$('#bootstrapMsg').removeClass('hidden alert-success alert-danger');
$('#bootstrapMsg').addClass(data.status === 'ok' ? 'alert-success' : 'alert-danger');
$('#bootstrapMsg').text(data.status === 'ok' ? '{{ lang._("Bootstrap completed") }}' : data.message);
$('#bootstrapAct').removeClass('disabled');
$('#bootstrap_progress').removeClass('fa fa-spinner fa-pulse');
refreshStatus();
});
});
$('#reconcileAct').click(function() {
ajaxCall('/api/nordvpn/service/reconcile', {}, function() {
window.setTimeout(refreshStatus, 2000);
});
});
loadSettings();
refreshStatus();
});
</script>
<div class="alert alert-info hidden" role="alert" id="responseMsg"></div>
<div class="alert alert-warning hidden" role="alert" id="rfc1918Warning">
{{ lang._('NordVPN egress enrollment can include an RFC 1918 destination address.') }}
</div>
<div class="content-box">
{{ partial('layout_partials/base_form', ['fields': settingsForm, 'id': 'frm_NordVPNSettings']) }}
<hr/>
<button class="btn btn-primary" id="applyAct" type="button">
<b>{{ lang._('Apply') }}</b> <i id="applyAct_progress"></i>
</button>
</div>
<div class="content-box">
<div class="table-responsive">
<table class="table table-striped">
<tbody>
<tr><th>{{ lang._('State') }}</th><td id="tunnelState">-</td></tr>
<tr><th>{{ lang._('Server') }}</th><td id="tunnelServer">-</td></tr>
<tr><th>{{ lang._('Country') }}</th><td id="tunnelCountry">-</td></tr>
<tr><th>{{ lang._('Message') }}</th><td id="tunnelMessage"></td></tr>
</tbody>
</table>
</div>
<button class="btn btn-default" id="reconcileAct" type="button">{{ lang._('Reconcile now') }}</button>
</div>
<div class="content-box">
<div class="form-group">
<label for="accessToken">{{ lang._('NordVPN Access Token') }}</label>
<input class="form-control" id="accessToken" type="password" autocomplete="off"/>
<span class="help-block">
{{ lang._('The token is used only for this bootstrap request and is not stored.') }}
</span>
</div>
<button class="btn btn-primary" id="bootstrapAct" type="button">
<b>{{ lang._('Bootstrap NordLynx') }}</b> <i id="bootstrap_progress"></i>
</button>
<div class="alert hidden" role="alert" id="bootstrapMsg"></div>
</div>