124 lines
4.3 KiB
Python
124 lines
4.3 KiB
Python
|
|
from __future__ import annotations
|
||
|
|
|
||
|
|
import logging
|
||
|
|
from typing import Callable
|
||
|
|
|
||
|
|
import httpx
|
||
|
|
from redis.asyncio import Redis
|
||
|
|
from sqlalchemy import text
|
||
|
|
from sqlalchemy.exc import SQLAlchemyError
|
||
|
|
from sqlalchemy.ext.asyncio import AsyncSession, async_sessionmaker
|
||
|
|
|
||
|
|
from app.config import RuntimeSettings, Settings
|
||
|
|
from app.models.intercept_log import InterceptLog
|
||
|
|
|
||
|
|
logger = logging.getLogger(__name__)
|
||
|
|
|
||
|
|
|
||
|
|
class AlertService:
|
||
|
|
def __init__(
|
||
|
|
self,
|
||
|
|
settings: Settings,
|
||
|
|
session_factory: async_sessionmaker[AsyncSession],
|
||
|
|
redis: Redis | None,
|
||
|
|
http_client: httpx.AsyncClient,
|
||
|
|
runtime_settings_getter: Callable[[], RuntimeSettings],
|
||
|
|
) -> None:
|
||
|
|
self.settings = settings
|
||
|
|
self.session_factory = session_factory
|
||
|
|
self.redis = redis
|
||
|
|
self.http_client = http_client
|
||
|
|
self.runtime_settings_getter = runtime_settings_getter
|
||
|
|
|
||
|
|
def alert_key(self, token_hash: str) -> str:
|
||
|
|
return f"sentinel:alert:{token_hash}"
|
||
|
|
|
||
|
|
async def handle_intercept(
|
||
|
|
self,
|
||
|
|
token_hash: str,
|
||
|
|
token_display: str,
|
||
|
|
bound_ip: str,
|
||
|
|
attempt_ip: str,
|
||
|
|
) -> None:
|
||
|
|
await self._write_intercept_log(token_hash, token_display, bound_ip, attempt_ip)
|
||
|
|
|
||
|
|
runtime_settings = self.runtime_settings_getter()
|
||
|
|
if self.redis is None:
|
||
|
|
logger.warning("Redis is unavailable. Intercept alert counters are disabled.")
|
||
|
|
return
|
||
|
|
|
||
|
|
try:
|
||
|
|
async with self.redis.pipeline(transaction=True) as pipeline:
|
||
|
|
pipeline.incr(self.alert_key(token_hash))
|
||
|
|
pipeline.expire(self.alert_key(token_hash), runtime_settings.alert_threshold_seconds)
|
||
|
|
result = await pipeline.execute()
|
||
|
|
except Exception:
|
||
|
|
logger.warning("Failed to update intercept alert counter.", extra={"token_hash": token_hash})
|
||
|
|
return
|
||
|
|
|
||
|
|
count = int(result[0])
|
||
|
|
if count < runtime_settings.alert_threshold_count:
|
||
|
|
return
|
||
|
|
|
||
|
|
payload = {
|
||
|
|
"token_display": token_display,
|
||
|
|
"attempt_ip": attempt_ip,
|
||
|
|
"bound_ip": bound_ip,
|
||
|
|
"count": count,
|
||
|
|
}
|
||
|
|
if runtime_settings.alert_webhook_url:
|
||
|
|
try:
|
||
|
|
await self.http_client.post(runtime_settings.alert_webhook_url, json=payload)
|
||
|
|
except httpx.HTTPError:
|
||
|
|
logger.exception("Failed to deliver alert webhook.", extra={"token_hash": token_hash})
|
||
|
|
|
||
|
|
try:
|
||
|
|
await self.redis.delete(self.alert_key(token_hash))
|
||
|
|
except Exception:
|
||
|
|
logger.warning("Failed to clear intercept alert counter.", extra={"token_hash": token_hash})
|
||
|
|
|
||
|
|
await self._mark_alerted_records(token_hash, runtime_settings.alert_threshold_seconds)
|
||
|
|
|
||
|
|
async def _write_intercept_log(
|
||
|
|
self,
|
||
|
|
token_hash: str,
|
||
|
|
token_display: str,
|
||
|
|
bound_ip: str,
|
||
|
|
attempt_ip: str,
|
||
|
|
) -> None:
|
||
|
|
async with self.session_factory() as session:
|
||
|
|
try:
|
||
|
|
session.add(
|
||
|
|
InterceptLog(
|
||
|
|
token_hash=token_hash,
|
||
|
|
token_display=token_display,
|
||
|
|
bound_ip=bound_ip,
|
||
|
|
attempt_ip=attempt_ip,
|
||
|
|
alerted=False,
|
||
|
|
)
|
||
|
|
)
|
||
|
|
await session.commit()
|
||
|
|
except SQLAlchemyError:
|
||
|
|
await session.rollback()
|
||
|
|
logger.exception("Failed to write intercept log.", extra={"token_hash": token_hash})
|
||
|
|
|
||
|
|
async def _mark_alerted_records(self, token_hash: str, threshold_seconds: int) -> None:
|
||
|
|
statement = text(
|
||
|
|
"""
|
||
|
|
UPDATE intercept_logs
|
||
|
|
SET alerted = TRUE
|
||
|
|
WHERE token_hash = :token_hash
|
||
|
|
AND intercepted_at >= NOW() - (:threshold_seconds || ' seconds')::interval
|
||
|
|
"""
|
||
|
|
)
|
||
|
|
async with self.session_factory() as session:
|
||
|
|
try:
|
||
|
|
await session.execute(
|
||
|
|
statement,
|
||
|
|
{"token_hash": token_hash, "threshold_seconds": threshold_seconds},
|
||
|
|
)
|
||
|
|
await session.commit()
|
||
|
|
except SQLAlchemyError:
|
||
|
|
await session.rollback()
|
||
|
|
logger.exception("Failed to mark intercept logs as alerted.", extra={"token_hash": token_hash})
|