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;