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)
if parsed.scheme not in {"http", "https"} or not parsed.hostname:
raise UnsafeTargetError("only absolute HTTP(S) URLs are allowed")
if parsed.username is not None or parsed.password is not None:
raise UnsafeTargetError("URL userinfo is not allowed")
try: try:
parts = urlsplit(raw) port = parsed.port or (443 if parsed.scheme == "https" else 80)
host = parts.hostname or "invalid" except ValueError as exc:
if ":" in host and not host.startswith("["): raise UnsafeTargetError("invalid target port") from exc
host = f"[{host}]" await self.resolve(parsed.hostname, port, socket.AF_UNSPEC)
port = f":{parts.port}" if parts.port else ""
query = "REDACTED" if parts.query else ""
return urlunsplit((parts.scheme, host + port, parts.path, query, ""))
except (TypeError, ValueError):
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
)
if hop == self.max_redirects:
raise TooManyRedirectsError(self._elapsed(started))
current = urljoin(current, location) current = urljoin(current, location)
continue continue
state = State.UP if 200 <= code < 400 else State.DOWN return HttpOutcome(
result = CurrentStatus(state=state, checked_at=utcnow(), response.status, self._elapsed(started), current
latency_ms=(perf_counter() - started) * 1000, )
http_status=code, final_url=redact_url(current)) except UnsafeTargetError:
event("check_finished", url=redact_url(raw_url), state=state, raise
http_status=code) except TooManyRedirectsError:
return result raise
raise SecurityError("redirect limit exceeded") except (asyncio.TimeoutError, TimeoutError) as exc:
except SecurityError: raise CheckTimeoutError(self._elapsed(started)) from exc
category = "blocked_destination" except aiohttp.ClientError as exc:
except asyncio.TimeoutError: raise CheckNetworkError(self._elapsed(started)) from exc
category = "timeout"
except (aiohttp.ClientError, OSError, ValueError): @staticmethod
category = "transport_error" def _elapsed(started: float) -> float:
event("check_failed", url=redact_url(raw_url), error=category) return round((perf_counter() - started) * 1000, 3)
return CurrentStatus(state=State.ERROR, checked_at=utcnow(),
latency_ms=(perf_counter() - started) * 1000,
error=category)