from __future__ import annotations import json import os import shutil import sqlite3 import tarfile import tempfile import threading import uuid from datetime import datetime, timezone from pathlib import Path from typing import Any class BackupManager: """Create bounded portable backups of persistent IDS state. Runtime-heavy data (Redis AOF/RDB, EVE logs and PCAP ring) is intentionally excluded. Those are caches/evidence streams, not configuration state. The SQLite database is copied with SQLite's online backup API for consistency. """ def __init__(self, db_path: str, data_dir: str = "/data", keep: int = 8) -> None: self.db_path = Path(db_path) self.data_dir = Path(data_dir) self.backup_dir = self.data_dir / "backups" self.backup_dir.mkdir(parents=True, exist_ok=True) self.keep = max(2, min(30, int(keep))) self._lock = threading.RLock() def create(self, label: str = "manual") -> dict[str, Any]: safe_label = "".join(ch if ch.isalnum() or ch in "-_." else "-" for ch in str(label or "manual"))[:40].strip("-") or "manual" stamp = datetime.now(timezone.utc).strftime("%Y%m%dT%H%M%SZ") name = f"mikrosuricata-{stamp}-{safe_label}-{uuid.uuid4().hex[:6]}.tar.gz" target = self.backup_dir / name with self._lock, tempfile.TemporaryDirectory(prefix="ms-backup-") as td: root = Path(td) db_copy = root / "ids.db" self._sqlite_backup(db_copy) manifest = { "format": 1, "created_at": datetime.now(timezone.utc).isoformat(), "label": safe_label, "contents": ["ids.db", "suricata/", "lib/suricata/update/sources/", "lib/suricata/rules/suricata.rules"], "excluded": ["redis/", "logs/", "pcap/", "backups/"], } (root / "manifest.json").write_text(json.dumps(manifest, ensure_ascii=False, indent=2) + "\n", encoding="utf-8") with tarfile.open(target, "w:gz") as tar: tar.add(db_copy, arcname="ids.db", recursive=False) tar.add(root / "manifest.json", arcname="manifest.json", recursive=False) self._add_if_exists(tar, self.data_dir / "suricata", "suricata") self._add_if_exists(tar, self.data_dir / "lib" / "suricata" / "update" / "sources", "lib/suricata/update/sources") self._add_if_exists(tar, self.data_dir / "lib" / "suricata" / "rules" / "suricata.rules", "lib/suricata/rules/suricata.rules") os.chmod(target, 0o600) self._prune() return self.info(name) or {"id": name, "path": str(target)} def list(self) -> list[dict[str, Any]]: with self._lock: paths = self._paths() out = [] for path in paths: try: stat = path.stat() except OSError: continue out.append({ "id": path.name, "size_bytes": int(stat.st_size), "created_at": datetime.fromtimestamp(stat.st_mtime, timezone.utc).isoformat(), }) return out def info(self, backup_id: str) -> dict[str, Any] | None: path = self.path(backup_id) if path is None: return None stat = path.stat() return { "id": path.name, "size_bytes": int(stat.st_size), "created_at": datetime.fromtimestamp(stat.st_mtime, timezone.utc).isoformat(), "path": str(path), } def path(self, backup_id: str) -> Path | None: name = os.path.basename(str(backup_id or "")) if not name.startswith("mikrosuricata-") or not name.endswith(".tar.gz"): return None path = (self.backup_dir / name).resolve() if path.parent != self.backup_dir.resolve() or not path.is_file(): return None return path def delete(self, backup_id: str) -> bool: path = self.path(backup_id) if path is None: return False with self._lock: try: path.unlink() return True except OSError: return False def _sqlite_backup(self, destination: Path) -> None: source = sqlite3.connect(str(self.db_path), timeout=10) target = sqlite3.connect(str(destination)) try: source.backup(target) target.execute("PRAGMA wal_checkpoint(TRUNCATE)") target.commit() finally: target.close() source.close() @staticmethod def _add_if_exists(tar: tarfile.TarFile, source: Path, arcname: str) -> None: if source.exists(): tar.add(source, arcname=arcname, recursive=True) def _paths(self) -> list[Path]: try: return sorted(self.backup_dir.glob("mikrosuricata-*.tar.gz"), key=lambda p: p.stat().st_mtime, reverse=True) except OSError: return [] def _prune(self) -> None: for path in self._paths()[self.keep:]: try: path.unlink() except OSError: pass