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:
parent
464388a250
commit
c1cb653b44
7 changed files with 320 additions and 0 deletions
|
|
@ -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'];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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'];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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>
|
||||||
9
src/opnsense/mvc/app/models/OPNsense/NordVPN/ACL/ACL.xml
Normal file
9
src/opnsense/mvc/app/models/OPNsense/NordVPN/ACL/ACL.xml
Normal 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>
|
||||||
|
|
@ -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>
|
||||||
113
src/opnsense/mvc/app/views/OPNsense/NordVPN/index.volt
Normal file
113
src/opnsense/mvc/app/views/OPNsense/NordVPN/index.volt
Normal 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>
|
||||||
Loading…
Reference in a new issue