""" Persistent IP ban list for Commonwealth Online servers. Stored as JSON next to the server config (default: bans.json). """ from __future__ import annotations import json import threading import time from dataclasses import dataclass from pathlib import Path from typing import Any @dataclass class BanEntry: ip: str reason: str = "" banned_at: float = 0.0 def to_dict(self) -> dict[str, Any]: return { "ip": self.ip, "reason": self.reason, "bannedAt": self.banned_at, } @classmethod def from_dict(cls, data: dict[str, Any]) -> BanEntry | None: ip = str(data.get("ip", "")).strip() if not ip: return None reason = str(data.get("reason", "") or "") banned_at = data.get("bannedAt", data.get("banned_at", 0.0)) try: banned_at_f = float(banned_at) if banned_at is not None else 0.0 except (TypeError, ValueError): banned_at_f = 0.0 return cls(ip=ip, reason=reason, banned_at=banned_at_f) class BanStore: """Thread-safe IP ban list with JSON persistence.""" def __init__(self, path: str | Path | None = None) -> None: self._path = Path(path) if path is not None else None self._lock = threading.RLock() self._bans: dict[str, BanEntry] = {} if self._path is not None: self.load() @property def path(self) -> Path | None: return self._path def set_path(self, path: str | Path | None) -> None: with self._lock: self._path = Path(path) if path is not None else None def load(self) -> None: with self._lock: if self._path is None or not self._path.exists(): self._bans = {} return try: with open(self._path, "r", encoding="utf-8") as handle: data = json.load(handle) except (OSError, json.JSONDecodeError): self._bans = {} return bans: dict[str, BanEntry] = {} raw_list = data.get("banned_ips", []) if isinstance(data, dict) else [] if isinstance(raw_list, list): for item in raw_list: if not isinstance(item, dict): continue entry = BanEntry.from_dict(item) if entry is not None: bans[entry.ip] = entry self._bans = bans def save(self) -> None: with self._lock: if self._path is None: return self._path.parent.mkdir(parents=True, exist_ok=True) payload = { "banned_ips": [entry.to_dict() for entry in sorted(self._bans.values(), key=lambda e: e.ip)] } with open(self._path, "w", encoding="utf-8", newline="\n") as handle: json.dump(payload, handle, indent=2, ensure_ascii=False) handle.write("\n") def is_banned(self, ip: str) -> bool: normalized = str(ip).strip() with self._lock: return normalized in self._bans def get_ban(self, ip: str) -> BanEntry | None: normalized = str(ip).strip() with self._lock: return self._bans.get(normalized) def ban_ip(self, ip: str, reason: str = "") -> BanEntry: normalized = str(ip).strip() if not normalized: raise ValueError("IP address cannot be empty.") entry = BanEntry( ip=normalized, reason=str(reason or ""), banned_at=time.time(), ) with self._lock: self._bans[normalized] = entry self.save() return entry def unban_ip(self, ip: str) -> bool: normalized = str(ip).strip() with self._lock: if normalized not in self._bans: return False del self._bans[normalized] self.save() return True def list_bans(self) -> list[BanEntry]: with self._lock: return [BanEntry(ip=e.ip, reason=e.reason, banned_at=e.banned_at) for e in sorted(self._bans.values(), key=lambda e: e.ip)]