Add enable/disable toggle for the global firewall allow/block lists

New firewall.DisableGlobal() removes the wireguard_ui_global nftables
table without touching stored IP list entries, and firewall.IsGlobalEnabled()
reports whether it's currently loaded. New GET /firewall-lists/status and
POST /firewall-lists/disable endpoints (admin-only), plus a status badge
and "Toggle enable/disable" button on the Global Firewall Lists page -
one click to turn the whole thing off without losing the list contents,
and back on again (re-applies the current ruleset).

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
sysops
2026-07-12 20:52:02 +02:00
co-authored by Claude Sonnet 5
parent 00d084a188
commit 5a7709bc6e
4 changed files with 119 additions and 0 deletions
+20
View File
@@ -52,3 +52,23 @@ func Apply(serverID, ruleset string) (string, error) {
func ApplyGlobal(ruleset string) (string, error) { func ApplyGlobal(ruleset string) (string, error) {
return applyTable(GlobalTableName, ruleset) return applyTable(GlobalTableName, ruleset)
} }
// DisableGlobal removes the host-wide allow/block list table entirely,
// turning enforcement off without touching the stored IP list entries -
// they stay in the database and can be re-applied later with ApplyGlobal.
// A missing table (already disabled) is not treated as an error.
func DisableGlobal() (string, error) {
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
defer cancel()
out, _ := exec.CommandContext(ctx, "nft", "delete", "table", "inet", GlobalTableName).CombinedOutput()
return string(out), nil
}
// IsGlobalEnabled reports whether the host-wide allow/block list table is
// currently loaded in the live firewall.
func IsGlobalEnabled() bool {
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
defer cancel()
err := exec.CommandContext(ctx, "nft", "list", "table", "inet", GlobalTableName).Run()
return err == nil
}
+32
View File
@@ -2004,6 +2004,38 @@ func GetGlobalFirewallPreview(db store.IStore) echo.HandlerFunc {
} }
} }
// GetGlobalFirewallStatus reports whether the host-wide allow/block list
// table is currently loaded in the live firewall.
func GetGlobalFirewallStatus() echo.HandlerFunc {
return func(c echo.Context) error {
return c.JSON(http.StatusOK, map[string]interface{}{
"enabled": firewall.IsGlobalEnabled(),
})
}
}
// DisableGlobalFirewallHandler removes the host-wide allow/block list table
// from the live firewall, without touching the stored entries.
func DisableGlobalFirewallHandler() echo.HandlerFunc {
return func(c echo.Context) error {
output, err := firewall.DisableGlobal()
if err != nil {
log.Errorf("Failed to disable global firewall: %v\n%s", err, output)
return c.JSON(http.StatusInternalServerError, map[string]interface{}{
"success": false,
"message": err.Error(),
"output": output,
})
}
log.Infof("Disabled global firewall allow/block lists")
return c.JSON(http.StatusOK, map[string]interface{}{
"success": true,
"message": "Global firewall disabled",
"output": output,
})
}
}
// ApplyGlobalFirewallHandler loads the host-wide allow/block list ruleset // ApplyGlobalFirewallHandler loads the host-wide allow/block list ruleset
// live via `nft -f`, scoped to firewall.GlobalTableName only. Runs at // live via `nft -f`, scoped to firewall.GlobalTableName only. Runs at
// priority -10, before every per-server WireGuard firewall table, so it // priority -10, before every per-server WireGuard firewall table, so it
+2
View File
@@ -244,6 +244,8 @@ func main() {
app.POST(util.BasePath+"/firewall-lists/entries/import", handler.BulkImportIPListEntries(db), handler.ValidSession, handler.ContentTypeJson, handler.NeedsAdmin) app.POST(util.BasePath+"/firewall-lists/entries/import", handler.BulkImportIPListEntries(db), handler.ValidSession, handler.ContentTypeJson, handler.NeedsAdmin)
app.GET(util.BasePath+"/firewall-lists/preview", handler.GetGlobalFirewallPreview(db), handler.ValidSession, handler.NeedsAdmin) app.GET(util.BasePath+"/firewall-lists/preview", handler.GetGlobalFirewallPreview(db), handler.ValidSession, handler.NeedsAdmin)
app.POST(util.BasePath+"/firewall-lists/apply", handler.ApplyGlobalFirewallHandler(db), handler.ValidSession, handler.ContentTypeJson, handler.NeedsAdmin) app.POST(util.BasePath+"/firewall-lists/apply", handler.ApplyGlobalFirewallHandler(db), handler.ValidSession, handler.ContentTypeJson, handler.NeedsAdmin)
app.GET(util.BasePath+"/firewall-lists/status", handler.GetGlobalFirewallStatus(), handler.ValidSession, handler.NeedsAdmin)
app.POST(util.BasePath+"/firewall-lists/disable", handler.DisableGlobalFirewallHandler(), handler.ValidSession, handler.ContentTypeJson, handler.NeedsAdmin)
app.GET(util.BasePath+"/_health", handler.Health()) app.GET(util.BasePath+"/_health", handler.Health())
app.GET(util.BasePath+"/favicon", handler.Favicon()) app.GET(util.BasePath+"/favicon", handler.Favicon())
app.POST(util.BasePath+"/new-client", handler.NewClient(db), handler.ValidSession, handler.ContentTypeJson) app.POST(util.BasePath+"/new-client", handler.NewClient(db), handler.ValidSession, handler.ContentTypeJson)
+65
View File
@@ -21,6 +21,9 @@ Global Firewall Lists
<div class="card card-warning"> <div class="card card-warning">
<div class="card-header"> <div class="card-header">
<h3 class="card-title">Host-wide Allow / Block Lists</h3> <h3 class="card-title">Host-wide Allow / Block Lists</h3>
<div class="card-tools">
<span id="_global_fw_status" class="badge">checking...</span>
</div>
</div> </div>
<div class="card-body"> <div class="card-body">
<p class="text-muted"> <p class="text-muted">
@@ -29,6 +32,7 @@ Global Firewall Lists
firewall table (nftables priority -10). Allow entries always win over block entries. firewall table (nftables priority -10). Allow entries always win over block entries.
Nothing is applied until you press "Apply now (live)". Nothing is applied until you press "Apply now (live)".
</p> </p>
<button type="button" class="btn btn-outline-secondary btn-sm mb-2" id="btn_toggle_global_firewall">Toggle enable/disable</button>
<table class="table table-sm" id="_iplist_table"> <table class="table table-sm" id="_iplist_table">
<thead> <thead>
@@ -126,9 +130,69 @@ Global Firewall Lists
}); });
} }
function refreshGlobalStatus() {
$.ajax({
cache: false,
method: 'GET',
url: '{{.basePath}}/firewall-lists/status',
dataType: 'json',
success: function (data) {
const badge = $("#_global_fw_status");
if (data.enabled) {
badge.removeClass('badge-secondary').addClass('badge-success').text('ENABLED');
} else {
badge.removeClass('badge-success').addClass('badge-secondary').text('DISABLED');
}
},
error: function () {
$("#_global_fw_status").removeClass('badge-success').addClass('badge-secondary').text('unknown');
}
});
}
$(document).ready(function () { $(document).ready(function () {
loadIPList(); loadIPList();
refreshGlobalPreview(); refreshGlobalPreview();
refreshGlobalStatus();
$("#btn_toggle_global_firewall").click(function () {
$.ajax({
cache: false,
method: 'GET',
url: '{{.basePath}}/firewall-lists/status',
dataType: 'json',
success: function (data) {
if (data.enabled) {
if (!confirm("Disable the global allow/block list firewall now?\nStored entries are kept, only the live nftables table is removed.")) return;
$.ajax({
method: 'POST', url: '{{.basePath}}/firewall-lists/disable',
dataType: 'json', contentType: "application/json",
success: function (r) { toastr.success(r.message); refreshGlobalStatus(); },
error: function (jqXHR) {
const rj = jQuery.parseJSON(jqXHR.responseText);
toastr.error(rj['message'] || "Failed to disable");
}
});
} else {
if (!confirm("Enable the global allow/block list firewall now?\nThis applies the current ruleset live via 'nft -f'.")) return;
$.ajax({
method: 'POST', url: '{{.basePath}}/firewall-lists/apply',
dataType: 'json', contentType: "application/json",
success: function (r) {
toastr.success(r.message);
if (r.output) { $("#_iplist_preview_text").text(r.output); }
refreshGlobalStatus();
},
error: function (jqXHR) {
const rj = jQuery.parseJSON(jqXHR.responseText);
toastr.error(rj['message'] || "Failed to enable");
if (rj['output']) { $("#_iplist_preview_text").text(rj['output']); }
}
});
}
}
});
});
$("#frm_iplist_entry").on('submit', function (e) { $("#frm_iplist_entry").on('submit', function (e) {
e.preventDefault(); e.preventDefault();
@@ -226,6 +290,7 @@ Global Firewall Lists
success: function (data) { success: function (data) {
toastr.success(data.message); toastr.success(data.message);
if (data.output) { $("#_iplist_preview_text").text(data.output); } if (data.output) { $("#_iplist_preview_text").text(data.output); }
refreshGlobalStatus();
}, },
error: function (jqXHR) { error: function (jqXHR) {
const responseJson = jQuery.parseJSON(jqXHR.responseText); const responseJson = jQuery.parseJSON(jqXHR.responseText);