Sync from GitHub main #1
+95
-46
@@ -1,12 +1,11 @@
|
|||||||
"""
|
"""Persistent, fail-loud IP ban storage for Commonwealth Online servers."""
|
||||||
Persistent IP ban list for Commonwealth Online servers.
|
|
||||||
|
|
||||||
Stored as JSON next to the server config (default: bans.json).
|
|
||||||
"""
|
|
||||||
|
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import ipaddress
|
||||||
import json
|
import json
|
||||||
|
import os
|
||||||
|
import tempfile
|
||||||
import threading
|
import threading
|
||||||
import time
|
import time
|
||||||
from dataclasses import dataclass
|
from dataclasses import dataclass
|
||||||
@@ -14,35 +13,39 @@ from pathlib import Path
|
|||||||
from typing import Any
|
from typing import Any
|
||||||
|
|
||||||
|
|
||||||
@dataclass
|
@dataclass(frozen=True)
|
||||||
class BanEntry:
|
class BanEntry:
|
||||||
ip: str
|
ip: str
|
||||||
reason: str = ""
|
reason: str = ""
|
||||||
banned_at: float = 0.0
|
banned_at: float = 0.0
|
||||||
|
|
||||||
def to_dict(self) -> dict[str, Any]:
|
def to_dict(self) -> dict[str, Any]:
|
||||||
return {
|
return {"ip": self.ip, "reason": self.reason, "bannedAt": self.banned_at}
|
||||||
"ip": self.ip,
|
|
||||||
"reason": self.reason,
|
|
||||||
"bannedAt": self.banned_at,
|
|
||||||
}
|
|
||||||
|
|
||||||
@classmethod
|
@classmethod
|
||||||
def from_dict(cls, data: dict[str, Any]) -> BanEntry | None:
|
def from_dict(cls, data: dict[str, Any]) -> BanEntry | None:
|
||||||
ip = str(data.get("ip", "")).strip()
|
raw_ip = str(data.get("ip", "")).strip()
|
||||||
if not ip:
|
if not raw_ip:
|
||||||
return None
|
return None
|
||||||
reason = str(data.get("reason", "") or "")
|
try:
|
||||||
|
normalized_ip = str(ipaddress.ip_address(raw_ip))
|
||||||
|
except ValueError:
|
||||||
|
return None
|
||||||
|
reason = str(data.get("reason", "") or "")[:1024]
|
||||||
banned_at = data.get("bannedAt", data.get("banned_at", 0.0))
|
banned_at = data.get("bannedAt", data.get("banned_at", 0.0))
|
||||||
try:
|
try:
|
||||||
banned_at_f = float(banned_at) if banned_at is not None else 0.0
|
banned_at_f = float(banned_at) if banned_at is not None else 0.0
|
||||||
except (TypeError, ValueError):
|
except (TypeError, ValueError):
|
||||||
banned_at_f = 0.0
|
banned_at_f = 0.0
|
||||||
return cls(ip=ip, reason=reason, banned_at=banned_at_f)
|
return cls(ip=normalized_ip, reason=reason, banned_at=banned_at_f)
|
||||||
|
|
||||||
|
|
||||||
class BanStore:
|
class BanStore:
|
||||||
"""Thread-safe IP ban list with JSON persistence."""
|
"""Thread-safe IP ban list with atomic JSON persistence.
|
||||||
|
|
||||||
|
Existing ban files are treated as security state. If one exists but cannot be parsed,
|
||||||
|
startup fails instead of silently replacing the effective ban list with an empty one.
|
||||||
|
"""
|
||||||
|
|
||||||
def __init__(self, path: str | Path | None = None) -> None:
|
def __init__(self, path: str | Path | None = None) -> None:
|
||||||
self._path = Path(path) if path is not None else None
|
self._path = Path(path) if path is not None else None
|
||||||
@@ -59,28 +62,44 @@ class BanStore:
|
|||||||
with self._lock:
|
with self._lock:
|
||||||
self._path = Path(path) if path is not None else None
|
self._path = Path(path) if path is not None else None
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _normalize_ip(ip: str) -> str:
|
||||||
|
text = str(ip).strip()
|
||||||
|
if not text:
|
||||||
|
raise ValueError("IP address cannot be empty.")
|
||||||
|
try:
|
||||||
|
return str(ipaddress.ip_address(text))
|
||||||
|
except ValueError as error:
|
||||||
|
raise ValueError(f"Invalid IP address: {text!r}") from error
|
||||||
|
|
||||||
def load(self) -> None:
|
def load(self) -> None:
|
||||||
with self._lock:
|
with self._lock:
|
||||||
if self._path is None or not self._path.exists():
|
if self._path is None or not self._path.exists():
|
||||||
self._bans = {}
|
self._bans = {}
|
||||||
return
|
return
|
||||||
|
|
||||||
try:
|
try:
|
||||||
with open(self._path, "r", encoding="utf-8") as handle:
|
with self._path.open("r", encoding="utf-8") as handle:
|
||||||
data = json.load(handle)
|
data = json.load(handle)
|
||||||
except (OSError, json.JSONDecodeError):
|
except (OSError, UnicodeError, json.JSONDecodeError) as error:
|
||||||
self._bans = {}
|
raise RuntimeError(f"Could not load ban file {self._path}: {error}") from error
|
||||||
return
|
if not isinstance(data, dict) or not isinstance(data.get("banned_ips", []), list):
|
||||||
|
raise RuntimeError(f"Ban file {self._path} has an invalid schema.")
|
||||||
|
|
||||||
bans: dict[str, BanEntry] = {}
|
bans: dict[str, BanEntry] = {}
|
||||||
raw_list = data.get("banned_ips", []) if isinstance(data, dict) else []
|
invalid_entries = 0
|
||||||
if isinstance(raw_list, list):
|
for item in data.get("banned_ips", []):
|
||||||
for item in raw_list:
|
if not isinstance(item, dict):
|
||||||
if not isinstance(item, dict):
|
invalid_entries += 1
|
||||||
continue
|
continue
|
||||||
entry = BanEntry.from_dict(item)
|
entry = BanEntry.from_dict(item)
|
||||||
if entry is not None:
|
if entry is None:
|
||||||
bans[entry.ip] = entry
|
invalid_entries += 1
|
||||||
|
continue
|
||||||
|
bans[entry.ip] = entry
|
||||||
|
if invalid_entries:
|
||||||
|
raise RuntimeError(
|
||||||
|
f"Ban file {self._path} contains {invalid_entries} invalid entr{'y' if invalid_entries == 1 else 'ies'}."
|
||||||
|
)
|
||||||
self._bans = bans
|
self._bans = bans
|
||||||
|
|
||||||
def save(self) -> None:
|
def save(self) -> None:
|
||||||
@@ -89,39 +108,66 @@ class BanStore:
|
|||||||
return
|
return
|
||||||
self._path.parent.mkdir(parents=True, exist_ok=True)
|
self._path.parent.mkdir(parents=True, exist_ok=True)
|
||||||
payload = {
|
payload = {
|
||||||
"banned_ips": [entry.to_dict() for entry in sorted(self._bans.values(), key=lambda e: e.ip)]
|
"banned_ips": [
|
||||||
|
entry.to_dict()
|
||||||
|
for entry in sorted(self._bans.values(), key=lambda entry: entry.ip)
|
||||||
|
]
|
||||||
}
|
}
|
||||||
with open(self._path, "w", encoding="utf-8", newline="\n") as handle:
|
fd, temporary_name = tempfile.mkstemp(
|
||||||
json.dump(payload, handle, indent=2, ensure_ascii=False)
|
prefix=f".{self._path.name}.",
|
||||||
handle.write("\n")
|
suffix=".tmp",
|
||||||
|
dir=str(self._path.parent),
|
||||||
|
text=True,
|
||||||
|
)
|
||||||
|
temporary_path = Path(temporary_name)
|
||||||
|
try:
|
||||||
|
if os.name != "nt":
|
||||||
|
os.fchmod(fd, 0o600)
|
||||||
|
with os.fdopen(fd, "w", encoding="utf-8", newline="\n") as handle:
|
||||||
|
json.dump(payload, handle, indent=2, ensure_ascii=False, allow_nan=False)
|
||||||
|
handle.write("\n")
|
||||||
|
handle.flush()
|
||||||
|
os.fsync(handle.fileno())
|
||||||
|
os.replace(temporary_path, self._path)
|
||||||
|
if os.name != "nt":
|
||||||
|
os.chmod(self._path, 0o600)
|
||||||
|
except Exception:
|
||||||
|
try:
|
||||||
|
os.close(fd)
|
||||||
|
except OSError:
|
||||||
|
pass
|
||||||
|
try:
|
||||||
|
temporary_path.unlink(missing_ok=True)
|
||||||
|
except OSError:
|
||||||
|
pass
|
||||||
|
raise
|
||||||
|
|
||||||
def is_banned(self, ip: str) -> bool:
|
def is_banned(self, ip: str) -> bool:
|
||||||
normalized = str(ip).strip()
|
try:
|
||||||
|
normalized = self._normalize_ip(ip)
|
||||||
|
except ValueError:
|
||||||
|
return False
|
||||||
with self._lock:
|
with self._lock:
|
||||||
return normalized in self._bans
|
return normalized in self._bans
|
||||||
|
|
||||||
def get_ban(self, ip: str) -> BanEntry | None:
|
def get_ban(self, ip: str) -> BanEntry | None:
|
||||||
normalized = str(ip).strip()
|
try:
|
||||||
|
normalized = self._normalize_ip(ip)
|
||||||
|
except ValueError:
|
||||||
|
return None
|
||||||
with self._lock:
|
with self._lock:
|
||||||
return self._bans.get(normalized)
|
return self._bans.get(normalized)
|
||||||
|
|
||||||
def ban_ip(self, ip: str, reason: str = "") -> BanEntry:
|
def ban_ip(self, ip: str, reason: str = "") -> BanEntry:
|
||||||
normalized = str(ip).strip()
|
normalized = self._normalize_ip(ip)
|
||||||
if not normalized:
|
entry = BanEntry(ip=normalized, reason=str(reason or "")[:1024], banned_at=time.time())
|
||||||
raise ValueError("IP address cannot be empty.")
|
|
||||||
|
|
||||||
entry = BanEntry(
|
|
||||||
ip=normalized,
|
|
||||||
reason=str(reason or ""),
|
|
||||||
banned_at=time.time(),
|
|
||||||
)
|
|
||||||
with self._lock:
|
with self._lock:
|
||||||
self._bans[normalized] = entry
|
self._bans[normalized] = entry
|
||||||
self.save()
|
self.save()
|
||||||
return entry
|
return entry
|
||||||
|
|
||||||
def unban_ip(self, ip: str) -> bool:
|
def unban_ip(self, ip: str) -> bool:
|
||||||
normalized = str(ip).strip()
|
normalized = self._normalize_ip(ip)
|
||||||
with self._lock:
|
with self._lock:
|
||||||
if normalized not in self._bans:
|
if normalized not in self._bans:
|
||||||
return False
|
return False
|
||||||
@@ -131,4 +177,7 @@ class BanStore:
|
|||||||
|
|
||||||
def list_bans(self) -> list[BanEntry]:
|
def list_bans(self) -> list[BanEntry]:
|
||||||
with self._lock:
|
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)]
|
return [
|
||||||
|
BanEntry(ip=entry.ip, reason=entry.reason, banned_at=entry.banned_at)
|
||||||
|
for entry in sorted(self._bans.values(), key=lambda entry: entry.ip)
|
||||||
|
]
|
||||||
|
|||||||
Reference in New Issue
Block a user