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 / quality (push) Has been cancelled
ci / container (push) Has been cancelled

This commit is contained in:
2026-08-09 16:07:05 +00:00
parent 3e19a430d3
commit d1d67cf5ed

View File

@@ -2,7 +2,7 @@ import asyncio
import ipaddress import ipaddress
import socket import socket
from collections.abc import Awaitable, Callable from collections.abc import Awaitable, Callable
from urllib.parse import urlsplit from urllib.parse import urlsplit, urlunsplit
Resolver = Callable[[str, int], Awaitable[list[str]]] Resolver = Callable[[str, int], Awaitable[list[str]]]
@@ -12,27 +12,55 @@ class UnsafeTarget(ValueError):
async def system_resolver(host: str, port: int) -> list[str]: async def system_resolver(host: str, port: int) -> list[str]:
loop = asyncio.get_running_loop() def resolve() -> list[str]:
records = await loop.getaddrinfo(host, port, type=socket.SOCK_STREAM) records = socket.getaddrinfo(host, port, type=socket.SOCK_STREAM)
return sorted({record[4][0] for record in records}) return sorted({record[4][0] for record in records})
try:
return await asyncio.to_thread(resolve)
except socket.gaierror as exc:
raise OSError(f"DNS resolution failed: {exc}") from exc
def _require_global(address: str) -> None:
try:
ip = ipaddress.ip_address(address)
except ValueError as exc:
raise UnsafeTarget("DNS returned an invalid address") from exc
if not ip.is_global:
raise UnsafeTarget("target resolves to a non-public address")
async def validate_target(url: str, resolver: Resolver = system_resolver) -> None: async def validate_target(url: str, resolver: Resolver = system_resolver) -> None:
parsed = urlsplit(url) parsed = urlsplit(url)
if parsed.scheme not in {"http", "https"} or not parsed.hostname: if parsed.scheme not in {"http", "https"}:
raise UnsafeTarget("unsupported_target") raise UnsafeTarget("only HTTP(S) targets are allowed")
if parsed.username or parsed.password: if parsed.username is not None or parsed.password is not None:
raise UnsafeTarget("credentials_not_allowed") raise UnsafeTarget("URL credentials are not allowed")
port = parsed.port or (443 if parsed.scheme == "https" else 80) if not parsed.hostname:
raise UnsafeTarget("target has no hostname")
try: try:
addresses = await resolver(parsed.hostname, port) literal = ipaddress.ip_address(parsed.hostname)
except (OSError, socket.gaierror) as exc: except ValueError:
raise UnsafeTarget("dns_resolution_failed") from exc addresses = await resolver(parsed.hostname, parsed.port or (443 if parsed.scheme == "https" else 80))
if not addresses: if not addresses:
raise UnsafeTarget("dns_resolution_failed") raise OSError("DNS resolution returned no addresses")
for address in addresses:
_require_global(address)
else:
_require_global(str(literal))
def redact_url(url: str) -> str:
"""Retain scheme/host/port/path, never credentials, query, or fragment."""
parsed = urlsplit(url)
host = parsed.hostname or ""
if ":" in host and not host.startswith("["):
host = f"[{host}]"
netloc = host
try: try:
safe = all(ipaddress.ip_address(address).is_global for address in addresses) if parsed.port is not None:
except ValueError as exc: netloc = f"{host}:{parsed.port}"
raise UnsafeTarget("dns_resolution_failed") from exc except ValueError:
if not safe: netloc = host
raise UnsafeTarget("non_public_target") return urlunsplit((parsed.scheme, netloc, parsed.path, "", ""))