From 77ba2b479903a6e9d7b0571ea9e8e15aa510098c Mon Sep 17 00:00:00 2001 From: sysops Date: Sun, 12 Jul 2026 17:50:58 +0200 Subject: [PATCH] Add bulk import for global firewall allow/block lists Paste-in import: one CIDR/IP per line, optional comment after '#', blank/comment-only lines ignored - matches the format used by common public blocklist feeds (Spamhaus DROP, blocklist.de, etc.) so those can mostly be pasted in directly. Whole batch gets one list_type (allow or block). Reuses the existing single-entry validation, skips duplicates (by list_type+CIDR, including within the same paste), caps at 5000 lines, and reports imported/skipped/invalid counts plus per-line errors. New route: POST /firewall-lists/entries/import (admin-only). UI: a collapsible "Bulk import" section on the Global Firewall Lists page. Co-Authored-By: Claude Sonnet 5 --- handler/routes.go | 99 +++++++++++++++++++++++++++++++++++ main.go | 1 + templates/firewall_lists.html | 55 +++++++++++++++++++ 3 files changed, 155 insertions(+) diff --git a/handler/routes.go b/handler/routes.go index fbe2991..a6bd11e 100644 --- a/handler/routes.go +++ b/handler/routes.go @@ -1843,6 +1843,105 @@ func DeleteIPListEntryHandler(db store.IStore) echo.HandlerFunc { } } +// maxBulkImportLines caps the number of lines accepted by +// BulkImportIPListEntries, to avoid pathological input. +const maxBulkImportLines = 5000 + +// BulkImportIPListEntries imports many allow/block list entries at once from +// a pasted block of text, one CIDR/IP per line. Lines may have an optional +// "# comment" suffix. Blank lines and lines starting with '#' are ignored. +// All imported entries share the same list_type for the whole batch. +func BulkImportIPListEntries(db store.IStore) echo.HandlerFunc { + return func(c echo.Context) error { + var payload struct { + ListType string `json:"list_type"` + Text string `json:"text"` + } + if err := c.Bind(&payload); err != nil { + return c.JSON(http.StatusBadRequest, jsonHTTPResponse{false, "Bad post data"}) + } + if payload.ListType != "allow" && payload.ListType != "block" { + return c.JSON(http.StatusBadRequest, jsonHTTPResponse{false, "list_type must be 'allow' or 'block'"}) + } + + lines := strings.Split(payload.Text, "\n") + if len(lines) > maxBulkImportLines { + return c.JSON(http.StatusBadRequest, jsonHTTPResponse{false, + fmt.Sprintf("Too many lines: %d (max %d)", len(lines), maxBulkImportLines)}) + } + + existing, err := db.GetIPListEntries() + if err != nil { + return c.JSON(http.StatusInternalServerError, jsonHTTPResponse{false, err.Error()}) + } + existingSet := make(map[string]bool, len(existing)) + for _, e := range existing { + existingSet[e.ListType+"|"+e.CIDR] = true + } + + type invalidLine struct { + Line int `json:"line"` + Text string `json:"text"` + Error string `json:"error"` + } + + imported := 0 + skippedDuplicates := 0 + var invalidLines []invalidLine + + for i, raw := range lines { + lineNum := i + 1 + trimmed := strings.TrimSpace(raw) + if trimmed == "" || strings.HasPrefix(trimmed, "#") { + continue + } + + cidr := trimmed + comment := "" + if idx := strings.Index(trimmed, "#"); idx >= 0 { + cidr = strings.TrimSpace(trimmed[:idx]) + comment = strings.TrimSpace(trimmed[idx+1:]) + } + if cidr == "" { + continue + } + + entry := model.IPListEntry{ + ListType: payload.ListType, + CIDR: cidr, + Comment: comment, + } + if err := validateIPListEntry(entry); err != nil { + invalidLines = append(invalidLines, invalidLine{Line: lineNum, Text: raw, Error: err.Error()}) + continue + } + + key := entry.ListType + "|" + entry.CIDR + if existingSet[key] { + skippedDuplicates++ + continue + } + + entry.ID = xid.New().String() + entry.CreatedAt = time.Now().UTC() + if err := db.CreateIPListEntry(entry); err != nil { + invalidLines = append(invalidLines, invalidLine{Line: lineNum, Text: raw, Error: err.Error()}) + continue + } + existingSet[key] = true + imported++ + } + + return c.JSON(http.StatusOK, map[string]interface{}{ + "success": true, + "imported": imported, + "skipped_duplicates": skippedDuplicates, + "invalid_count": len(invalidLines), + "invalid_lines": invalidLines, + }) + } +} + // GetGlobalFirewallPreview returns the generated host-wide allow/block list // ruleset as plain text. Preview only. func GetGlobalFirewallPreview(db store.IStore) echo.HandlerFunc { diff --git a/main.go b/main.go index 074b35d..b9c7794 100644 --- a/main.go +++ b/main.go @@ -241,6 +241,7 @@ func main() { app.GET(util.BasePath+"/firewall-lists/entries", handler.GetIPListEntries(db), handler.ValidSession, handler.NeedsAdmin) app.POST(util.BasePath+"/firewall-lists/entries", handler.CreateIPListEntryHandler(db), handler.ValidSession, handler.ContentTypeJson, handler.NeedsAdmin) app.POST(util.BasePath+"/firewall-lists/entries/:id/delete", handler.DeleteIPListEntryHandler(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.POST(util.BasePath+"/firewall-lists/apply", handler.ApplyGlobalFirewallHandler(db), handler.ValidSession, handler.ContentTypeJson, handler.NeedsAdmin) app.GET(util.BasePath+"/_health", handler.Health()) diff --git a/templates/firewall_lists.html b/templates/firewall_lists.html index 1bd8f58..b8b4849 100644 --- a/templates/firewall_lists.html +++ b/templates/firewall_lists.html @@ -47,6 +47,28 @@ Global Firewall Lists +
+

+
+
+
+
+ + +
+
+ + +
+ +
+
+
+

Ruleset preview:


@@ -135,6 +157,39 @@ Global Firewall Lists
             });
         });
 
+        $("#frm_iplist_import").on('submit', function (e) {
+            e.preventDefault();
+            const data = {
+                list_type: $("#_iplist_import_type").val(),
+                text: $("#_iplist_import_text").val()
+            };
+            $.ajax({
+                cache: false,
+                method: 'POST',
+                url: '{{.basePath}}/firewall-lists/entries/import',
+                dataType: 'json',
+                contentType: "application/json",
+                data: JSON.stringify(data),
+                success: function (result) {
+                    let summary = "Imported " + result.imported + ", skipped " + result.skipped_duplicates + " duplicates";
+                    if (result.invalid_count > 0) {
+                        summary += ", " + result.invalid_count + " invalid lines";
+                        console.warn("Bulk import invalid lines:", result.invalid_lines);
+                        toastr.warning(summary);
+                    } else {
+                        toastr.success(summary);
+                    }
+                    $("#_iplist_import_text").val("");
+                    loadIPList();
+                    refreshGlobalPreview();
+                },
+                error: function (jqXHR) {
+                    const responseJson = jQuery.parseJSON(jqXHR.responseText);
+                    toastr.error(responseJson['message'] || "Import failed");
+                }
+            });
+        });
+
         $("#_iplist_tbody").on('click', '.btn-delete-iplist', function () {
             const id = $(this).data('id');
             if (!confirm("Delete this entry?")) return;