Add central host-wide firewall allow/block lists

New model.IPListEntry + jsondb CRUD, independent of any single WireGuard
server. firewall.GenerateGlobalRuleset builds an nftables table
(wireguard_ui_global) with allow/block sets evaluated at priority -10 -
before every per-server table - so it applies to all traffic on the host,
not just WireGuard. Allow entries always win over block entries.
firewall.ApplyGlobal loads it live via `nft -f`, scoped to that one table.

New "Global Firewall Lists" page (nav entry under Settings): add/delete
entries, ruleset preview, "Apply now (live)" with an explicit confirm()
warning since this affects the whole host's firewall, not just one server.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
sysops
2026-07-12 17:45:53 +02:00
co-authored by Claude Sonnet 5
parent bdcf1ec60c
commit eb1913d400
10 changed files with 438 additions and 9 deletions
+8
View File
@@ -145,6 +145,14 @@
</p>
</a>
</li>
<li class="nav-item">
<a href="{{.basePath}}/firewall-lists" class="nav-link {{if eq .baseData.Active "firewall-lists" }}active{{end}}">
<i class="nav-icon fas fa-shield-alt"></i>
<p>
Global Firewall Lists
</p>
</a>
</li>
{{if not .loginDisabled}}
<li class="nav-item">
<a href="{{.basePath}}/users-settings" class="nav-link {{if eq .baseData.Active "users-settings" }}active{{end}}">
+184
View File
@@ -0,0 +1,184 @@
{{define "title"}}
Global Firewall Lists
{{end}}
{{define "top_css"}}
{{end}}
{{define "username"}}
{{.username}}
{{end}}
{{define "page_title"}}
Global Firewall Lists
{{end}}
{{define "page_content"}}
<section class="content">
<div class="container-fluid">
<div class="row">
<div class="col-md-12">
<div class="card card-warning">
<div class="card-header">
<h3 class="card-title">Host-wide Allow / Block Lists</h3>
</div>
<div class="card-body">
<p class="text-muted">
These entries are NOT scoped to a single WireGuard server - they apply to
<strong>all traffic on this host</strong>, evaluated before every per-server
firewall table (nftables priority -10). Allow entries always win over block entries.
Nothing is applied until you press "Apply now (live)".
</p>
<table class="table table-sm" id="_iplist_table">
<thead>
<tr><th>Type</th><th>CIDR / IP</th><th>Comment</th><th></th></tr>
</thead>
<tbody id="_iplist_tbody"></tbody>
</table>
<form id="frm_iplist_entry" class="form-inline">
<select class="form-control form-control-sm mr-1 mb-1" id="_iplist_type">
<option value="block">block</option>
<option value="allow">allow</option>
</select>
<input type="text" class="form-control form-control-sm mr-1 mb-1" id="_iplist_cidr" placeholder="e.g. 203.0.113.0/24" style="width:14em">
<input type="text" class="form-control form-control-sm mr-1 mb-1" id="_iplist_comment" placeholder="comment" style="width:14em">
<button type="submit" class="btn btn-primary btn-sm mb-1">Add entry</button>
</form>
<hr>
<p class="text-muted mb-1">Ruleset preview:</p>
<pre id="_iplist_preview_text" style="max-height: 30vh; overflow:auto;"></pre>
<button type="button" class="btn btn-danger" id="btn_apply_global_firewall">Apply now (live)</button>
</div>
</div>
</div>
</div>
</div>
</section>
{{end}}
{{define "bottom_js"}}
<script>
function refreshGlobalPreview() {
$("#_iplist_preview_text").text("Loading...");
$.ajax({
cache: false,
method: 'GET',
url: '{{.basePath}}/firewall-lists/preview',
dataType: 'text',
success: function (data) { $("#_iplist_preview_text").text(data); },
error: function () { $("#_iplist_preview_text").text("Could not load preview."); }
});
}
function renderIPList(entries) {
const tbody = $("#_iplist_tbody");
tbody.empty();
$.each(entries, function (i, entry) {
const safeCidr = $('<div>').text(entry.cidr).html();
const safeComment = $('<div>').text(entry.comment || "").html();
const row = `<tr>
<td>${entry.list_type}</td>
<td>${safeCidr}</td>
<td>${safeComment}</td>
<td><button type="button" class="btn btn-outline-danger btn-sm btn-delete-iplist" data-id="${entry.id}">Delete</button></td>
</tr>`;
tbody.append(row);
});
}
function loadIPList() {
$.ajax({
cache: false,
method: 'GET',
url: '{{.basePath}}/firewall-lists/entries',
dataType: 'json',
success: function (entries) { renderIPList(entries); },
error: function (jqXHR) {
const responseJson = jQuery.parseJSON(jqXHR.responseText);
toastr.error(responseJson['message']);
}
});
}
$(document).ready(function () {
loadIPList();
refreshGlobalPreview();
$("#frm_iplist_entry").on('submit', function (e) {
e.preventDefault();
const data = {
list_type: $("#_iplist_type").val(),
cidr: $("#_iplist_cidr").val(),
comment: $("#_iplist_comment").val()
};
$.ajax({
cache: false,
method: 'POST',
url: '{{.basePath}}/firewall-lists/entries',
dataType: 'json',
contentType: "application/json",
data: JSON.stringify(data),
success: function () {
toastr.success("Entry added");
$("#frm_iplist_entry")[0].reset();
loadIPList();
refreshGlobalPreview();
},
error: function (jqXHR) {
const responseJson = jQuery.parseJSON(jqXHR.responseText);
toastr.error(responseJson['message']);
}
});
});
$("#_iplist_tbody").on('click', '.btn-delete-iplist', function () {
const id = $(this).data('id');
if (!confirm("Delete this entry?")) return;
$.ajax({
cache: false,
method: 'POST',
url: '{{.basePath}}/firewall-lists/entries/' + id + '/delete',
dataType: 'json',
contentType: "application/json",
success: function () {
toastr.success("Entry deleted");
loadIPList();
refreshGlobalPreview();
},
error: function (jqXHR) {
const responseJson = jQuery.parseJSON(jqXHR.responseText);
toastr.error(responseJson['message']);
}
});
});
$("#btn_apply_global_firewall").click(function () {
if (!confirm("Apply the global allow/block lists to the live firewall now?\n" +
"This runs 'nft -f' on the server, scoped to the dedicated wireguard_ui_global table only, " +
"but it affects ALL traffic on this host, not just WireGuard.")) {
return;
}
$.ajax({
cache: false,
method: 'POST',
url: '{{.basePath}}/firewall-lists/apply',
dataType: 'json',
contentType: "application/json",
success: function (data) {
toastr.success(data.message);
if (data.output) { $("#_iplist_preview_text").text(data.output); }
},
error: function (jqXHR) {
const responseJson = jQuery.parseJSON(jqXHR.responseText);
toastr.error(responseJson['message'] || "Failed to apply");
if (responseJson['output']) { $("#_iplist_preview_text").text(responseJson['output']); }
}
});
});
});
</script>
{{end}}