decomposer: generate deliverable files for Define the service contract and project architecture for the FastAPI endpoint monitoring service.; Implement the typed monitor CRUD API and concurrency-safe in-memory state according to the service design.; Implement secure on-demand endpoint checks with status updates, latency measurement, robust error handling, and redacted structured logs.; Add operational API endpoints and environment-driven runtime configuration to the monitoring service.; Create automated tests for the monitoring service.; Package the service with Docker and developer documentation.; Validate the complete project.
Some checks failed
ci / validate (push) Has been cancelled

This commit is contained in:
2026-08-09 15:41:14 +00:00
parent e7ab5bf57d
commit e8c5966411

View File

@@ -1,117 +1,125 @@
import asyncio import asyncio
import ipaddress import ipaddress
import json
import logging
import socket import socket
from dataclasses import dataclass
from time import perf_counter from time import perf_counter
from urllib.parse import urljoin, urlsplit, urlunsplit from urllib.parse import urljoin, urlsplit
import aiohttp import aiohttp
from aiohttp.abc import AbstractResolver from aiohttp.abc import AbstractResolver
from app.models import CurrentStatus, State, utcnow
from app.settings import Settings
logger = logging.getLogger("endpoint_monitor.checker") class UnsafeTargetError(Exception):
class SecurityError(Exception):
pass pass
class StaticResolver(AbstractResolver): class CheckTimeoutError(Exception):
def __init__(self, addresses: list[str]) -> None: def __init__(self, latency_ms: float) -> None:
self.addresses = addresses self.latency_ms = latency_ms
async def resolve(self, host: str, port: int = 0, family: int = socket.AF_INET):
del family class CheckNetworkError(Exception):
return [{"hostname": host, "host": address, "port": port, def __init__(self, latency_ms: float) -> None:
"family": socket.AF_INET6 if ":" in address else socket.AF_INET, self.latency_ms = latency_ms
"proto": socket.IPPROTO_TCP, "flags": socket.AI_NUMERICHOST}
for address in self.addresses]
class TooManyRedirectsError(Exception):
def __init__(self, latency_ms: float) -> None:
self.latency_ms = latency_ms
@dataclass(frozen=True)
class HttpOutcome:
status_code: int
latency_ms: float
final_url: str
class SafeResolver(AbstractResolver):
async def resolve(
self, host: str, port: int = 0, family: int = socket.AF_INET
) -> list[dict[str, object]]:
try:
infos = await asyncio.to_thread(
socket.getaddrinfo, host, port, family, socket.SOCK_STREAM
)
except socket.gaierror as exc:
raise OSError("DNS resolution failed") from exc
results: list[dict[str, object]] = []
seen: set[str] = set()
for resolved_family, _, proto, _, sockaddr in infos:
address = sockaddr[0]
ip = ipaddress.ip_address(address)
if not ip.is_global:
raise UnsafeTargetError("target resolves to a non-public address")
if address not in seen:
seen.add(address)
results.append({
"hostname": host, "host": address, "port": port,
"family": resolved_family, "proto": proto, "flags": 0,
})
if not results:
raise OSError("DNS returned no addresses")
return results
async def close(self) -> None: async def close(self) -> None:
return None return None
async def validate_url(self, url: str) -> None:
def redact_url(raw: str) -> str: parsed = urlsplit(url)
try: if parsed.scheme not in {"http", "https"} or not parsed.hostname:
parts = urlsplit(raw) raise UnsafeTargetError("only absolute HTTP(S) URLs are allowed")
host = parts.hostname or "invalid" if parsed.username is not None or parsed.password is not None:
if ":" in host and not host.startswith("["): raise UnsafeTargetError("URL userinfo is not allowed")
host = f"[{host}]" try:
port = f":{parts.port}" if parts.port else "" port = parsed.port or (443 if parsed.scheme == "https" else 80)
query = "REDACTED" if parts.query else "" except ValueError as exc:
return urlunsplit((parts.scheme, host + port, parts.path, query, "")) raise UnsafeTargetError("invalid target port") from exc
except (TypeError, ValueError): await self.resolve(parsed.hostname, port, socket.AF_UNSPEC)
return "invalid-url"
def event(name: str, **fields: object) -> None:
logger.info(json.dumps({"event": name, **fields}, separators=(",", ":")))
class EndpointChecker: class EndpointChecker:
def __init__(self, settings: Settings) -> None: def __init__(self, timeout_seconds: float, max_redirects: int) -> None:
self.settings = settings self.timeout_seconds = timeout_seconds
self.max_redirects = max_redirects
self.resolver = SafeResolver()
async def _resolve(self, host: str, port: int) -> list[str]: async def check(self, url: str) -> HttpOutcome:
loop = asyncio.get_running_loop()
answers = await loop.getaddrinfo(host, port, type=socket.SOCK_STREAM,
proto=socket.IPPROTO_TCP)
addresses = sorted({answer[4][0] for answer in answers})
if not addresses:
raise OSError("no addresses")
for raw in addresses:
address = ipaddress.ip_address(raw)
if address.ipv4_mapped is not None:
address = address.ipv4_mapped
if not address.is_global:
raise SecurityError("destination is not globally routable")
return addresses
async def _request_once(self, url: str, addresses: list[str]) -> tuple[int, str | None]:
timeout = aiohttp.ClientTimeout(total=self.settings.request_timeout_seconds,
connect=self.settings.connect_timeout_seconds)
connector = aiohttp.TCPConnector(resolver=StaticResolver(addresses), use_dns_cache=False)
async with aiohttp.ClientSession(timeout=timeout, connector=connector) as session:
async with session.get(url, allow_redirects=False) as response:
return response.status, response.headers.get("Location")
async def check(self, raw_url: str) -> CurrentStatus:
started = perf_counter() started = perf_counter()
current = raw_url timeout = aiohttp.ClientTimeout(total=self.timeout_seconds)
event("check_started", url=redact_url(raw_url)) connector = aiohttp.TCPConnector(
resolver=self.resolver, ttl_dns_cache=0, force_close=True
)
try: try:
for redirects in range(self.settings.max_redirects + 1): async with aiohttp.ClientSession(
parsed = urlsplit(current) timeout=timeout, connector=connector, trust_env=False
if parsed.scheme not in {"http", "https"} or not parsed.hostname: ) as session:
raise SecurityError("unsupported redirect destination") current = url
if parsed.username is not None or parsed.password is not None: for hop in range(self.max_redirects + 1):
raise SecurityError("URL credentials are not allowed") await self.resolver.validate_url(current)
port = parsed.port or (443 if parsed.scheme == "https" else 80) async with session.get(current, allow_redirects=False) as response:
addresses = await self._resolve(parsed.hostname, port) if response.status in {301, 302, 303, 307, 308}:
code, location = await self._request_once(current, addresses) location = response.headers.get("Location")
if code in {301, 302, 303, 307, 308} and location is not None: if location is None:
if redirects >= self.settings.max_redirects: return HttpOutcome(
raise SecurityError("redirect limit exceeded") response.status, self._elapsed(started), current
current = urljoin(current, location) )
continue if hop == self.max_redirects:
state = State.UP if 200 <= code < 400 else State.DOWN raise TooManyRedirectsError(self._elapsed(started))
result = CurrentStatus(state=state, checked_at=utcnow(), current = urljoin(current, location)
latency_ms=(perf_counter() - started) * 1000, continue
http_status=code, final_url=redact_url(current)) return HttpOutcome(
event("check_finished", url=redact_url(raw_url), state=state, response.status, self._elapsed(started), current
http_status=code) )
return result except UnsafeTargetError:
raise SecurityError("redirect limit exceeded") raise
except SecurityError: except TooManyRedirectsError:
category = "blocked_destination" raise
except asyncio.TimeoutError: except (asyncio.TimeoutError, TimeoutError) as exc:
category = "timeout" raise CheckTimeoutError(self._elapsed(started)) from exc
except (aiohttp.ClientError, OSError, ValueError): except aiohttp.ClientError as exc:
category = "transport_error" raise CheckNetworkError(self._elapsed(started)) from exc
event("check_failed", url=redact_url(raw_url), error=category)
return CurrentStatus(state=State.ERROR, checked_at=utcnow(), @staticmethod
latency_ms=(perf_counter() - started) * 1000, def _elapsed(started: float) -> float:
error=category) return round((perf_counter() - started) * 1000, 3)