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 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Sonnet 5
parent
eb1913d400
commit
77ba2b4799
@@ -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
|
// GetGlobalFirewallPreview returns the generated host-wide allow/block list
|
||||||
// ruleset as plain text. Preview only.
|
// ruleset as plain text. Preview only.
|
||||||
func GetGlobalFirewallPreview(db store.IStore) echo.HandlerFunc {
|
func GetGlobalFirewallPreview(db store.IStore) echo.HandlerFunc {
|
||||||
|
|||||||
@@ -241,6 +241,7 @@ func main() {
|
|||||||
app.GET(util.BasePath+"/firewall-lists/entries", handler.GetIPListEntries(db), handler.ValidSession, handler.NeedsAdmin)
|
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", 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/: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.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+"/_health", handler.Health())
|
app.GET(util.BasePath+"/_health", handler.Health())
|
||||||
|
|||||||
@@ -47,6 +47,28 @@ Global Firewall Lists
|
|||||||
<button type="submit" class="btn btn-primary btn-sm mb-1">Add entry</button>
|
<button type="submit" class="btn btn-primary btn-sm mb-1">Add entry</button>
|
||||||
</form>
|
</form>
|
||||||
|
|
||||||
|
<hr>
|
||||||
|
<p><a data-toggle="collapse" href="#collapse_bulk_import" role="button" aria-expanded="false" aria-controls="collapse_bulk_import">Bulk import ▼</a></p>
|
||||||
|
<div class="collapse" id="collapse_bulk_import">
|
||||||
|
<div class="card card-body">
|
||||||
|
<form id="frm_iplist_import">
|
||||||
|
<div class="form-group">
|
||||||
|
<label for="_iplist_import_type">List type for this batch</label>
|
||||||
|
<select class="form-control form-control-sm" id="_iplist_import_type" style="width:10em">
|
||||||
|
<option value="block">block</option>
|
||||||
|
<option value="allow">allow</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<div class="form-group">
|
||||||
|
<label for="_iplist_import_text">Entries (one CIDR/IP per line)</label>
|
||||||
|
<textarea class="form-control" id="_iplist_import_text" rows="10"
|
||||||
|
placeholder="One CIDR or IP per line. Optional comment after '#'. Blank lines and lines starting with '#' are ignored. 203.0.113.0/24 198.51.100.5 # known scanner # this whole line is a comment"></textarea>
|
||||||
|
</div>
|
||||||
|
<button type="submit" class="btn btn-primary btn-sm">Import</button>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<hr>
|
<hr>
|
||||||
<p class="text-muted mb-1">Ruleset preview:</p>
|
<p class="text-muted mb-1">Ruleset preview:</p>
|
||||||
<pre id="_iplist_preview_text" style="max-height: 30vh; overflow:auto;"></pre>
|
<pre id="_iplist_preview_text" style="max-height: 30vh; overflow:auto;"></pre>
|
||||||
@@ -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 () {
|
$("#_iplist_tbody").on('click', '.btn-delete-iplist', function () {
|
||||||
const id = $(this).data('id');
|
const id = $(this).data('id');
|
||||||
if (!confirm("Delete this entry?")) return;
|
if (!confirm("Delete this entry?")) return;
|
||||||
|
|||||||
Reference in New Issue
Block a user