Add live firewall rule management per server (nftables)

New model.FirewallRule + jsondb CRUD (GetFirewallRules/CreateFirewallRule/
UpdateFirewallRule/DeleteFirewallRule), scoped per server. firewall package
now generates a full ruleset (baseline + enabled custom rules) and can
apply it live via `nft -f` (firewall.Apply), scoped to a per-server
nftables table (wireguard_ui_<serverID>) so applying one server never
touches another server's rules or any pre-existing firewall state.

New endpoints: GET/POST /servers/:id/firewall/rules, POST .../rules/:ruleId,
POST .../rules/:ruleId/delete, POST .../apply (live, admin-only). UI in the
All Servers page: rule table with add/delete, ruleset preview, and an
"Apply now (live)" button with an explicit confirm() warning before it
touches the running firewall.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
sysops
2026-07-12 17:31:08 +02:00
co-authored by Claude Sonnet 5
parent 28eb08df41
commit 1d080904b0
8 changed files with 507 additions and 41 deletions
+165 -15
View File
@@ -176,21 +176,55 @@ All Servers
</div>
<!-- /.modal -->
<div class="modal fade" id="modal_firewall_preview">
<div class="modal fade" id="modal_firewall">
<div class="modal-dialog modal-lg">
<div class="modal-content">
<div class="modal-header">
<h4 class="modal-title">Firewall Preview (nftables)</h4>
<h4 class="modal-title">Firewall Rules (nftables) - <span id="_fw_server_label"></span></h4>
<button type="button" class="close" data-dismiss="modal" aria-label="Close">
<span aria-hidden="true">&times;</span>
</button>
</div>
<div class="modal-body">
<p class="text-muted">Preview only - nothing is applied to the running firewall. Review, then apply manually if desired.</p>
<pre id="_firewall_preview_text" style="max-height: 50vh; overflow:auto;"></pre>
<input type="hidden" id="_fw_server_id">
<table class="table table-sm" id="_fw_rules_table">
<thead>
<tr>
<th>On</th><th>Chain</th><th>Proto</th><th>Port</th><th>Source</th><th>Action</th><th>Comment</th><th></th>
</tr>
</thead>
<tbody id="_fw_rules_tbody"></tbody>
</table>
<form id="frm_firewall_rule" class="form-inline">
<select class="form-control form-control-sm mr-1 mb-1" id="_fw_direction">
<option value="input">input</option>
<option value="forward">forward</option>
</select>
<select class="form-control form-control-sm mr-1 mb-1" id="_fw_protocol">
<option value="">any</option>
<option value="tcp">tcp</option>
<option value="udp">udp</option>
</select>
<input type="text" class="form-control form-control-sm mr-1 mb-1" id="_fw_port" placeholder="port(s) e.g. 8080" style="width:9em">
<input type="text" class="form-control form-control-sm mr-1 mb-1" id="_fw_source" placeholder="source CIDR (optional)" style="width:11em">
<select class="form-control form-control-sm mr-1 mb-1" id="_fw_action">
<option value="accept">accept</option>
<option value="drop">drop</option>
<option value="reject">reject</option>
</select>
<input type="text" class="form-control form-control-sm mr-1 mb-1" id="_fw_comment" placeholder="comment" style="width:11em">
<button type="submit" class="btn btn-primary btn-sm mb-1">Add rule</button>
</form>
<hr>
<p class="text-muted mb-1">Ruleset preview:</p>
<pre id="_firewall_preview_text" style="max-height: 30vh; overflow:auto;"></pre>
</div>
<div class="modal-footer justify-content-between">
<button type="button" class="btn btn-default" data-dismiss="modal">Close</button>
<button type="button" class="btn btn-danger" id="btn_apply_firewall">Apply now (live)</button>
</div>
</div>
<!-- /.modal-content -->
@@ -232,7 +266,7 @@ All Servers
data-target="#modal_server_interface" data-serverid="${obj.id}">Interface</button>
</div>
<div class="btn-group">
<button type="button" class="btn btn-outline-info btn-sm btn-firewall-preview" data-serverid="${obj.id}">Firewall Preview</button>
<button type="button" class="btn btn-outline-info btn-sm btn-firewall-preview" data-serverid="${obj.id}" data-servername="${safeName}">Firewall</button>
</div>
<div class="btn-group">
<button type="button" class="btn btn-outline-danger btn-sm btn-delete-server" data-serverid="${obj.id}" data-servername="${safeName}">Delete</button>
@@ -507,22 +541,138 @@ All Servers
});
});
// Firewall preview button: rendered dynamically, use event delegation
// Firewall management modal
function refreshFirewallPreview(serverId) {
$("#_firewall_preview_text").text("Loading...");
$.ajax({
cache: false,
method: 'GET',
url: '{{.basePath}}/servers/' + serverId + '/firewall-preview',
dataType: 'text',
success: function (data) { $("#_firewall_preview_text").text(data); },
error: function () { $("#_firewall_preview_text").text("Could not load firewall preview."); }
});
}
function renderFirewallRules(serverId, rules) {
const tbody = $("#_fw_rules_tbody");
tbody.empty();
$.each(rules, function (i, rule) {
const safeComment = $('<div>').text(rule.comment || "").html();
const row = `<tr data-ruleid="${rule.id}">
<td>${rule.enabled ? "yes" : "no"}</td>
<td>${rule.direction}</td>
<td>${rule.protocol || "any"}</td>
<td>${rule.port || "any"}</td>
<td>${rule.source || "any"}</td>
<td>${rule.action}</td>
<td>${safeComment}</td>
<td><button type="button" class="btn btn-outline-danger btn-sm btn-delete-fw-rule" data-serverid="${serverId}" data-ruleid="${rule.id}">Delete</button></td>
</tr>`;
tbody.append(row);
});
}
function loadFirewallRules(serverId) {
$.ajax({
cache: false,
method: 'GET',
url: '{{.basePath}}/servers/' + serverId + '/firewall/rules',
dataType: 'json',
success: function (rules) { renderFirewallRules(serverId, rules); },
error: function (jqXHR) {
const responseJson = jQuery.parseJSON(jqXHR.responseText);
toastr.error(responseJson['message']);
}
});
}
// Firewall button: rendered dynamically, use event delegation
$(document).ready(function () {
$('#servers-list').on('click', '.btn-firewall-preview', function () {
const serverId = $(this).data('serverid');
$("#_firewall_preview_text").text("Loading...");
$("#modal_firewall_preview").modal('show');
const serverName = $(this).data('servername');
$("#_fw_server_id").val(serverId);
$("#_fw_server_label").text(serverName + " (" + serverId + ")");
$("#modal_firewall").modal('show');
loadFirewallRules(serverId);
refreshFirewallPreview(serverId);
});
$("#frm_firewall_rule").on('submit', function (e) {
e.preventDefault();
const serverId = $("#_fw_server_id").val();
const data = {
direction: $("#_fw_direction").val(),
protocol: $("#_fw_protocol").val(),
port: $("#_fw_port").val(),
source: $("#_fw_source").val(),
action: $("#_fw_action").val(),
comment: $("#_fw_comment").val(),
enabled: true
};
$.ajax({
cache: false,
method: 'GET',
url: '{{.basePath}}/servers/' + serverId + '/firewall-preview',
dataType: 'text',
success: function (data) {
$("#_firewall_preview_text").text(data);
method: 'POST',
url: '{{.basePath}}/servers/' + serverId + '/firewall/rules',
dataType: 'json',
contentType: "application/json",
data: JSON.stringify(data),
success: function () {
toastr.success("Rule added");
$("#frm_firewall_rule")[0].reset();
loadFirewallRules(serverId);
refreshFirewallPreview(serverId);
},
error: function (jqXHR, exception) {
$("#_firewall_preview_text").text("Could not load firewall preview.");
error: function (jqXHR) {
const responseJson = jQuery.parseJSON(jqXHR.responseText);
toastr.error(responseJson['message']);
}
});
});
$("#_fw_rules_tbody").on('click', '.btn-delete-fw-rule', function () {
const serverId = $(this).data('serverid');
const ruleId = $(this).data('ruleid');
if (!confirm("Delete this firewall rule?")) return;
$.ajax({
cache: false,
method: 'POST',
url: '{{.basePath}}/servers/' + serverId + '/firewall/rules/' + ruleId + '/delete',
dataType: 'json',
contentType: "application/json",
success: function () {
toastr.success("Rule deleted");
loadFirewallRules(serverId);
refreshFirewallPreview(serverId);
},
error: function (jqXHR) {
const responseJson = jQuery.parseJSON(jqXHR.responseText);
toastr.error(responseJson['message']);
}
});
});
$("#btn_apply_firewall").click(function () {
const serverId = $("#_fw_server_id").val();
if (!confirm("Apply this ruleset to the live firewall now?\n" +
"This runs 'nft -f' on the server, scoped to this server's own nftables table only.")) {
return;
}
$.ajax({
cache: false,
method: 'POST',
url: '{{.basePath}}/servers/' + serverId + '/firewall/apply',
dataType: 'json',
contentType: "application/json",
success: function (data) {
toastr.success(data.message);
if (data.output) { $("#_firewall_preview_text").text(data.output); }
},
error: function (jqXHR) {
const responseJson = jQuery.parseJSON(jqXHR.responseText);
toastr.error(responseJson['message'] || "Failed to apply firewall rules");
if (responseJson['output']) { $("#_firewall_preview_text").text(responseJson['output']); }
}
});
});