feat: add incremental log scanning with resumable byte offsets

Track last processed byte offset per log file so scans only read new
entries, add --full flag to force a rescan, and persist state atomically.
This commit is contained in:
zwitschi committed 2026-08-05 21:41:00 +02:00
1 parent f5abaaf7e5
commit 51f57f7a04
14 files changed
+133 -9

No files matched your search

+42 -7
View File
@@ -13,13 +13,14 @@ from collections import Counter
from datetime import datetime
from typing import Any, Sequence
from .files import ensure_dirs, find_logs, read_lines, validate_dir, write_csv, write_json, write_lines
from .files import ensure_dirs, find_logs, read_lines, read_lines_from, validate_dir, write_csv, write_json, write_lines
from .flags import by_total, flag_malicious_patterns
from .ip_utils import is_whitelisted, split_versions
from .mikrotik import write_mikrotik
from .models import IPStats, Request, aggregate
from .reporting import Column, columns_header, columns_row, render_kv, render_table, timestamp_name
from .constants import DEFAULT_TOP, DEFAULT_TOP_PATHS
from . import scan_state
from .whitelists import refresh_whitelists
@@ -44,13 +45,15 @@ class Analyzer:
flag_note = ""
def __init__(self, log_path: str, report_dir: str, top: int = DEFAULT_TOP,
quiet: bool = False, write_rsc: bool = True):
quiet: bool = False, write_rsc: bool = True,
full: bool = False):
self.log_path = log_path
self.report_dir = report_dir
self.data_dir = os.path.join(report_dir, "data")
self.top = top
self.quiet = quiet
self.write_rsc = write_rsc
self.full = full
self.files: list[str] = []
self.requests: list[Request] = []
self.stats: dict[str, IPStats] = {}
@@ -89,14 +92,46 @@ class Analyzer:
return self.files
def load(self) -> list[Request]:
"""Read log entries, resuming from the last processed offset.
Non-gzip logs are read incrementally (only new bytes since the last
scan) unless ``full`` is set; gzip archives are always fully read
because they cannot be seeked.
"""
state: dict[str, int] = {}
if not self.full:
state = scan_state.load_state()
# Prune entries whose log file no longer exists (safe across
# analyzers: only removes dead keys, keeps live ones).
state = {k: v for k, v in state.items() if os.path.exists(k)}
for name in self.files:
full_path = os.path.join(self.log_path, name)
count = 0
for line in read_lines(os.path.join(self.log_path, name)):
req = self.parse_line(line)
if req is not None:
self.requests.append(req)
count += 1
if self.full or full_path.endswith(".gz"):
for line in read_lines(full_path):
req = self.parse_line(line)
if req is not None:
self.requests.append(req)
count += 1
else:
try:
size = os.path.getsize(full_path)
except OSError:
size = 0
recorded = state.get(full_path, 0)
# Reset to the start if the file shrank or was truncated.
start = recorded if 0 < recorded <= size else 0
last_resume = start
for line, resume in read_lines_from(full_path, start):
req = self.parse_line(line)
if req is not None:
self.requests.append(req)
count += 1
last_resume = resume
state[full_path] = last_resume
self.per_file[name] = count
if not self.full:
scan_state.save_state(state)
return self.requests
def analyze(self) -> dict[str, IPStats]:
+3
View File
@@ -53,6 +53,9 @@ MIKROTIK_DATA_DIR = os.path.join(DATA_DIR, "mikrotik")
WHOIS_CACHE_PATH = os.path.join(DATA_DIR, "whois_cache.json")
# Incremental-scan state: absolute log path -> last processed byte offset
SCAN_STATE_PATH = os.path.join(DATA_DIR, "scan_state.json")
# ---------------------------------------------------------------------------
# Log sources (configurable via environment)
# ---------------------------------------------------------------------------
+3
View File
@@ -164,6 +164,8 @@ def main() -> None:
COPYPARTY_LOG_PATH, COPYPARTY_REPORT_DIR)
parser.add_argument("--min-denied", type=int, default=DEFAULT_MIN_DENIED,
help=f"minimum denied hits before flagging (default: {DEFAULT_MIN_DENIED})")
parser.add_argument("--full", action="store_true",
help="rescan all logs from the start (ignore incremental state)")
args = parser.parse_args()
CopypartyAnalyzer(
@@ -171,6 +173,7 @@ def main() -> None:
report_dir=args.report_dir,
top=args.top,
quiet=args.quiet,
full=args.full,
min_denied=args.min_denied,
).run()
+20
View File
@@ -60,6 +60,26 @@ def read_lines(path: str) -> Iterator[str]:
yield ANSI_RE.sub("", line.rstrip("\r\n"))
def read_lines_from(path: str, start: int = 0) -> Iterator[tuple[str, int]]:
"""Yield ``(line, resume_offset)`` for a plain-text log from byte ``start``.
Only seekable (non-gzip) files can be resumed. ``resume_offset`` points
after the last complete line; a trailing partial line (no newline yet) is
yielded but resets the resume offset to its start so it is re-read once
completed. The last yielded ``resume_offset`` is the value to store.
"""
with open(path, "rb") as f:
f.seek(start)
pos = start
for raw in f:
line_start = pos
pos += len(raw)
complete = raw.endswith(b"\n") or raw.endswith(b"\r")
text = ANSI_RE.sub(
"", raw.decode("utf-8", errors="replace").rstrip("\r\n"))
yield text, (pos if complete else line_start)
# -- writers ----------------------------------------------------------------
def write_csv(path: str, header: Sequence[str],
+3
View File
@@ -151,6 +151,8 @@ def main() -> None:
help=f"minimum error responses to flag (default: {DEFAULT_MIN_ERRORS})")
parser.add_argument("--error-ratio", type=float, default=DEFAULT_ERROR_RATIO,
help=f"minimum error ratio to flag (default: {DEFAULT_ERROR_RATIO})")
parser.add_argument("--full", action="store_true",
help="rescan all logs from the start (ignore incremental state)")
args = parser.parse_args()
NpmAnalyzer(
@@ -158,6 +160,7 @@ def main() -> None:
report_dir=args.report_dir,
top=args.top,
quiet=args.quiet,
full=args.full,
min_errors=args.min_errors,
error_ratio=args.error_ratio,
).run()
+6
View File
@@ -48,6 +48,7 @@ class ScanParams:
report_dir: str = REPORT_DIR
top: int = DEFAULT_TOP
quiet: bool = False
full: bool = False
update_firewall: bool = False
dry_run: bool = False
@@ -105,6 +106,7 @@ def run_scan(params: ScanParams) -> ScanResult:
report_dir=params.copyparty_report_dir,
top=params.top,
quiet=params.quiet,
full=params.full,
min_denied=params.min_denied,
)
@@ -113,6 +115,7 @@ def run_scan(params: ScanParams) -> ScanResult:
report_dir=params.npm_report_dir,
top=params.top,
quiet=params.quiet,
full=params.full,
min_errors=params.min_errors,
error_ratio=params.error_ratio,
)
@@ -237,6 +240,8 @@ def _build_parser() -> argparse.ArgumentParser:
help=f"rows in the top-IP tables (default: {DEFAULT_TOP})")
parser.add_argument("--quiet", action="store_true",
help="write files, suppress console reports")
parser.add_argument("--full", action="store_true",
help="rescan all log files from the start (ignore incremental state)")
parser.add_argument("--update-firewall", action="store_true",
help="synchronize the MikroTik Blocked list after scanning")
parser.add_argument("--dry-run", action="store_true",
@@ -257,6 +262,7 @@ def main(argv: list[str] | None = None) -> None:
report_dir=args.report_dir,
top=args.top,
quiet=args.quiet,
full=args.full,
update_firewall=args.update_firewall,
dry_run=args.dry_run,
)
+33
View File
@@ -0,0 +1,33 @@
#!/usr/bin/env python3
"""Persistent state for incremental log scanning.
Tracks the last processed byte offset per absolute log file path so a scan
only reads new entries instead of re-reading the whole file. The state file
is written atomically (temp file + rename) to avoid corruption.
"""
from __future__ import annotations
import json
import os
from .constants import SCAN_STATE_PATH
def load_state() -> dict[str, int]:
"""Return ``{absolute log path: byte offset}`` from the scan-state file."""
try:
with open(SCAN_STATE_PATH, "r", encoding="utf-8") as f:
data = json.load(f)
return {k: int(v) for k, v in data.items()}
except (OSError, ValueError, TypeError):
return {}
def save_state(state: dict[str, int]) -> None:
"""Write the scan-state file atomically (temp file + rename)."""
os.makedirs(os.path.dirname(SCAN_STATE_PATH), exist_ok=True)
tmp = SCAN_STATE_PATH + ".tmp"
with open(tmp, "w", encoding="utf-8") as f:
json.dump(state, f, indent=2, sort_keys=True)
os.replace(tmp, SCAN_STATE_PATH)
+2
View File
@@ -96,5 +96,7 @@ endblock %} {% block content %}
}
loadDashboard();
// Keep the latest-scans table fresh without hammering /api/scan/status.
setInterval(loadDashboard, 15000);
</script>
{% endblock %}
+8 -1
View File
@@ -42,6 +42,10 @@
><input id="dry_run" type="checkbox" /> Dry-run (preview, no
changes)</label
>
<label class="check"
><input id="full" type="checkbox" /> Full scan (ignore incremental
state)</label
>
</form>
<div class="panel-actions">
<button class="btn primary" id="run-btn">Run scan</button>
@@ -86,6 +90,7 @@
parseFloat(document.getElementById("error_ratio").value) || 0.5,
update_firewall: document.getElementById("update_firewall").checked,
dry_run: document.getElementById("dry_run").checked,
full: document.getElementById("full").checked,
quiet: true,
};
runBtn.disabled = true;
@@ -173,6 +178,8 @@
runBtn.addEventListener("click", runScan);
refreshJobs();
setInterval(refreshJobs, 5000);
// /api/scan/status is only polled every 15 s; the active job's own
// 2 s poll keeps live progress without hammering the status endpoint.
setInterval(refreshJobs, 15000);
</script>
{% endblock %}
+1
View File
@@ -102,6 +102,7 @@ def _params_from_request() -> ScanParams:
report_dir=str(_pick("report_dir", REPORT_DIR)),
top=int(_pick("top", DEFAULT_TOP)),
quiet=bool(data.get("quiet", False)),
full=bool(data.get("full", False)),
update_firewall=bool(data.get("update_firewall", False)),
dry_run=bool(data.get("dry_run", False)),
)
+2
View File
@@ -100,6 +100,7 @@ def start_scan(kind: ScanKind, params: ScanParams) -> dict[str, Any]:
report_dir=params.copyparty_report_dir,
top=params.top,
quiet=params.quiet,
full=params.full,
min_denied=params.min_denied,
).run()
job.result = ScanResult(ok=True)
@@ -111,6 +112,7 @@ def start_scan(kind: ScanKind, params: ScanParams) -> dict[str, Any]:
report_dir=params.npm_report_dir,
top=params.top,
quiet=params.quiet,
full=params.full,
min_errors=params.min_errors,
error_ratio=params.error_ratio,
).run()