From 5a7709bc6ecbc40221a5be4f8e8e03477688eace Mon Sep 17 00:00:00 2001 From: sysops Date: Sun, 12 Jul 2026 20:52:02 +0200 Subject: [PATCH] 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 --- firewall/apply.go | 20 +++++++++++ handler/routes.go | 32 +++++++++++++++++ main.go | 2 ++ templates/firewall_lists.html | 65 +++++++++++++++++++++++++++++++++++ 4 files changed, 119 insertions(+) diff --git a/firewall/apply.go b/firewall/apply.go index 70d0e14..3174054 100644 --- a/firewall/apply.go +++ b/firewall/apply.go @@ -52,3 +52,23 @@ func Apply(serverID, ruleset string) (string, error) { func ApplyGlobal(ruleset string) (string, error) { 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 +} diff --git a/handler/routes.go b/handler/routes.go index 6f4559a..0dc5ee7 100644 --- a/handler/routes.go +++ b/handler/routes.go @@ -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 // live via `nft -f`, scoped to firewall.GlobalTableName only. Runs at // priority -10, before every per-server WireGuard firewall table, so it diff --git a/main.go b/main.go index b9c7794..89d6ab2 100644 --- a/main.go +++ b/main.go @@ -244,6 +244,8 @@ func main() { 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.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+"/favicon", handler.Favicon()) app.POST(util.BasePath+"/new-client", handler.NewClient(db), handler.ValidSession, handler.ContentTypeJson) diff --git a/templates/firewall_lists.html b/templates/firewall_lists.html index b8b4849..3263f89 100644 --- a/templates/firewall_lists.html +++ b/templates/firewall_lists.html @@ -21,6 +21,9 @@ Global Firewall Lists

Host-wide Allow / Block Lists

+
+ checking... +

@@ -29,6 +32,7 @@ Global Firewall Lists firewall table (nftables priority -10). Allow entries always win over block entries. Nothing is applied until you press "Apply now (live)".

+ @@ -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 () { loadIPList(); 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) { e.preventDefault(); @@ -226,6 +290,7 @@ Global Firewall Lists success: function (data) { toastr.success(data.message); if (data.output) { $("#_iplist_preview_text").text(data.output); } + refreshGlobalStatus(); }, error: function (jqXHR) { const responseJson = jQuery.parseJSON(jqXHR.responseText);