feat(prune): add functionality to remove old single-host IP blocks while keeping CIDR ranges intact
This commit is contained in:
1 parent
714aaf901e
commit
5ece4cd113
3 files changed
+536
-428
No files matched your search
+71
-3
@@ -11,7 +11,8 @@ from __future__ import annotations
|
||||
import json
|
||||
import sqlite3
|
||||
from contextlib import contextmanager
|
||||
from datetime import datetime, timezone
|
||||
from datetime import datetime, timedelta, timezone
|
||||
import ipaddress
|
||||
from typing import Any, Iterator, Sequence
|
||||
|
||||
from ..utils.models import IPStats
|
||||
@@ -104,7 +105,8 @@ def _parse_ts(ts: str) -> tuple[str, float | None]:
|
||||
cleaned = ts.strip()
|
||||
# Accept a trailing timezone like +HH:MM or Z; fall back to naive parse.
|
||||
try:
|
||||
epoch = datetime.fromisoformat(cleaned.replace("Z", "+00:00")).timestamp()
|
||||
epoch = datetime.fromisoformat(
|
||||
cleaned.replace("Z", "+00:00")).timestamp()
|
||||
except ValueError:
|
||||
try:
|
||||
epoch = datetime.fromisoformat(cleaned).timestamp()
|
||||
@@ -339,7 +341,7 @@ def record_blocklist(conn: sqlite3.Connection, cidrs: Sequence[str], *,
|
||||
with transaction(conn):
|
||||
for cidr in to_add:
|
||||
_block_entry_inner(conn, cidr, added_by=added_by, last_seen=now,
|
||||
reasons=reasons)
|
||||
reasons=reasons)
|
||||
# Refresh last_seen on entries that are still desired so the ledger
|
||||
# reflects recency for existing rows too.
|
||||
conn.executemany(
|
||||
@@ -348,6 +350,52 @@ def record_blocklist(conn: sqlite3.Connection, cidrs: Sequence[str], *,
|
||||
return {"added": to_add, "active": len(before) + len(to_add)}
|
||||
|
||||
|
||||
def prune_old_single_ip_blocks(conn: sqlite3.Connection, *,
|
||||
older_than_days: int = 30,
|
||||
actor: str | None = "retention") -> list[str]:
|
||||
"""Remove active single-host entries older than ``older_than_days``.
|
||||
|
||||
Keeps CIDR ranges/subnets intact. Host entries are detected via
|
||||
``ipaddress.ip_network(..., strict=False)`` and only removed when the
|
||||
network is exactly one address (/32 for IPv4, /128 for IPv6).
|
||||
"""
|
||||
cutoff = datetime.now(timezone.utc) - \
|
||||
timedelta(days=max(0, older_than_days))
|
||||
rows = conn.execute(
|
||||
"SELECT cidr, added_at, last_seen FROM blocklist_entries "
|
||||
"WHERE status='active'").fetchall()
|
||||
|
||||
to_remove: list[str] = []
|
||||
for row in rows:
|
||||
cidr = str(row["cidr"])
|
||||
if not _is_single_host_cidr(cidr):
|
||||
continue
|
||||
seen = _parse_iso_ts(
|
||||
row["last_seen"]) or _parse_iso_ts(row["added_at"])
|
||||
if seen is None:
|
||||
continue
|
||||
if seen < cutoff:
|
||||
to_remove.append(cidr)
|
||||
|
||||
if not to_remove:
|
||||
return []
|
||||
|
||||
now = utcnow()
|
||||
with transaction(conn):
|
||||
conn.executemany(
|
||||
"UPDATE blocklist_entries SET status='removed', removed_at=? "
|
||||
"WHERE cidr=? AND status='active'",
|
||||
[(now, cidr) for cidr in to_remove],
|
||||
)
|
||||
conn.executemany(
|
||||
"INSERT INTO blocklist_events (action, cidr, actor, fired_at, detail) "
|
||||
"VALUES ('remove', ?, ?, ?, ?)",
|
||||
[(cidr, actor, now, f"single-ip-retention>{older_than_days}d")
|
||||
for cidr in to_remove],
|
||||
)
|
||||
return sorted(to_remove)
|
||||
|
||||
|
||||
def sync_blocklist(conn: sqlite3.Connection, desired_cidrs: Sequence[str], *,
|
||||
added_by: str | None = None) -> dict[str, Any]:
|
||||
"""Reconcile active blocklist to ``desired_cidrs``.
|
||||
@@ -402,6 +450,26 @@ def _cidr_version(cidr: str) -> int:
|
||||
return 4 if ":" not in str(cidr) else 6
|
||||
|
||||
|
||||
def _is_single_host_cidr(cidr: str) -> bool:
|
||||
try:
|
||||
net = ipaddress.ip_network(cidr, strict=False)
|
||||
return net.num_addresses == 1
|
||||
except ValueError:
|
||||
return False
|
||||
|
||||
|
||||
def _parse_iso_ts(value: str | None) -> datetime | None:
|
||||
if not value:
|
||||
return None
|
||||
try:
|
||||
parsed = datetime.fromisoformat(str(value).replace("Z", "+00:00"))
|
||||
except ValueError:
|
||||
return None
|
||||
if parsed.tzinfo is None:
|
||||
return parsed.replace(tzinfo=timezone.utc)
|
||||
return parsed.astimezone(timezone.utc)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Whitelists
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
Reference in new issue
Block a user