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