nomyo-router/compliance/buffer.py

94 lines
3 KiB
Python

"""Persistent, encrypted retry buffer for offline periods.
When the upstream is unreachable, signed payloads are written to ``buffer_dir``
as individual ``{timestamp}-{router_id}.json.enc`` files, encrypted with a key
derived from the tenant API key (see compliance.crypto). On reconnection they
are replayed oldest-first; each is deleted only after a ``202 Accepted``.
The buffer is bounded (``max_buffer_payloads``); the oldest file is dropped
when the limit is exceeded.
"""
import json
import re
from pathlib import Path
from typing import Optional
from compliance import crypto
from compliance.settings import ComplianceSettings
_SAFE = re.compile(r"[^A-Za-z0-9_.-]")
def _safe(s: str) -> str:
return _SAFE.sub("_", s)
class BufferStore:
def __init__(self, cfg: ComplianceSettings):
self._cfg = cfg
self._dir = Path(cfg.buffer_dir)
def _ensure_dir(self) -> None:
self._dir.mkdir(parents=True, exist_ok=True)
def _files(self) -> list[Path]:
if not self._dir.exists():
return []
# Filenames are ``{epoch_ms}-{router_id}.json.enc``; lexical sort on the
# zero-padded epoch prefix is chronological.
return sorted(self._dir.glob("*.json.enc"))
def count(self) -> int:
return len(self._files())
def save(self, payload: dict, timestamp_ms: int) -> Optional[Path]:
"""Encrypt and persist ``payload``. Returns the path, or None on failure."""
if not self._cfg.api_key:
return None
self._ensure_dir()
self._enforce_limit(reserve=1)
name = f"{timestamp_ms:020d}-{_safe(self._cfg.router_id)}.json.enc"
path = self._dir / name
token = crypto.encrypt(json.dumps(payload).encode("utf-8"), self._cfg.api_key)
path.write_bytes(token)
return path
def load(self, path: Path) -> Optional[dict]:
"""Decrypt and parse a buffer file. Returns None (and deletes) on corruption."""
if not self._cfg.api_key:
return None
try:
token = path.read_bytes()
except OSError:
return None
plaintext = crypto.decrypt(token, self._cfg.api_key)
if plaintext is None:
# Wrong key or corrupt file — unrecoverable, drop it.
self.delete(path)
return None
try:
return json.loads(plaintext)
except json.JSONDecodeError:
self.delete(path)
return None
def pending(self) -> list[Path]:
"""Buffered files, oldest first."""
return self._files()
def delete(self, path: Path) -> None:
try:
path.unlink()
except OSError:
pass
def clear(self) -> None:
for p in self._files():
self.delete(p)
def _enforce_limit(self, reserve: int = 0) -> None:
"""Drop oldest files until under (max - reserve)."""
limit = max(self._cfg.max_buffer_payloads - reserve, 0)
files = self._files()
while len(files) > limit:
self.delete(files.pop(0))