Firewall: NAT: Merge npt_rule, onat_rule, snat_rule, dnat_rule into a single nat_rule template (#9855)

Most notable differences that are decided by the controller providing an "entrypoint" and "categoryKey" variable:
- controller name (entrypoint)
- category key (category vs categories)

Other subtle differences:
- enabled/disabled key (dnat is disabled)
- DNAT local port cannot do port ranges so the list_port_select_options reflect that

The functionality should be the same as before with less code as the same view template can be shared by all NAT controllers.
This commit is contained in:
Monviech 2026-02-24 09:58:16 +01:00 committed by GitHub
parent 1156faee1d
commit 6bd26c248b
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
9 changed files with 106 additions and 1814 deletions

5
plist
View file

@ -968,13 +968,10 @@
/usr/local/opnsense/mvc/app/views/OPNsense/Firewall/alias.volt
/usr/local/opnsense/mvc/app/views/OPNsense/Firewall/alias_util.volt
/usr/local/opnsense/mvc/app/views/OPNsense/Firewall/category.volt
/usr/local/opnsense/mvc/app/views/OPNsense/Firewall/dnat_rule.volt
/usr/local/opnsense/mvc/app/views/OPNsense/Firewall/filter_rule.volt
/usr/local/opnsense/mvc/app/views/OPNsense/Firewall/firewall_migration.volt
/usr/local/opnsense/mvc/app/views/OPNsense/Firewall/group.volt
/usr/local/opnsense/mvc/app/views/OPNsense/Firewall/npt_rule.volt
/usr/local/opnsense/mvc/app/views/OPNsense/Firewall/onat_rule.volt
/usr/local/opnsense/mvc/app/views/OPNsense/Firewall/snat_rule.volt
/usr/local/opnsense/mvc/app/views/OPNsense/Firewall/nat_rule.volt
/usr/local/opnsense/mvc/app/views/OPNsense/Hostdiscovery/settings.volt
/usr/local/opnsense/mvc/app/views/OPNsense/IDS/index.volt
/usr/local/opnsense/mvc/app/views/OPNsense/IDS/policy.volt

View file

@ -31,8 +31,10 @@ class DNatController extends \OPNsense\Base\IndexController
{
public function indexAction()
{
$this->view->pick('OPNsense/Firewall/dnat_rule');
$this->view->formDialogDNatRule = $this->getForm("dialogDNatRule");
$this->view->formGridDNatRule = $this->getFormGrid('dialogDNatRule');
$this->view->entrypoint = 'd_nat';
$this->view->categoryKey = 'category';
$this->view->pick('OPNsense/Firewall/nat_rule');
$this->view->formDialogRule = $this->getForm('dialogDNatRule');
$this->view->formGridRule = $this->getFormGrid('dialogDNatRule');
}
}

View file

@ -31,8 +31,10 @@ class NptController extends \OPNsense\Base\IndexController
{
public function indexAction()
{
$this->view->pick('OPNsense/Firewall/npt_rule');
$this->view->formDialogNptRule = $this->getForm('dialogNptRule');
$this->view->formGridNptRule = $this->getFormGrid('dialogNptRule');
$this->view->entrypoint = 'npt';
$this->view->categoryKey = 'categories';
$this->view->pick('OPNsense/Firewall/nat_rule');
$this->view->formDialogRule = $this->getForm('dialogNptRule');
$this->view->formGridRule = $this->getFormGrid('dialogNptRule');
}
}

View file

@ -31,8 +31,10 @@ class OneToOneController extends \OPNsense\Base\IndexController
{
public function indexAction()
{
$this->view->pick('OPNsense/Firewall/onat_rule');
$this->view->formDialogOneToOneNatRule = $this->getForm('dialogOneToOneRule');
$this->view->formGridOneToOneNatRule = $this->getFormGrid('dialogOneToOneRule');
$this->view->entrypoint = 'one_to_one';
$this->view->categoryKey = 'categories';
$this->view->pick('OPNsense/Firewall/nat_rule');
$this->view->formDialogRule = $this->getForm('dialogOneToOneRule');
$this->view->formGridRule = $this->getFormGrid('dialogOneToOneRule');
}
}

View file

@ -31,8 +31,10 @@ class SourceNatController extends \OPNsense\Base\IndexController
{
public function indexAction()
{
$this->view->pick('OPNsense/Firewall/snat_rule');
$this->view->formDialogSNatRule = $this->getForm('dialogSNatRule');
$this->view->formGridSNatRule = $this->getFormGrid('dialogSNatRule');
$this->view->entrypoint = 'source_nat';
$this->view->categoryKey = 'categories';
$this->view->pick('OPNsense/Firewall/nat_rule');
$this->view->formDialogRule = $this->getForm('dialogSNatRule');
$this->view->formGridRule = $this->getFormGrid('dialogSNatRule');
}
}

View file

@ -1,5 +1,5 @@
{#
# Copyright (c) 2025 Deciso B.V.
# Copyright (c) 2025-2026 Deciso B.V.
# All rights reserved.
#
# Redistribution and use in source and binary forms, with or without modification,
@ -27,9 +27,10 @@
<script>
$(document).ready(function() {
// XXX: Most code comments are the same as filter_rule.volt, thats why they're omitted.
// Large blocks of code are exactly the same, so we could think about a base view.
// Though since there are subtle differences leaving them separated might be best for now.
// Each NAT controller sets the entrypoint so they can all use this template
const entrypoint = '{{ entrypoint }}';
// XXX: Category keys differ in the individual models
const category_key = '{{ categoryKey }}';
function showDialogAlert(type, title, message) {
BootstrapDialog.show({
@ -45,7 +46,8 @@
});
}
let treeViewEnabled = localStorage.getItem("dnat_tree") === "1";
const storageKey = entrypoint + "_tree";
let treeViewEnabled = localStorage.getItem(storageKey) === "1";
$('#toggle_tree_button').toggleClass('active btn-primary', treeViewEnabled);
function dynamicResponseHandler(resp) {
@ -57,7 +59,7 @@
let current = null;
resp.rows.forEach(r => {
const label = (r["%category"] || r.category || "");
const label = r[`%${category_key}`] || r[category_key] || "";
if (!current || current._label !== label) {
current = {
@ -67,7 +69,7 @@
children : []
};
current.category = label;
current[category_key] = label;
current.category_colors = r.category_colors || [];
buckets.push(current);
@ -79,22 +81,26 @@
return Object.assign({}, resp, { rows: buckets });
}
const grid = $("#{{ formGridDNatRule['table_id'] }}").UIBootgrid({
search : '/api/firewall/d_nat/search_rule/',
get : '/api/firewall/d_nat/get_rule/',
set : '/api/firewall/d_nat/set_rule/',
add : '/api/firewall/d_nat/add_rule/',
del : '/api/firewall/d_nat/del_rule/',
toggle : '/api/firewall/d_nat/toggle_rule/',
const grid = $("#{{ formGridRule['table_id'] }}").UIBootgrid({
search : '/api/firewall/' + entrypoint + '/search_rule/',
get : '/api/firewall/' + entrypoint + '/get_rule/',
set : '/api/firewall/' + entrypoint + '/set_rule/',
add : '/api/firewall/' + entrypoint + '/add_rule/',
del : '/api/firewall/' + entrypoint + '/del_rule/',
toggle : '/api/firewall/' + entrypoint + '/toggle_rule/',
tabulatorOptions : {
dataTree : true,
dataTreeChildField : "children",
dataTreeElementColumn : "category",
dataTreeElementColumn : category_key,
rowFormatter: function(row) {
const data = row.getData();
const $element = $(row.getElement());
if ('disabled' in data && data.disabled == "1") {
// XXX: d_nat model provides a disabled key
if (
('enabled' in data && data.enabled == "0") ||
('disabled' in data && data.disabled == "1")
) {
$element.addClass('row-disabled');
}
@ -111,7 +117,6 @@
responsive: true,
sorting: false,
initialSearchPhrase: getUrlHash('search'),
triggerEditFor: getUrlHash('edit'),
requestHandler: function(request){
if ( $('#category_filter').val().length > 0) {
request['category'] = $('#category_filter').val();
@ -120,14 +125,21 @@
},
responseHandler: dynamicResponseHandler,
headerFormatters: {
// XXX: This cannot be (easily) dynamically decided, so some keys are duplicate for simplicity
enabled: function (column) {
return '<i class="fa-solid fa-fw fa-check-square" data-toggle="tooltip" title="{{ lang._('Enabled') }}"></i>';;
},
disabled: function (column) {
return '<i class="fa-solid fa-fw fa-check-square" data-toggle="tooltip" title="{{ lang._('Enabled') }}"></i>';;
},
interface: function (column) {
return '<i class="fa-solid fa-fw fa-network-wired" data-toggle="tooltip" title="{{ lang._('Network interface') }}"></i>';
},
categories: function (column) {
return '<i class="fa-solid fa-fw fa-tag" data-toggle="tooltip" title="{{ lang._("Category") }}"></i>';
},
category: function (column) {
return '<i class="fa-solid fa-fw fa-tag" data-toggle="tooltip" title="{{ lang._("Categories") }}"></i>';
return '<i class="fa-solid fa-fw fa-tag" data-toggle="tooltip" title="{{ lang._("Category") }}"></i>';
},
},
formatters:{
@ -186,7 +198,10 @@
if (row.isGroup || !rowId.includes('-')) {
return '';
}
const isEnabled = row[column.id] === "0"; /* flag is inverted in model */
const isEnabled =
entrypoint === 'd_nat' /* flag is inverted in model */
? row[column.id] === "0"
: row[column.id] === "1";
return `
<span class="fa fa-fw ${isEnabled ? 'fa-check-square-o' : 'fa-square-o text-muted'} bootgrid-tooltip command-toggle"
style="cursor: pointer;"
@ -213,7 +228,7 @@
},
category: function (column, row) {
const isGroup = row.isGroup;
const hasCategories = row.category && Array.isArray(row.category_colors);
const hasCategories = row[category_key] && Array.isArray(row.category_colors);
if (!hasCategories) {
@ -227,7 +242,7 @@
: '';
}
const category = (row["%category"] || row.category).split(',');
const category = (row[`%${category_key}`] || row[category_key]).split(',');
const colors = row.category_colors;
const icons = category.map((cat, idx) => `
@ -308,7 +323,7 @@
commands: {
move_before: {
method: function(event) {
const selected = $("#{{ formGridDNatRule['table_id'] }}").bootgrid("getSelectedRows");
const selected = $("#{{ formGridRule['table_id'] }}").bootgrid("getSelectedRows");
if (selected.length !== 1) {
showDialogAlert(
BootstrapDialog.TYPE_WARNING,
@ -331,11 +346,11 @@
}
ajaxCall(
"/api/firewall/d_nat/move_rule_before/" + selectedUuid + "/" + targetUuid,
"/api/firewall/" + entrypoint + "/move_rule_before/" + selectedUuid + "/" + targetUuid,
{},
function(data, status) {
if (data.status === "ok") {
$("#{{ formGridDNatRule['table_id'] }}").bootgrid("reload");
$("#{{ formGridRule['table_id'] }}").bootgrid("reload");
$("#change_message_base_form").stop(true, false).slideDown(1000).delay(2000).slideUp(2000);
}
},
@ -358,11 +373,11 @@
const uuid = $(this).data("row-id");
const log = String(+$(this).data("value") ^ 1);
ajaxCall(
`/api/firewall/d_nat/toggle_rule_log/${uuid}/${log}`,
`/api/firewall/${entrypoint}/toggle_rule_log/${uuid}/${log}`,
{},
function(data) {
if (data.status === "ok") {
$("#{{ formGridDNatRule['table_id'] }}").bootgrid("reload");
$("#{{ formGridRule['table_id'] }}").bootgrid("reload");
$("#change_message_base_form").stop(true, false).slideDown(1000).delay(2000).slideUp(2000);
}
},
@ -390,7 +405,7 @@
const currentSelection = $("#category_filter").val();
return $("#category_filter").fetch_options(
'/api/firewall/d_nat/list_categories',
'/api/firewall/' + entrypoint + '/list_categories',
{},
function (data) {
if (!data.rows) return [];
@ -428,7 +443,7 @@
);
}
$("#category_filter_container").detach().insertBefore('#{{ formGridDNatRule["table_id"] }}-header .search');
$("#category_filter_container").detach().insertBefore('#{{ formGridRule["table_id"] }}-header .search');
$("#category_filter").on('changed.bs.select', function(){
if (!categoryInitialized || reconfigureActInProgress) return;
grid.bootgrid('reload');
@ -437,9 +452,9 @@
$("#tree_toggle_container").detach().insertAfter("#category_filter_container");
$('#toggle_tree_button').click(function() {
treeViewEnabled = !treeViewEnabled;
localStorage.setItem("dnat_tree", treeViewEnabled ? "1" : "0");
localStorage.setItem(storageKey, treeViewEnabled ? "1" : "0");
$(this).toggleClass('active btn-primary', treeViewEnabled);
$("#{{ formGridDNatRule['table_id'] }}").toggleClass("tree-enabled", treeViewEnabled);
$("#{{ formGridRule['table_id'] }}").toggleClass("tree-enabled", treeViewEnabled);
$("#tree_expand_container").toggle(treeViewEnabled);
grid.bootgrid("reload");
});
@ -447,7 +462,7 @@
$("#tree_expand_container").detach().insertAfter("#tree_toggle_container");
$("#tree_expand_container").toggle(treeViewEnabled);
$('#expand_tree_button').on('click', function () {
const $table = $('#{{ formGridDNatRule["table_id"] }}');
const $table = $('#{{ formGridRule["table_id"] }}');
if ($table.find('.tabulator-data-tree-control-expand').length) {
$table.find('.tabulator-data-tree-control-expand').trigger('click');
@ -456,44 +471,43 @@
}
});
ajaxGet('/api/firewall/d_nat/list_network_select_options', [], function(data, status){
if (data.single) {
$(".net_selector").each(function(){
$(this).replaceInputWithSelector(data, $(this).hasClass('net_selector_multi'));
/* enforce single selection when "single host or network" or "any" are selected */
if ($(this).hasClass('net_selector_multi')) {
$("select[for='" + $(this).attr('id') + "']").on('shown.bs.select', function(){
$(this).data('previousValue', $(this).val());
}).change(function(){
const prev = Array.isArray($(this).data('previousValue')) ? $(this).data('previousValue') : [];
const is_single = $(this).val().includes('') || $(this).val().includes('any');
const was_single = prev.includes('') || prev.includes('any');
let refresh = false;
if (was_single && is_single && $(this).val().length > 1) {
$(this).val($(this).val().filter(value => !prev.includes(value)));
refresh = true;
} else if (is_single && $(this).val().length > 1) {
if ($(this).val().includes('any') && !prev.includes('any')) {
$(this).val('any');
} else{
$(this).val('');
}
refresh = true;
ajaxGet('/api/firewall/' + entrypoint + '/list_network_select_options', [], function(data, status){
if (!data || !data.single) return;
$(".net_selector").each(function(){
$(this).replaceInputWithSelector(data, $(this).hasClass('net_selector_multi'));
/* enforce single selection when "single host or network" or "any" are selected */
if ($(this).hasClass('net_selector_multi')) {
$("select[for='" + $(this).attr('id') + "']").on('shown.bs.select', function(){
$(this).data('previousValue', $(this).val());
}).change(function(){
const prev = Array.isArray($(this).data('previousValue')) ? $(this).data('previousValue') : [];
const is_single = $(this).val().includes('') || $(this).val().includes('any');
const was_single = prev.includes('') || prev.includes('any');
let refresh = false;
if (was_single && is_single && $(this).val().length > 1) {
$(this).val($(this).val().filter(value => !prev.includes(value)));
refresh = true;
} else if (is_single && $(this).val().length > 1) {
if ($(this).val().includes('any') && !prev.includes('any')) {
$(this).val('any');
} else{
$(this).val('');
}
if (refresh) {
$(this).selectpicker('refresh');
$(this).trigger('change');
}
$(this).data('previousValue', $(this).val());
});
}
});
}
refresh = true;
}
if (refresh) {
$(this).selectpicker('refresh');
$(this).trigger('change');
}
$(this).data('previousValue', $(this).val());
});
}
});
});
ajaxGet('/api/firewall/d_nat/list_port_select_options', [], function (data) {
ajaxGet('/api/firewall/' + entrypoint + '/list_port_select_options', [], function (data) {
if (!data || !data.single) return;
// local-port does not support port ranges, so we replace the label for clarity
// local-port in DNAT does not support port ranges, so we replace the label for clarity
const singlePortOnly = $.extend(true, {}, data);
singlePortOnly.single.label = "{{ lang._('Single port') }}";
@ -566,7 +580,7 @@
box-shadow: none !important;
background: transparent !important;
}
.bucket-row .tabulator-cell[tabulator-field="category"] {
.bucket-row .tabulator-cell {
overflow: visible !important;
white-space: nowrap !important;
text-overflow: clip !important;
@ -606,8 +620,8 @@
margin: 0;
}
#dialogFilterRule-header #tree_toggle_container,
#dialogFilterRule-header #tree_expand_container {
#dialogRule-header #tree_toggle_container,
#dialogRule-header #tree_expand_container {
flex: 1 1 0;
margin: 0;
}
@ -637,8 +651,8 @@
</div>
</div>
{{ partial('layout_partials/base_bootgrid_table', formGridDNatRule + {'command_width':'150'}) }}
{{ partial('layout_partials/base_bootgrid_table', formGridRule + {'command_width':'150'}) }}
</div>
{{ partial('layout_partials/base_apply_button', {'data_endpoint': '/api/firewall/d_nat/apply'}) }}
{{ partial("layout_partials/base_dialog",{'fields':formDialogDNatRule,'id':formGridDNatRule['edit_dialog_id'],'label':lang._('Edit Destination Nat')}) }}
{{ partial('layout_partials/base_apply_button', {'data_endpoint': '/api/firewall/' ~ entrypoint ~ '/apply'}) }}
{{ partial("layout_partials/base_dialog",{'fields':formDialogRule,'id':formGridRule['edit_dialog_id'],'label':lang._('Edit Rule')}) }}

View file

@ -1,507 +0,0 @@
{#
# Copyright (c) 2025 Deciso B.V.
# All rights reserved.
#
# Redistribution and use in source and binary forms, with or without modification,
# are permitted provided that the following conditions are met:
#
# 1. Redistributions of source code must retain the above copyright notice,
# this list of conditions and the following disclaimer.
#
# 2. Redistributions in binary form must reproduce the above copyright notice,
# this list of conditions and the following disclaimer in the documentation
# and/or other materials provided with the distribution.
#
# THIS SOFTWARE IS PROVIDED “AS IS” AND ANY EXPRESS OR IMPLIED WARRANTIES,
# INCLUDING, BUT NOT LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY
# AND FITNESS FOR A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE
# AUTHOR BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY,
# OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF
# SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS
# INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN
# CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE)
# ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE
# POSSIBILITY OF SUCH DAMAGE.
#}
<script>
$(document).ready(function() {
function showDialogAlert(type, title, message) {
BootstrapDialog.show({
type: type,
title: title,
message: message,
buttons: [{
label: '{{ lang._('Close') }}',
action: function(dialogRef) {
dialogRef.close();
}
}]
});
}
let treeViewEnabled = localStorage.getItem("npt_tree") === "1";
$('#toggle_tree_button').toggleClass('active btn-primary', treeViewEnabled);
function dynamicResponseHandler(resp) {
if (!treeViewEnabled) {
return resp;
}
const buckets = [];
let current = null;
resp.rows.forEach(r => {
const label = (r["%categories"] || r.categories || "");
if (!current || current._label !== label) {
current = {
uuid : `${String(r.uuid).replace(/-/g, '')}`,
isGroup : true,
_label : label,
children : []
};
current.categories = label;
current.category_colors = r.category_colors || [];
buckets.push(current);
}
current.children.push(r);
});
return Object.assign({}, resp, { rows: buckets });
}
const grid = $("#{{ formGridNptRule['table_id'] }}").UIBootgrid({
search : '/api/firewall/npt/search_rule/',
get : '/api/firewall/npt/get_rule/',
set : '/api/firewall/npt/set_rule/',
add : '/api/firewall/npt/add_rule/',
del : '/api/firewall/npt/del_rule/',
toggle : '/api/firewall/npt/toggle_rule/',
tabulatorOptions : {
dataTree : true,
dataTreeChildField : "children",
dataTreeElementColumn : "categories",
rowFormatter: function(row) {
const data = row.getData();
const $element = $(row.getElement());
if ('enabled' in data && data.enabled == "0") {
$element.addClass('row-disabled');
}
if (data.isGroup || !data.uuid || !data.uuid.includes("-")) {
$element.addClass('row-no-select');
}
if (data.isGroup) {
$element.addClass('bucket-row');
}
},
},
options: {
responsive: true,
sorting: false,
initialSearchPhrase: getUrlHash('search'),
triggerEditFor: getUrlHash('edit'),
requestHandler: function(request){
if ( $('#category_filter').val().length > 0) {
request['category'] = $('#category_filter').val();
}
return request;
},
responseHandler: dynamicResponseHandler,
headerFormatters: {
enabled: function (column) {
return '<i class="fa-solid fa-fw fa-check-square" data-toggle="tooltip" title="{{ lang._('Enabled') }}"></i>';;
},
interface: function (column) {
return '<i class="fa-solid fa-fw fa-network-wired" data-toggle="tooltip" title="{{ lang._('Network interface') }}"></i>';
},
categories: function (column) {
return '<i class="fa-solid fa-fw fa-tag" data-toggle="tooltip" title="{{ lang._("Categories") }}"></i>';
},
},
formatters:{
commands: function (column, row) {
if (row.isGroup) {
return "";
}
let rowId = row.uuid;
return `
<button type="button" class="btn btn-xs btn-default command-move_before
bootgrid-tooltip" data-row-id="${rowId}"
title="{{ lang._('Move selected rule before this rule') }}">
<span class="fa fa-fw fa-arrow-left"></span>
</button>
<button type="button" class="btn btn-xs btn-default command-toggle_log bootgrid-tooltip"
data-row-id="${row.uuid}" data-value="${row.log}"
title="${row.log == '1'
? '{{ lang._("Disable Logging") }}'
: '{{ lang._("Enable Logging") }}'}">
<i class="fa fa-fw ${row.log == '1' ? 'fa-bell' : 'fa-bell-slash'}"></i>
</button>
<button type="button" class="btn btn-xs btn-default command-edit
bootgrid-tooltip" data-row-id="${rowId}"
title="{{ lang._('Edit') }}">
<span class="fa fa-fw fa-pencil"></span>
</button>
<button type="button" class="btn btn-xs btn-default command-copy
bootgrid-tooltip" data-row-id="${rowId}"
title="{{ lang._('Clone') }}">
<span class="fa fa-fw fa-clone"></span>
</button>
<button type="button" class="btn btn-xs btn-default command-delete
bootgrid-tooltip" data-row-id="${rowId}"
title="{{ lang._('Delete') }}">
<span class="fa fa-fw fa-trash-o"></span>
</button>
`;
},
rowtoggle: function (column, row) {
const rowId = row.uuid || '';
if (row.isGroup || !rowId.includes('-')) {
return '';
}
const isEnabled = row[column.id] === "1";
return `
<span class="fa fa-fw ${isEnabled ? 'fa-check-square-o' : 'fa-square-o text-muted'} bootgrid-tooltip command-toggle"
style="cursor: pointer;"
data-value="${isEnabled ? 1 : 0}"
data-row-id="${rowId}"
title="${isEnabled ? '{{ lang._("Enabled") }}' : '{{ lang._("Disabled") }}'}">
</span>
`;
},
category: function (column, row) {
const isGroup = row.isGroup;
const hasCategories = row.categories && Array.isArray(row.category_colors);
if (!hasCategories) {
return isGroup
? `<span class="category-icon category-cell">
<i class="fa fa-fw fa-tag"></i>
<strong>{{ lang._('Uncategorized') }}</strong>
<span class="badge badge-sm bg-info"
style="margin-left:6px;">${(row.children && row.children.length) || 0}</span>
</span>`
: '';
}
const category = (row["%categories"] || row.categories).split(',');
const colors = row.category_colors;
const icons = category.map((cat, idx) => `
<span class="category-icon" data-toggle="tooltip" title="${cat}">
<i class="fa fa-fw fa-tag" style="color:${colors[idx]};"></i>
</span>`).join(' ');
return isGroup
? `<span class="category-cell">
<span class="category-cell-content">
<strong>${icons} ${category.join(', ')}</strong>
<span class="badge badge-sm bg-info"
style="margin-left:6px;">${(row.children && row.children.length) || 0}</span>
</span>
</span>`
: icons;
},
},
},
commands: {
move_before: {
method: function(event) {
const selected = $("#{{ formGridNptRule['table_id'] }}").bootgrid("getSelectedRows");
if (selected.length !== 1) {
showDialogAlert(
BootstrapDialog.TYPE_WARNING,
"{{ lang._('Selection Error') }}",
"{{ lang._('Please select exactly one rule to move.') }}"
);
return;
}
const selectedUuid = selected[0];
const targetUuid = $(this).data("row-id");
if (selectedUuid === targetUuid) {
showDialogAlert(
BootstrapDialog.TYPE_WARNING,
"{{ lang._('Move Error') }}",
"{{ lang._('Cannot move a rule before itself.') }}"
);
return;
}
ajaxCall(
"/api/firewall/npt/move_rule_before/" + selectedUuid + "/" + targetUuid,
{},
function(data, status) {
if (data.status === "ok") {
$("#{{ formGridNptRule['table_id'] }}").bootgrid("reload");
$("#change_message_base_form").stop(true, false).slideDown(1000).delay(2000).slideUp(2000);
}
},
function(xhr, textStatus, errorThrown) {
showDialogAlert(
BootstrapDialog.TYPE_DANGER,
"{{ lang._('Request Failed') }}",
errorThrown
);
},
'POST'
);
},
classname: 'fa fa-fw fa-arrow-left',
title: "{{ lang._('Move selected rule before this rule') }}",
sequence: 10
},
toggle_log: {
method: function(event) {
const uuid = $(this).data("row-id");
const log = String(+$(this).data("value") ^ 1);
ajaxCall(
`/api/firewall/npt/toggle_rule_log/${uuid}/${log}`,
{},
function(data) {
if (data.status === "ok") {
$("#{{ formGridNptRule['table_id'] }}").bootgrid("reload");
$("#change_message_base_form").stop(true, false).slideDown(1000).delay(2000).slideUp(2000);
}
},
function(xhr, textStatus, errorThrown) {
showDialogAlert(
BootstrapDialog.TYPE_DANGER,
"{{ lang._('Request Failed') }}",
errorThrown
);
},
'POST'
);
},
classname: 'fa fa-fw fa-exclamation-circle',
title: "{{ lang._('Toggle Logging') }}",
sequence: 20
}
},
});
let categoryInitialized = false;
let reconfigureActInProgress = false;
function populateCategoriesSelectpicker() {
const currentSelection = $("#category_filter").val();
return $("#category_filter").fetch_options(
'/api/firewall/npt/list_categories',
{},
function (data) {
if (!data.rows) return [];
data.rows.sort((a, b) => {
const aUsed = a.used > 0 ? 0 : 1;
const bUsed = b.used > 0 ? 0 : 1;
if (aUsed !== bUsed) return aUsed - bUsed;
return a.name.localeCompare(b.name);
});
return data.rows.map(row => {
const optVal = $('<div/>').text(row.name).html();
const bgColor = row.color || '31708f';
return {
value: row.uuid,
label: row.name,
id: row.used > 0 ? row.uuid : undefined,
'data-content': row.used > 0
? `<span><span class="badge badge-sm" style="background:#${bgColor};">${row.used}</span> ${optVal}</span>`
: undefined
};
});
},
false,
function () {
if (currentSelection?.length) {
$("#category_filter").val(currentSelection).selectpicker('refresh');
}
categoryInitialized = true;
},
true
);
}
$("#category_filter_container").detach().insertBefore('#{{ formGridNptRule["table_id"] }}-header .search');
$("#category_filter").on('changed.bs.select', function(){
if (!categoryInitialized || reconfigureActInProgress) return;
grid.bootgrid('reload');
});
$("#tree_toggle_container").detach().insertAfter("#category_filter_container");
$('#toggle_tree_button').click(function() {
treeViewEnabled = !treeViewEnabled;
localStorage.setItem("onat_tree", treeViewEnabled ? "1" : "0");
$(this).toggleClass('active btn-primary', treeViewEnabled);
$("#{{ formGridNptRule['table_id'] }}").toggleClass("tree-enabled", treeViewEnabled);
$("#tree_expand_container").toggle(treeViewEnabled);
grid.bootgrid("reload");
});
$("#tree_expand_container").detach().insertAfter("#tree_toggle_container");
$("#tree_expand_container").toggle(treeViewEnabled);
$('#expand_tree_button').on('click', function () {
const $table = $('#{{ formGridNptRule["table_id"] }}');
if ($table.find('.tabulator-data-tree-control-expand').length) {
$table.find('.tabulator-data-tree-control-expand').trigger('click');
} else {
$table.find('.tabulator-data-tree-control-collapse').trigger('click');
}
});
$('#category_filter').parent().find('.dropdown-toggle').prepend('<i class="fa fa-tag" style="margin-right: 6px;"></i>');
$("#reconfigureAct").SimpleActionButton({
onPreAction() {
reconfigureActInProgress = true;
return $.Deferred().resolve();
},
onAction(data, status) {
Promise.all([
populateCategoriesSelectpicker()
])
.finally(() => {
reconfigureActInProgress = false;
});
}
});
populateCategoriesSelectpicker();
});
</script>
<style>
.actions .dropdown-menu.pull-right {
max-height: 200px;
min-width: max-content;
overflow-y: auto;
overflow-x: hidden;
}
.tooltip-inner {
max-width: 600px;
text-align: left;
}
#type_filter_container {
float: left;
margin-left: 5px;
}
#tree_toggle_container {
float: left;
margin-left: 5px;
}
#tree_expand_container {
float: left;
margin-left: 5px;
}
.badge.bg-info {
background-color: #31708f !important;
}
.badge-sm {
font-size: 12px;
padding: 2px 5px;
}
.bucket-row {
pointer-events: none;
}
.bucket-row .tabulator-cell {
border: 0 !important;
box-shadow: none !important;
background: transparent !important;
}
.bucket-row .tabulator-cell[tabulator-field="categories"] {
overflow: visible !important;
white-space: nowrap !important;
text-overflow: clip !important;
}
.bucket-row .tabulator-data-tree-control,
.bucket-row .tabulator-data-tree-control * {
pointer-events: auto;
}
.row-no-select .tabulator-row-header input[type="checkbox"] {
visibility: hidden;
pointer-events: none;
}
.tree-enabled .tabulator-col.tabulator-row-header input[type="checkbox"] {
visibility: hidden;
pointer-events: none;
}
.row-disabled {
opacity: 0.4;
}
#type_filter_container {
float: none !important;
flex: 1 1 150px;
min-width: 0;
max-width: 400px;
}
#type_filter_container .bootstrap-select {
flex: 1 1 auto;
min-width: 0;
}
.bootgrid-header .actionBar .btn-group {
align-items: flex-start;
}
@media (max-width: 1024px) {
#type_filter_container {
flex: 1 1 100%;
max-width: 100%;
margin: 0;
}
#dialogFilterRule-header #tree_toggle_container,
#dialogFilterRule-header #tree_expand_container {
flex: 1 1 0;
margin: 0;
}
}
</style>
<div class="tab-content content-box">
<div class="hidden">
<div id="category_filter_container" class="btn-group">
<select id="category_filter" data-title="{{ lang._('Categories') }}"
class="selectpicker" data-live-search="true"
data-size="30" multiple data-container="body"></select>
</div>
<div id="tree_toggle_container" class="btn-group">
<button id="toggle_tree_button" type="button" class="btn btn-default"
data-toggle="tooltip" title="{{ lang._('Show categories in a tree') }}">
<i class="fa fa-fw fa-sitemap"></i> {{ lang._('Tree') }}
</button>
</div>
<div id="tree_expand_container" class="btn-group">
<button id="expand_tree_button" type="button" class="btn btn-default"
data-toggle="tooltip" title="{{ lang._('Expand/Collapse all') }}">
<i class="fa fa-fw fa-angle-double-down"></i>
</button>
</div>
</div>
{{ partial('layout_partials/base_bootgrid_table', formGridNptRule + {'command_width':'150'}) }}
</div>
{{ partial('layout_partials/base_apply_button', {'data_endpoint': '/api/firewall/npt/apply'}) }}
{{ partial("layout_partials/base_dialog",{'fields':formDialogNptRule,'id':formGridNptRule['edit_dialog_id'],'label':lang._('Edit NPT Rule')}) }}

View file

@ -1,597 +0,0 @@
{#
# Copyright (c) 2025 Deciso B.V.
# All rights reserved.
#
# Redistribution and use in source and binary forms, with or without modification,
# are permitted provided that the following conditions are met:
#
# 1. Redistributions of source code must retain the above copyright notice,
# this list of conditions and the following disclaimer.
#
# 2. Redistributions in binary form must reproduce the above copyright notice,
# this list of conditions and the following disclaimer in the documentation
# and/or other materials provided with the distribution.
#
# THIS SOFTWARE IS PROVIDED “AS IS” AND ANY EXPRESS OR IMPLIED WARRANTIES,
# INCLUDING, BUT NOT LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY
# AND FITNESS FOR A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE
# AUTHOR BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY,
# OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF
# SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS
# INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN
# CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE)
# ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE
# POSSIBILITY OF SUCH DAMAGE.
#}
<script>
$(document).ready(function() {
function showDialogAlert(type, title, message) {
BootstrapDialog.show({
type: type,
title: title,
message: message,
buttons: [{
label: '{{ lang._('Close') }}',
action: function(dialogRef) {
dialogRef.close();
}
}]
});
}
let treeViewEnabled = localStorage.getItem("onat_tree") === "1";
$('#toggle_tree_button').toggleClass('active btn-primary', treeViewEnabled);
function dynamicResponseHandler(resp) {
if (!treeViewEnabled) {
return resp;
}
const buckets = [];
let current = null;
resp.rows.forEach(r => {
const label = (r["%categories"] || r.categories || "");
if (!current || current._label !== label) {
current = {
uuid : `${String(r.uuid).replace(/-/g, '')}`,
isGroup : true,
_label : label,
children : []
};
current.categories = label;
current.category_colors = r.category_colors || [];
buckets.push(current);
}
current.children.push(r);
});
return Object.assign({}, resp, { rows: buckets });
}
const grid = $("#{{ formGridOneToOneNatRule['table_id'] }}").UIBootgrid({
search : '/api/firewall/one_to_one/search_rule/',
get : '/api/firewall/one_to_one/get_rule/',
set : '/api/firewall/one_to_one/set_rule/',
add : '/api/firewall/one_to_one/add_rule/',
del : '/api/firewall/one_to_one/del_rule/',
toggle : '/api/firewall/one_to_one/toggle_rule/',
tabulatorOptions : {
dataTree : true,
dataTreeChildField : "children",
dataTreeElementColumn : "categories",
rowFormatter: function(row) {
const data = row.getData();
const $element = $(row.getElement());
if ('enabled' in data && data.enabled == "0") {
$element.addClass('row-disabled');
}
if (data.isGroup || !data.uuid || !data.uuid.includes("-")) {
$element.addClass('row-no-select');
}
if (data.isGroup) {
$element.addClass('bucket-row');
}
},
},
options: {
responsive: true,
sorting: false,
initialSearchPhrase: getUrlHash('search'),
triggerEditFor: getUrlHash('edit'),
requestHandler: function(request){
if ( $('#category_filter').val().length > 0) {
request['category'] = $('#category_filter').val();
}
return request;
},
responseHandler: dynamicResponseHandler,
headerFormatters: {
enabled: function (column) {
return '<i class="fa-solid fa-fw fa-check-square" data-toggle="tooltip" title="{{ lang._('Enabled') }}"></i>';;
},
interface: function (column) {
return '<i class="fa-solid fa-fw fa-network-wired" data-toggle="tooltip" title="{{ lang._('Network interface') }}"></i>';
},
categories: function (column) {
return '<i class="fa-solid fa-fw fa-tag" data-toggle="tooltip" title="{{ lang._("Categories") }}"></i>';
},
},
formatters:{
commands: function (column, row) {
if (row.isGroup) {
return "";
}
let rowId = row.uuid;
return `
<button type="button" class="btn btn-xs btn-default command-move_before
bootgrid-tooltip" data-row-id="${rowId}"
title="{{ lang._('Move selected rule before this rule') }}">
<span class="fa fa-fw fa-arrow-left"></span>
</button>
<button type="button" class="btn btn-xs btn-default command-toggle_log bootgrid-tooltip"
data-row-id="${row.uuid}" data-value="${row.log}"
title="${row.log == '1'
? '{{ lang._("Disable Logging") }}'
: '{{ lang._("Enable Logging") }}'}">
<i class="fa fa-fw ${row.log == '1' ? 'fa-bell' : 'fa-bell-slash'}"></i>
</button>
<button type="button" class="btn btn-xs btn-default command-edit
bootgrid-tooltip" data-row-id="${rowId}"
title="{{ lang._('Edit') }}">
<span class="fa fa-fw fa-pencil"></span>
</button>
<button type="button" class="btn btn-xs btn-default command-copy
bootgrid-tooltip" data-row-id="${rowId}"
title="{{ lang._('Clone') }}">
<span class="fa fa-fw fa-clone"></span>
</button>
<button type="button" class="btn btn-xs btn-default command-delete
bootgrid-tooltip" data-row-id="${rowId}"
title="{{ lang._('Delete') }}">
<span class="fa fa-fw fa-trash-o"></span>
</button>
`;
},
rowtoggle: function (column, row) {
const rowId = row.uuid || '';
if (row.isGroup || !rowId.includes('-')) {
return '';
}
const isEnabled = row[column.id] === "1";
return `
<span class="fa fa-fw ${isEnabled ? 'fa-check-square-o' : 'fa-square-o text-muted'} bootgrid-tooltip command-toggle"
style="cursor: pointer;"
data-value="${isEnabled ? 1 : 0}"
data-row-id="${rowId}"
title="${isEnabled ? '{{ lang._("Enabled") }}' : '{{ lang._("Disabled") }}'}">
</span>
`;
},
any: function(column, row) {
if (row.isGroup) {
return "";
}
if (
row[column.id] !== '' &&
row[column.id] !== 'any' &&
row[column.id] !== 'None'
) {
return row[`%${column.id}`] || row[column.id];
} else {
return '*';
}
},
category: function (column, row) {
const isGroup = row.isGroup;
const hasCategories = row.categories && Array.isArray(row.category_colors);
if (!hasCategories) {
return isGroup
? `<span class="category-icon category-cell">
<i class="fa fa-fw fa-tag"></i>
<strong>{{ lang._('Uncategorized') }}</strong>
<span class="badge badge-sm bg-info"
style="margin-left:6px;">${(row.children && row.children.length) || 0}</span>
</span>`
: '';
}
const category = (row["%categories"] || row.categories).split(',');
const colors = row.category_colors;
const icons = category.map((cat, idx) => `
<span class="category-icon" data-toggle="tooltip" title="${cat}">
<i class="fa fa-fw fa-tag" style="color:${colors[idx]};"></i>
</span>`).join(' ');
return isGroup
? `<span class="category-cell">
<span class="category-cell-content">
<strong>${icons} ${category.join(', ')}</strong>
<span class="badge badge-sm bg-info"
style="margin-left:6px;">${(row.children && row.children.length) || 0}</span>
</span>
</span>`
: icons;
},
alias: function(column, row) {
if (row.isGroup) {
return "";
}
const value = row[column.id] || "";
const isNegated = (row[column.id.replace('network', 'not')] == 1) ? "! " : "";
if (typeof value !== 'string') {
return '';
}
if (!value || value === "any") {
return isNegated + '*';
}
const aliasMetadataList = row["alias_meta_" + column.id] || [];
const renderedItems = aliasMetadataList.map(aliasInfo => {
if (aliasInfo.isAlias) {
const tooltipHtml = aliasInfo.description || aliasInfo.value || "";
return `
<span data-toggle="tooltip" data-html="true" title="${tooltipHtml}">${aliasInfo.value}&nbsp;</span>
<a href="/ui/firewall/alias/index/${encodeURIComponent(aliasInfo.value)}"
data-toggle="tooltip" title="{{ lang._('Edit alias') }}">
<i class="fa fa-fw fa-list"></i>
</a>
`;
}
return aliasInfo["%value"];
}).join(", ");
return isNegated + renderedItems;
},
},
},
commands: {
move_before: {
method: function(event) {
const selected = $("#{{ formGridOneToOneNatRule['table_id'] }}").bootgrid("getSelectedRows");
if (selected.length !== 1) {
showDialogAlert(
BootstrapDialog.TYPE_WARNING,
"{{ lang._('Selection Error') }}",
"{{ lang._('Please select exactly one rule to move.') }}"
);
return;
}
const selectedUuid = selected[0];
const targetUuid = $(this).data("row-id");
if (selectedUuid === targetUuid) {
showDialogAlert(
BootstrapDialog.TYPE_WARNING,
"{{ lang._('Move Error') }}",
"{{ lang._('Cannot move a rule before itself.') }}"
);
return;
}
ajaxCall(
"/api/firewall/one_to_one/move_rule_before/" + selectedUuid + "/" + targetUuid,
{},
function(data, status) {
if (data.status === "ok") {
$("#{{ formGridOneToOneNatRule['table_id'] }}").bootgrid("reload");
$("#change_message_base_form").stop(true, false).slideDown(1000).delay(2000).slideUp(2000);
}
},
function(xhr, textStatus, errorThrown) {
showDialogAlert(
BootstrapDialog.TYPE_DANGER,
"{{ lang._('Request Failed') }}",
errorThrown
);
},
'POST'
);
},
classname: 'fa fa-fw fa-arrow-left',
title: "{{ lang._('Move selected rule before this rule') }}",
sequence: 10
},
toggle_log: {
method: function(event) {
const uuid = $(this).data("row-id");
const log = String(+$(this).data("value") ^ 1);
ajaxCall(
`/api/firewall/one_to_one/toggle_rule_log/${uuid}/${log}`,
{},
function(data) {
if (data.status === "ok") {
$("#{{ formGridOneToOneNatRule['table_id'] }}").bootgrid("reload");
$("#change_message_base_form").stop(true, false).slideDown(1000).delay(2000).slideUp(2000);
}
},
function(xhr, textStatus, errorThrown) {
showDialogAlert(
BootstrapDialog.TYPE_DANGER,
"{{ lang._('Request Failed') }}",
errorThrown
);
},
'POST'
);
},
classname: 'fa fa-fw fa-exclamation-circle',
title: "{{ lang._('Toggle Logging') }}",
sequence: 20
}
},
});
let categoryInitialized = false;
let reconfigureActInProgress = false;
function populateCategoriesSelectpicker() {
const currentSelection = $("#category_filter").val();
return $("#category_filter").fetch_options(
'/api/firewall/one_to_one/list_categories',
{},
function (data) {
if (!data.rows) return [];
data.rows.sort((a, b) => {
const aUsed = a.used > 0 ? 0 : 1;
const bUsed = b.used > 0 ? 0 : 1;
if (aUsed !== bUsed) return aUsed - bUsed;
return a.name.localeCompare(b.name);
});
return data.rows.map(row => {
const optVal = $('<div/>').text(row.name).html();
const bgColor = row.color || '31708f';
return {
value: row.uuid,
label: row.name,
id: row.used > 0 ? row.uuid : undefined,
'data-content': row.used > 0
? `<span><span class="badge badge-sm" style="background:#${bgColor};">${row.used}</span> ${optVal}</span>`
: undefined
};
});
},
false,
function () {
if (currentSelection?.length) {
$("#category_filter").val(currentSelection).selectpicker('refresh');
}
categoryInitialized = true;
},
true
);
}
$("#category_filter_container").detach().insertBefore('#{{ formGridOneToOneNatRule["table_id"] }}-header .search');
$("#category_filter").on('changed.bs.select', function(){
if (!categoryInitialized || reconfigureActInProgress) return;
grid.bootgrid('reload');
});
$("#tree_toggle_container").detach().insertAfter("#category_filter_container");
$('#toggle_tree_button').click(function() {
treeViewEnabled = !treeViewEnabled;
localStorage.setItem("onat_tree", treeViewEnabled ? "1" : "0");
$(this).toggleClass('active btn-primary', treeViewEnabled);
$("#{{ formGridOneToOneNatRule['table_id'] }}").toggleClass("tree-enabled", treeViewEnabled);
$("#tree_expand_container").toggle(treeViewEnabled);
grid.bootgrid("reload");
});
$("#tree_expand_container").detach().insertAfter("#tree_toggle_container");
$("#tree_expand_container").toggle(treeViewEnabled);
$('#expand_tree_button').on('click', function () {
const $table = $('#{{ formGridOneToOneNatRule["table_id"] }}');
if ($table.find('.tabulator-data-tree-control-expand').length) {
$table.find('.tabulator-data-tree-control-expand').trigger('click');
} else {
$table.find('.tabulator-data-tree-control-collapse').trigger('click');
}
});
ajaxGet('/api/firewall/one_to_one/list_network_select_options', [], function(data, status){
if (data.single) {
$(".net_selector").each(function(){
$(this).replaceInputWithSelector(data, $(this).hasClass('net_selector_multi'));
/* enforce single selection when "single host or network" or "any" are selected */
if ($(this).hasClass('net_selector_multi')) {
$("select[for='" + $(this).attr('id') + "']").on('shown.bs.select', function(){
$(this).data('previousValue', $(this).val());
}).change(function(){
const prev = Array.isArray($(this).data('previousValue')) ? $(this).data('previousValue') : [];
const is_single = $(this).val().includes('') || $(this).val().includes('any');
const was_single = prev.includes('') || prev.includes('any');
let refresh = false;
if (was_single && is_single && $(this).val().length > 1) {
$(this).val($(this).val().filter(value => !prev.includes(value)));
refresh = true;
} else if (is_single && $(this).val().length > 1) {
if ($(this).val().includes('any') && !prev.includes('any')) {
$(this).val('any');
} else{
$(this).val('');
}
refresh = true;
}
if (refresh) {
$(this).selectpicker('refresh');
$(this).trigger('change');
}
$(this).data('previousValue', $(this).val());
});
}
});
}
});
ajaxGet('/api/firewall/one_to_one/list_port_select_options', [], function (data) {
if (!data || !data.single) return;
$(".port_selector").each(function () {
$(this).replaceInputWithSelector(data, false);
});
});
$('#category_filter').parent().find('.dropdown-toggle').prepend('<i class="fa fa-tag" style="margin-right: 6px;"></i>');
$("#reconfigureAct").SimpleActionButton({
onPreAction() {
reconfigureActInProgress = true;
return $.Deferred().resolve();
},
onAction(data, status) {
Promise.all([
populateCategoriesSelectpicker()
])
.finally(() => {
reconfigureActInProgress = false;
});
}
});
populateCategoriesSelectpicker();
});
</script>
<style>
.actions .dropdown-menu.pull-right {
max-height: 200px;
min-width: max-content;
overflow-y: auto;
overflow-x: hidden;
}
.tooltip-inner {
max-width: 600px;
text-align: left;
}
#type_filter_container {
float: left;
margin-left: 5px;
}
#tree_toggle_container {
float: left;
margin-left: 5px;
}
#tree_expand_container {
float: left;
margin-left: 5px;
}
.badge.bg-info {
background-color: #31708f !important;
}
.badge-sm {
font-size: 12px;
padding: 2px 5px;
}
.bucket-row {
pointer-events: none;
}
.bucket-row .tabulator-cell {
border: 0 !important;
box-shadow: none !important;
background: transparent !important;
}
.bucket-row .tabulator-cell[tabulator-field="categories"] {
overflow: visible !important;
white-space: nowrap !important;
text-overflow: clip !important;
}
.bucket-row .tabulator-data-tree-control,
.bucket-row .tabulator-data-tree-control * {
pointer-events: auto;
}
.row-no-select .tabulator-row-header input[type="checkbox"] {
visibility: hidden;
pointer-events: none;
}
.tree-enabled .tabulator-col.tabulator-row-header input[type="checkbox"] {
visibility: hidden;
pointer-events: none;
}
.row-disabled {
opacity: 0.4;
}
#type_filter_container {
float: none !important;
flex: 1 1 150px;
min-width: 0;
max-width: 400px;
}
#type_filter_container .bootstrap-select {
flex: 1 1 auto;
min-width: 0;
}
.bootgrid-header .actionBar .btn-group {
align-items: flex-start;
}
@media (max-width: 1024px) {
#type_filter_container {
flex: 1 1 100%;
max-width: 100%;
margin: 0;
}
#dialogFilterRule-header #tree_toggle_container,
#dialogFilterRule-header #tree_expand_container {
flex: 1 1 0;
margin: 0;
}
}
</style>
<div class="tab-content content-box">
<div class="hidden">
<div id="category_filter_container" class="btn-group">
<select id="category_filter" data-title="{{ lang._('Categories') }}"
class="selectpicker" data-live-search="true"
data-size="30" multiple data-container="body"></select>
</div>
<div id="tree_toggle_container" class="btn-group">
<button id="toggle_tree_button" type="button" class="btn btn-default"
data-toggle="tooltip" title="{{ lang._('Show categories in a tree') }}">
<i class="fa fa-fw fa-sitemap"></i> {{ lang._('Tree') }}
</button>
</div>
<div id="tree_expand_container" class="btn-group">
<button id="expand_tree_button" type="button" class="btn btn-default"
data-toggle="tooltip" title="{{ lang._('Expand/Collapse all') }}">
<i class="fa fa-fw fa-angle-double-down"></i>
</button>
</div>
</div>
{{ partial('layout_partials/base_bootgrid_table', formGridOneToOneNatRule + {'command_width':'150'}) }}
</div>
{{ partial('layout_partials/base_apply_button', {'data_endpoint': '/api/firewall/one_to_one/apply'}) }}
{{ partial("layout_partials/base_dialog",{'fields':formDialogOneToOneNatRule,'id':formGridOneToOneNatRule['edit_dialog_id'],'label':lang._('Edit One-to-One Nat')}) }}

View file

@ -1,623 +0,0 @@
{#
# Copyright (c) 2025 Deciso B.V.
# All rights reserved.
#
# Redistribution and use in source and binary forms, with or without modification,
# are permitted provided that the following conditions are met:
#
# 1. Redistributions of source code must retain the above copyright notice,
# this list of conditions and the following disclaimer.
#
# 2. Redistributions in binary form must reproduce the above copyright notice,
# this list of conditions and the following disclaimer in the documentation
# and/or other materials provided with the distribution.
#
# THIS SOFTWARE IS PROVIDED “AS IS” AND ANY EXPRESS OR IMPLIED WARRANTIES,
# INCLUDING, BUT NOT LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY
# AND FITNESS FOR A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE
# AUTHOR BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY,
# OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF
# SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS
# INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN
# CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE)
# ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE
# POSSIBILITY OF SUCH DAMAGE.
#}
<script>
$(document).ready(function() {
function showDialogAlert(type, title, message) {
BootstrapDialog.show({
type: type,
title: title,
message: message,
buttons: [{
label: '{{ lang._('Close') }}',
action: function(dialogRef) {
dialogRef.close();
}
}]
});
}
let treeViewEnabled = localStorage.getItem("snat_tree") === "1";
$('#toggle_tree_button').toggleClass('active btn-primary', treeViewEnabled);
function dynamicResponseHandler(resp) {
if (!treeViewEnabled) {
return resp;
}
const buckets = [];
let current = null;
resp.rows.forEach(r => {
const label = (r["%categories"] || r.categories || "");
if (!current || current._label !== label) {
current = {
uuid : `${String(r.uuid).replace(/-/g, '')}`,
isGroup : true,
_label : label,
children : []
};
current.categories = label;
current.category_colors = r.category_colors || [];
buckets.push(current);
}
current.children.push(r);
});
return Object.assign({}, resp, { rows: buckets });
}
const grid = $("#{{ formGridSNatRule['table_id'] }}").UIBootgrid({
search : '/api/firewall/source_nat/search_rule/',
get : '/api/firewall/source_nat/get_rule/',
set : '/api/firewall/source_nat/set_rule/',
add : '/api/firewall/source_nat/add_rule/',
del : '/api/firewall/source_nat/del_rule/',
toggle : '/api/firewall/source_nat/toggle_rule/',
tabulatorOptions : {
dataTree : true,
dataTreeChildField : "children",
dataTreeElementColumn : "categories",
rowFormatter: function(row) {
const data = row.getData();
const $element = $(row.getElement());
if ('enabled' in data && data.enabled == "0") {
$element.addClass('row-disabled');
}
if (data.isGroup || !data.uuid || !data.uuid.includes("-")) {
$element.addClass('row-no-select');
}
if (data.isGroup) {
$element.addClass('bucket-row');
}
},
},
options: {
responsive: true,
sorting: false,
initialSearchPhrase: getUrlHash('search'),
triggerEditFor: getUrlHash('edit'),
requestHandler: function(request){
if ( $('#category_filter').val().length > 0) {
request['category'] = $('#category_filter').val();
}
return request;
},
responseHandler: dynamicResponseHandler,
headerFormatters: {
enabled: function (column) {
return '<i class="fa-solid fa-fw fa-check-square" data-toggle="tooltip" title="{{ lang._('Enabled') }}"></i>';;
},
interface: function (column) {
return '<i class="fa-solid fa-fw fa-network-wired" data-toggle="tooltip" title="{{ lang._('Network interface') }}"></i>';
},
categories: function (column) {
return '<i class="fa-solid fa-fw fa-tag" data-toggle="tooltip" title="{{ lang._("Categories") }}"></i>';
},
},
formatters:{
commands: function (column, row) {
if (row.isGroup) {
return "";
}
let rowId = row.uuid;
return `
<button type="button" class="btn btn-xs btn-default command-move_before
bootgrid-tooltip" data-row-id="${rowId}"
title="{{ lang._('Move selected rule before this rule') }}">
<span class="fa fa-fw fa-arrow-left"></span>
</button>
<button type="button" class="btn btn-xs btn-default command-toggle_log bootgrid-tooltip"
data-row-id="${row.uuid}" data-value="${row.log}"
title="${row.log == '1'
? '{{ lang._("Disable Logging") }}'
: '{{ lang._("Enable Logging") }}'}">
<i class="fa fa-fw ${row.log == '1' ? 'fa-bell' : 'fa-bell-slash'}"></i>
</button>
<button type="button" class="btn btn-xs btn-default command-edit
bootgrid-tooltip" data-row-id="${rowId}"
title="{{ lang._('Edit') }}">
<span class="fa fa-fw fa-pencil"></span>
</button>
<button type="button" class="btn btn-xs btn-default command-copy
bootgrid-tooltip" data-row-id="${rowId}"
title="{{ lang._('Clone') }}">
<span class="fa fa-fw fa-clone"></span>
</button>
<button type="button" class="btn btn-xs btn-default command-delete
bootgrid-tooltip" data-row-id="${rowId}"
title="{{ lang._('Delete') }}">
<span class="fa fa-fw fa-trash-o"></span>
</button>
`;
},
rowtoggle: function (column, row) {
const rowId = row.uuid || '';
if (row.isGroup || !rowId.includes('-')) {
return '';
}
const isEnabled = row[column.id] === "1";
return `
<span class="fa fa-fw ${isEnabled ? 'fa-check-square-o' : 'fa-square-o text-muted'} bootgrid-tooltip command-toggle"
style="cursor: pointer;"
data-value="${isEnabled ? 1 : 0}"
data-row-id="${rowId}"
title="${isEnabled ? '{{ lang._("Enabled") }}' : '{{ lang._("Disabled") }}'}">
</span>
`;
},
any: function(column, row) {
if (row.isGroup) {
return "";
}
if (
row[column.id] !== '' &&
row[column.id] !== 'any' &&
row[column.id] !== 'None'
) {
return row[`%${column.id}`] || row[column.id];
} else {
return '*';
}
},
category: function (column, row) {
const isGroup = row.isGroup;
const hasCategories = row.categories && Array.isArray(row.category_colors);
if (!hasCategories) {
return isGroup
? `<span class="category-icon category-cell">
<i class="fa fa-fw fa-tag"></i>
<strong>{{ lang._('Uncategorized') }}</strong>
<span class="badge badge-sm bg-info"
style="margin-left:6px;">${(row.children && row.children.length) || 0}</span>
</span>`
: '';
}
const category = (row["%categories"] || row.categories).split(',');
const colors = row.category_colors;
const icons = category.map((cat, idx) => `
<span class="category-icon" data-toggle="tooltip" title="${cat}">
<i class="fa fa-fw fa-tag" style="color:${colors[idx]};"></i>
</span>`).join(' ');
return isGroup
? `<span class="category-cell">
<span class="category-cell-content">
<strong>${icons} ${category.join(', ')}</strong>
<span class="badge badge-sm bg-info"
style="margin-left:6px;">${(row.children && row.children.length) || 0}</span>
</span>
</span>`
: icons;
},
interfaces: function(column, row) {
if (row.isGroup) {
return "";
}
const interfaces = row["%" + column.id] || row[column.id] || "";
if (interfaces === "") {
return "*";
}
if (!interfaces.includes(",")) {
return (row.interfacenot == 1 ? "! " : "") + interfaces;
}
const interfaceList = interfaces.split(",");
const tooltipText = interfaceList.join("<br>");
return `
<span data-toggle="tooltip" data-html="true" title="${tooltipText}" style="white-space: nowrap;">
<span class="interface-count">${interfaceList.length}</span>
<i class="fa-solid fa-fw fa-network-wired"></i>
</span>
`;
},
alias: function(column, row) {
if (row.isGroup) {
return "";
}
const value = row[column.id] || "";
const isNegated = (row[column.id.replace('network', 'not')] == 1) ? "! " : "";
if (typeof value !== 'string') {
return '';
}
if (!value || value === "any") {
return isNegated + '*';
}
const aliasMetadataList = row["alias_meta_" + column.id] || [];
const renderedItems = aliasMetadataList.map(aliasInfo => {
if (aliasInfo.isAlias) {
const tooltipHtml = aliasInfo.description || aliasInfo.value || "";
return `
<span data-toggle="tooltip" data-html="true" title="${tooltipHtml}">${aliasInfo.value}&nbsp;</span>
<a href="/ui/firewall/alias/index/${encodeURIComponent(aliasInfo.value)}"
data-toggle="tooltip" title="{{ lang._('Edit alias') }}">
<i class="fa fa-fw fa-list"></i>
</a>
`;
}
return aliasInfo["%value"];
}).join(", ");
return isNegated + renderedItems;
},
},
},
commands: {
move_before: {
method: function(event) {
const selected = $("#{{ formGridSNatRule['table_id'] }}").bootgrid("getSelectedRows");
if (selected.length !== 1) {
showDialogAlert(
BootstrapDialog.TYPE_WARNING,
"{{ lang._('Selection Error') }}",
"{{ lang._('Please select exactly one rule to move.') }}"
);
return;
}
const selectedUuid = selected[0];
const targetUuid = $(this).data("row-id");
if (selectedUuid === targetUuid) {
showDialogAlert(
BootstrapDialog.TYPE_WARNING,
"{{ lang._('Move Error') }}",
"{{ lang._('Cannot move a rule before itself.') }}"
);
return;
}
ajaxCall(
"/api/firewall/source_nat/move_rule_before/" + selectedUuid + "/" + targetUuid,
{},
function(data, status) {
if (data.status === "ok") {
$("#{{ formGridSNatRule['table_id'] }}").bootgrid("reload");
$("#change_message_base_form").stop(true, false).slideDown(1000).delay(2000).slideUp(2000);
}
},
function(xhr, textStatus, errorThrown) {
showDialogAlert(
BootstrapDialog.TYPE_DANGER,
"{{ lang._('Request Failed') }}",
errorThrown
);
},
'POST'
);
},
classname: 'fa fa-fw fa-arrow-left',
title: "{{ lang._('Move selected rule before this rule') }}",
sequence: 10
},
toggle_log: {
method: function(event) {
const uuid = $(this).data("row-id");
const log = String(+$(this).data("value") ^ 1);
ajaxCall(
`/api/firewall/source_nat/toggle_rule_log/${uuid}/${log}`,
{},
function(data) {
if (data.status === "ok") {
$("#{{ formGridSNatRule['table_id'] }}").bootgrid("reload");
$("#change_message_base_form").stop(true, false).slideDown(1000).delay(2000).slideUp(2000);
}
},
function(xhr, textStatus, errorThrown) {
showDialogAlert(
BootstrapDialog.TYPE_DANGER,
"{{ lang._('Request Failed') }}",
errorThrown
);
},
'POST'
);
},
classname: 'fa fa-fw fa-exclamation-circle',
title: "{{ lang._('Toggle Logging') }}",
sequence: 20
}
},
});
let categoryInitialized = false;
let reconfigureActInProgress = false;
function populateCategoriesSelectpicker() {
const currentSelection = $("#category_filter").val();
return $("#category_filter").fetch_options(
'/api/firewall/source_nat/list_categories',
{},
function (data) {
if (!data.rows) return [];
data.rows.sort((a, b) => {
const aUsed = a.used > 0 ? 0 : 1;
const bUsed = b.used > 0 ? 0 : 1;
if (aUsed !== bUsed) return aUsed - bUsed;
return a.name.localeCompare(b.name);
});
return data.rows.map(row => {
const optVal = $('<div/>').text(row.name).html();
const bgColor = row.color || '31708f';
return {
value: row.uuid,
label: row.name,
id: row.used > 0 ? row.uuid : undefined,
'data-content': row.used > 0
? `<span><span class="badge badge-sm" style="background:#${bgColor};">${row.used}</span> ${optVal}</span>`
: undefined
};
});
},
false,
function () {
if (currentSelection?.length) {
$("#category_filter").val(currentSelection).selectpicker('refresh');
}
categoryInitialized = true;
},
true
);
}
$("#category_filter_container").detach().insertBefore('#{{ formGridSNatRule["table_id"] }}-header .search');
$("#category_filter").on('changed.bs.select', function(){
if (!categoryInitialized || reconfigureActInProgress) return;
grid.bootgrid('reload');
});
$("#tree_toggle_container").detach().insertAfter("#category_filter_container");
$('#toggle_tree_button').click(function() {
treeViewEnabled = !treeViewEnabled;
localStorage.setItem("snat_tree", treeViewEnabled ? "1" : "0");
$(this).toggleClass('active btn-primary', treeViewEnabled);
$("#{{ formGridSNatRule['table_id'] }}").toggleClass("tree-enabled", treeViewEnabled);
$("#tree_expand_container").toggle(treeViewEnabled);
grid.bootgrid("reload");
});
$("#tree_expand_container").detach().insertAfter("#tree_toggle_container");
$("#tree_expand_container").toggle(treeViewEnabled);
$('#expand_tree_button').on('click', function () {
const $table = $('#{{ formGridSNatRule["table_id"] }}');
if ($table.find('.tabulator-data-tree-control-expand').length) {
$table.find('.tabulator-data-tree-control-expand').trigger('click');
} else {
$table.find('.tabulator-data-tree-control-collapse').trigger('click');
}
});
ajaxGet('/api/firewall/source_nat/list_network_select_options', [], function(data, status){
if (data.single) {
$(".net_selector").each(function(){
$(this).replaceInputWithSelector(data, $(this).hasClass('net_selector_multi'));
/* enforce single selection when "single host or network" or "any" are selected */
if ($(this).hasClass('net_selector_multi')) {
$("select[for='" + $(this).attr('id') + "']").on('shown.bs.select', function(){
$(this).data('previousValue', $(this).val());
}).change(function(){
const prev = Array.isArray($(this).data('previousValue')) ? $(this).data('previousValue') : [];
const is_single = $(this).val().includes('') || $(this).val().includes('any');
const was_single = prev.includes('') || prev.includes('any');
let refresh = false;
if (was_single && is_single && $(this).val().length > 1) {
$(this).val($(this).val().filter(value => !prev.includes(value)));
refresh = true;
} else if (is_single && $(this).val().length > 1) {
if ($(this).val().includes('any') && !prev.includes('any')) {
$(this).val('any');
} else{
$(this).val('');
}
refresh = true;
}
if (refresh) {
$(this).selectpicker('refresh');
$(this).trigger('change');
}
$(this).data('previousValue', $(this).val());
});
}
});
}
});
ajaxGet('/api/firewall/source_nat/list_port_select_options', [], function (data) {
if (!data || !data.single) return;
$(".port_selector").each(function () {
$(this).replaceInputWithSelector(data, false);
});
});
$('#category_filter').parent().find('.dropdown-toggle').prepend('<i class="fa fa-tag" style="margin-right: 6px;"></i>');
$("#reconfigureAct").SimpleActionButton({
onPreAction() {
reconfigureActInProgress = true;
return $.Deferred().resolve();
},
onAction(data, status) {
Promise.all([
populateCategoriesSelectpicker()
])
.finally(() => {
reconfigureActInProgress = false;
});
}
});
populateCategoriesSelectpicker();
});
</script>
<style>
.actions .dropdown-menu.pull-right {
max-height: 200px;
min-width: max-content;
overflow-y: auto;
overflow-x: hidden;
}
.tooltip-inner {
max-width: 600px;
text-align: left;
}
#type_filter_container {
float: left;
margin-left: 5px;
}
#tree_toggle_container {
float: left;
margin-left: 5px;
}
#tree_expand_container {
float: left;
margin-left: 5px;
}
.badge.bg-info {
background-color: #31708f !important;
}
.badge-sm {
font-size: 12px;
padding: 2px 5px;
}
.bucket-row {
pointer-events: none;
}
.bucket-row .tabulator-cell {
border: 0 !important;
box-shadow: none !important;
background: transparent !important;
}
.bucket-row .tabulator-cell[tabulator-field="categories"] {
overflow: visible !important;
white-space: nowrap !important;
text-overflow: clip !important;
}
.bucket-row .tabulator-data-tree-control,
.bucket-row .tabulator-data-tree-control * {
pointer-events: auto;
}
.row-no-select .tabulator-row-header input[type="checkbox"] {
visibility: hidden;
pointer-events: none;
}
.tree-enabled .tabulator-col.tabulator-row-header input[type="checkbox"] {
visibility: hidden;
pointer-events: none;
}
.row-disabled {
opacity: 0.4;
}
#type_filter_container {
float: none !important;
flex: 1 1 150px;
min-width: 0;
max-width: 400px;
}
#type_filter_container .bootstrap-select {
flex: 1 1 auto;
min-width: 0;
}
.bootgrid-header .actionBar .btn-group {
align-items: flex-start;
}
@media (max-width: 1024px) {
#type_filter_container {
flex: 1 1 100%;
max-width: 100%;
margin: 0;
}
#dialogFilterRule-header #inspect_toggle_container,
#dialogFilterRule-header #tree_toggle_container,
#dialogFilterRule-header #tree_expand_container {
flex: 1 1 0;
margin: 0;
}
}
</style>
<div class="tab-content content-box">
<div class="hidden">
<div id="category_filter_container" class="btn-group">
<select id="category_filter" data-title="{{ lang._('Categories') }}"
class="selectpicker" data-live-search="true"
data-size="30" multiple data-container="body"></select>
</div>
<div id="tree_toggle_container" class="btn-group">
<button id="toggle_tree_button" type="button" class="btn btn-default"
data-toggle="tooltip" title="{{ lang._('Show categories in a tree') }}">
<i class="fa fa-fw fa-sitemap"></i> {{ lang._('Tree') }}
</button>
</div>
<div id="tree_expand_container" class="btn-group">
<button id="expand_tree_button" type="button" class="btn btn-default"
data-toggle="tooltip" title="{{ lang._('Expand/Collapse all') }}">
<i class="fa fa-fw fa-angle-double-down"></i>
</button>
</div>
</div>
{{ partial('layout_partials/base_bootgrid_table', formGridSNatRule + {'command_width':'150'}) }}
</div>
{{ partial('layout_partials/base_apply_button', {'data_endpoint': '/api/firewall/source_nat/apply'}) }}
{{ partial("layout_partials/base_dialog",{'fields':formDialogSNatRule,'id':formGridSNatRule['edit_dialog_id'],'label':lang._('Edit Source Nat')}) }}