Harden current match and servers public endpoints

This commit is contained in:
devRaGonSa
2026-06-10 19:48:12 +02:00
parent ee76bfde1c
commit ac1816f688
6 changed files with 369 additions and 48 deletions

View File

@@ -118,48 +118,33 @@ def build_discord_payload() -> dict[str, object]:
def build_servers_payload() -> dict[str, object]:
"""Return current server status, refreshing stale snapshots before responding."""
"""Return current server status from persisted snapshots only."""
max_snapshot_age_seconds = get_refresh_interval_seconds()
persisted_items = _select_primary_snapshot_items(
_enrich_server_items(list_latest_snapshots())
)
persisted_snapshot_at = _resolve_last_snapshot_at(persisted_items)
persisted_snapshot_age_seconds = _calculate_snapshot_age_seconds(persisted_snapshot_at)
refresh_attempted = _should_refresh_snapshot(
persisted_items,
persisted_snapshot_age_seconds,
max_snapshot_age_seconds,
)
refresh_errors: list[dict[str, object]] = []
refresh_source_policy = build_source_policy(
primary_source=get_live_data_source_kind(),
selected_source="none",
selected_source="persisted-snapshot" if persisted_items else "none",
fallback_reason=None,
source_attempts=[],
source_attempts=[
build_source_attempt(
source="persisted-snapshot",
role="served-response",
status="success" if persisted_items else "empty",
reason="public-servers-read-is-cache-only",
)
],
)
if refresh_attempted:
refreshed_items, refresh_errors, refresh_source_policy = _try_collect_real_time_snapshot()
if refreshed_items:
refreshed_snapshot_at = _resolve_last_snapshot_at(refreshed_items)
refreshed_snapshot_age_seconds = _calculate_snapshot_age_seconds(refreshed_snapshot_at)
return _build_servers_response(
items=refreshed_items,
response_source=_build_live_response_source(refresh_source_policy),
last_snapshot_at=refreshed_snapshot_at,
snapshot_age_seconds=refreshed_snapshot_age_seconds,
max_snapshot_age_seconds=max_snapshot_age_seconds,
refresh_attempted=True,
refresh_status="success",
refresh_errors=refresh_errors,
source_policy=refresh_source_policy,
)
if persisted_items:
refresh_status = "failed" if refresh_attempted else "not-needed"
response_source = (
"persisted-stale-snapshot" if refresh_attempted else "persisted-fresh-snapshot"
"persisted-stale-snapshot"
if _is_snapshot_stale(persisted_snapshot_age_seconds, max_snapshot_age_seconds)
else "persisted-fresh-snapshot"
)
return _build_servers_response(
items=persisted_items,
@@ -167,12 +152,12 @@ def build_servers_payload() -> dict[str, object]:
last_snapshot_at=persisted_snapshot_at,
snapshot_age_seconds=persisted_snapshot_age_seconds,
max_snapshot_age_seconds=max_snapshot_age_seconds,
refresh_attempted=refresh_attempted,
refresh_status=refresh_status,
refresh_attempted=False,
refresh_status="cache-only",
refresh_errors=refresh_errors,
source_policy=_infer_live_source_policy_from_items(
persisted_items,
refresh_attempted=refresh_attempted,
refresh_attempted=False,
refresh_errors=refresh_errors,
),
)
@@ -189,8 +174,8 @@ def build_servers_payload() -> dict[str, object]:
"max_snapshot_age_seconds": max_snapshot_age_seconds,
"is_stale": True,
"freshness": "stale",
"refresh_attempted": refresh_attempted,
"refresh_status": "failed" if refresh_attempted else "not-needed",
"refresh_attempted": False,
"refresh_status": "cache-only",
"refresh_errors": refresh_errors,
**refresh_source_policy,
"items": [],
@@ -415,16 +400,27 @@ def build_current_match_kill_feed_payload(
origin = get_trusted_public_scoreboard_origin(server_slug)
if origin is None:
raise ValueError("Unsupported current match server.")
feed = list_current_match_kill_feed(
server_key=origin.slug,
limit=limit,
since_event_id=since_event_id,
)
try:
feed = list_current_match_kill_feed(
server_key=origin.slug,
limit=limit,
since_event_id=since_event_id,
ensure_storage=False,
)
source_policy = _build_current_match_admin_log_source_policy(status="success")
except Exception as error: # noqa: BLE001 - public live read must degrade cleanly
feed = _empty_current_match_kill_feed_payload()
source_policy = _build_current_match_admin_log_source_policy(
status="error",
error_reason=_public_error_reason(error),
message=str(error),
)
return {
"status": "ok",
"data": {
"server_slug": origin.slug,
"server_name": origin.display_name,
**source_policy,
**feed,
},
}
@@ -435,17 +431,86 @@ def build_current_match_player_stats_payload(*, server_slug: str) -> dict[str, o
origin = get_trusted_public_scoreboard_origin(server_slug)
if origin is None:
raise ValueError("Unsupported current match server.")
stats = list_current_match_player_stats(server_key=origin.slug)
try:
stats = list_current_match_player_stats(
server_key=origin.slug,
ensure_storage=False,
)
source_policy = _build_current_match_admin_log_source_policy(status="success")
except Exception as error: # noqa: BLE001 - public live read must degrade cleanly
stats = _empty_current_match_player_stats_payload()
source_policy = _build_current_match_admin_log_source_policy(
status="error",
error_reason=_public_error_reason(error),
message=str(error),
)
return {
"status": "ok",
"data": {
"server_slug": origin.slug,
"server_name": origin.display_name,
**source_policy,
**stats,
},
}
def _empty_current_match_kill_feed_payload() -> dict[str, object]:
return {
"scope": "no-current-match-events",
"confidence": "unavailable",
"stale_events_filtered": 0,
"items": [],
}
def _empty_current_match_player_stats_payload() -> dict[str, object]:
return {
"scope": "no-current-match-events",
"confidence": "unavailable",
"source": "rcon-admin-log-current-match-summary",
"updated_at": None,
"stale_events_filtered": 0,
"items": [],
}
def _build_current_match_admin_log_source_policy(
*,
status: str,
error_reason: str | None = None,
message: str | None = None,
) -> dict[str, object]:
return build_source_policy(
primary_source=SOURCE_KIND_RCON,
selected_source="rcon-admin-log",
fallback_used=status != "success",
fallback_reason=error_reason,
source_attempts=[
build_source_attempt(
source="rcon-admin-log",
role="read-model",
status=status,
reason=error_reason,
message=message,
)
],
)
def _public_error_reason(error: Exception) -> str:
if isinstance(error, FileNotFoundError):
return "admin-log-read-model-unavailable"
if isinstance(error, TimeoutError):
return "admin-log-read-timeout"
message = str(error).lower()
if "timeout" in message or "timed out" in message:
return "admin-log-read-timeout"
if "does not exist" in message or "no such table" in message:
return "admin-log-read-model-unavailable"
return "admin-log-read-failed"
def _query_current_match_rcon_sample(server_slug: str) -> dict[str, object] | None:
"""Read one configured trusted RCON target for the current-match view."""
try:
@@ -2314,6 +2379,13 @@ def _should_refresh_snapshot(
return snapshot_age_seconds > max_snapshot_age_seconds
def _is_snapshot_stale(
snapshot_age_seconds: int | None,
max_snapshot_age_seconds: int,
) -> bool:
return snapshot_age_seconds is None or snapshot_age_seconds > max_snapshot_age_seconds
def _try_collect_real_time_snapshot() -> tuple[
list[dict[str, object]],
list[dict[str, object]],

View File

@@ -348,16 +348,21 @@ def list_current_match_kill_feed(
since_event_id: str | None = None,
db_path: Path | None = None,
now: datetime | None = None,
ensure_storage: bool = True,
) -> dict[str, object]:
"""Return safe recent kill rows for one AdminLog server window."""
resolved_path = initialize_rcon_admin_log_storage(db_path=db_path)
resolved_path = (
initialize_rcon_admin_log_storage(db_path=db_path)
if ensure_storage
else db_path or get_storage_path()
)
since_row_id = _parse_current_match_event_row_id(since_event_id)
if use_postgres_rcon_storage(explicit_sqlite_path=db_path):
from .postgres_rcon_storage import connect_postgres_compat
connection_scope = connect_postgres_compat()
else:
connection_scope = closing(sqlite3.connect(resolved_path))
connection_scope = closing(_connect_admin_log_sqlite_read(resolved_path))
with connection_scope as connection:
if isinstance(connection, sqlite3.Connection):
@@ -469,15 +474,20 @@ def list_current_match_player_stats(
server_key: str,
db_path: Path | None = None,
now: datetime | None = None,
ensure_storage: bool = True,
) -> dict[str, object]:
"""Return current-match participants and partial stats from the safe AdminLog window."""
resolved_path = initialize_rcon_admin_log_storage(db_path=db_path)
resolved_path = (
initialize_rcon_admin_log_storage(db_path=db_path)
if ensure_storage
else db_path or get_storage_path()
)
if use_postgres_rcon_storage(explicit_sqlite_path=db_path):
from .postgres_rcon_storage import connect_postgres_compat
connection_scope = connect_postgres_compat()
else:
connection_scope = closing(sqlite3.connect(resolved_path))
connection_scope = closing(_connect_admin_log_sqlite_read(resolved_path))
with connection_scope as connection:
if isinstance(connection, sqlite3.Connection):
@@ -592,6 +602,14 @@ def list_current_match_player_stats(
}
def _connect_admin_log_sqlite_read(db_path: Path) -> sqlite3.Connection:
"""Open AdminLog storage for read without creating files during public GETs."""
if not db_path.exists():
raise FileNotFoundError(f"AdminLog storage is not available: {db_path}")
uri = f"file:{db_path.as_posix()}?mode=ro"
return sqlite3.connect(uri, uri=True)
def _resolve_current_match_window(
connection: sqlite3.Connection | object,
*,

View File

@@ -1,7 +1,9 @@
from http import HTTPStatus
from datetime import datetime, timezone
import unittest
from unittest.mock import patch
from app import payloads
from app.payloads import build_current_match_payload
from app.rcon_admin_log_storage import list_current_match_player_stats, persist_rcon_admin_log_entries
from app.rcon_client import RconServerTarget
@@ -494,6 +496,72 @@ def test_current_match_player_stats_filter_stale_recent_events(tmp_path):
assert stats["items"] == []
class CurrentMatchPublicEndpointHardeningTests(unittest.TestCase):
def test_kill_feed_degrades_when_admin_log_read_fails(self) -> None:
with patch.object(
payloads,
"list_current_match_kill_feed",
side_effect=TimeoutError("read timed out"),
):
result = payloads.build_current_match_kill_feed_payload(
server_slug="comunidad-hispana-01",
limit=30,
)
data = result["data"]
self.assertEqual(result["status"], "ok")
self.assertEqual(data["items"], [])
self.assertEqual(data["confidence"], "unavailable")
self.assertTrue(data["fallback_used"])
self.assertEqual(data["fallback_reason"], "admin-log-read-timeout")
def test_player_stats_degrades_when_admin_log_read_fails(self) -> None:
with patch.object(
payloads,
"list_current_match_player_stats",
side_effect=RuntimeError("no such table: rcon_admin_log_events"),
):
result = payloads.build_current_match_player_stats_payload(
server_slug="comunidad-hispana-01",
)
data = result["data"]
self.assertEqual(result["status"], "ok")
self.assertEqual(data["items"], [])
self.assertEqual(data["updated_at"], None)
self.assertTrue(data["fallback_used"])
self.assertEqual(data["fallback_reason"], "admin-log-read-model-unavailable")
def test_servers_payload_does_not_refresh_live_on_public_get(self) -> None:
stale_snapshot = {
"server_name": "Comunidad Hispana #01",
"external_server_id": "comunidad-hispana-01",
"captured_at": "2020-01-01T00:00:00Z",
"snapshot_origin": "real-rcon",
"current_map": "carentan",
}
fake_live_source = type(
"FakeLiveSource",
(),
{"build_target_index": lambda self: {}},
)()
with (
patch.object(payloads, "list_latest_snapshots", return_value=[stale_snapshot]),
patch.object(payloads, "get_live_data_source", return_value=fake_live_source),
patch.object(payloads, "_try_collect_real_time_snapshot") as refresh,
):
result = payloads.build_servers_payload()
refresh.assert_not_called()
data = result["data"]
self.assertEqual(result["status"], "ok")
self.assertEqual(data["items"][0]["external_server_id"], "comunidad-hispana-01")
self.assertEqual(data["refresh_attempted"], False)
self.assertEqual(data["refresh_status"], "cache-only")
self.assertEqual(data["source"], "persisted-stale-snapshot")
def _build_with_rcon_sample(sample: dict[str, object]) -> dict[str, object]:
with (
patch("app.payloads.load_rcon_targets", return_value=(TARGET,)),