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.
This commit is contained in:
@@ -4,63 +4,58 @@ import socket
|
|||||||
from collections.abc import Awaitable, Callable
|
from collections.abc import Awaitable, Callable
|
||||||
from urllib.parse import urlsplit, urlunsplit
|
from urllib.parse import urlsplit, urlunsplit
|
||||||
|
|
||||||
Resolver = Callable[[str, int], Awaitable[list[str]]]
|
|
||||||
|
|
||||||
|
class UnsafeTargetError(ValueError):
|
||||||
class UnsafeTarget(ValueError):
|
|
||||||
pass
|
pass
|
||||||
|
|
||||||
|
|
||||||
async def system_resolver(host: str, port: int) -> list[str]:
|
Resolver = Callable[[str, int], Awaitable[list[str]]]
|
||||||
def resolve() -> list[str]:
|
|
||||||
records = socket.getaddrinfo(host, port, type=socket.SOCK_STREAM)
|
|
||||||
|
async def resolve_addresses(host: str, port: int) -> list[str]:
|
||||||
|
loop = asyncio.get_running_loop()
|
||||||
|
records = await loop.run_in_executor(
|
||||||
|
None, lambda: 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
|
|
||||||
|
|
||||||
|
async def assert_safe_url(
|
||||||
def _require_global(address: str) -> None:
|
url: str, allowed_ports: frozenset[int], resolver: Resolver = resolve_addresses
|
||||||
try:
|
) -> str:
|
||||||
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:
|
|
||||||
parsed = urlsplit(url)
|
parsed = urlsplit(url)
|
||||||
if parsed.scheme not in {"http", "https"}:
|
if parsed.scheme not in {"http", "https"} or not parsed.hostname:
|
||||||
raise UnsafeTarget("only HTTP(S) targets are allowed")
|
raise UnsafeTargetError("only HTTP(S) targets with a hostname are allowed")
|
||||||
if parsed.username is not None or parsed.password is not None:
|
if parsed.username is not None or parsed.password is not None:
|
||||||
raise UnsafeTarget("URL credentials are not allowed")
|
raise UnsafeTargetError("target credentials are not allowed")
|
||||||
if not parsed.hostname:
|
|
||||||
raise UnsafeTarget("target has no hostname")
|
|
||||||
try:
|
try:
|
||||||
literal = ipaddress.ip_address(parsed.hostname)
|
port = parsed.port or (443 if parsed.scheme == "https" else 80)
|
||||||
except ValueError:
|
except ValueError as exc:
|
||||||
addresses = await resolver(parsed.hostname, parsed.port or (443 if parsed.scheme == "https" else 80))
|
raise UnsafeTargetError("target port is invalid") from exc
|
||||||
|
if port not in allowed_ports:
|
||||||
|
raise UnsafeTargetError("target port is not allowed")
|
||||||
|
try:
|
||||||
|
addresses = await resolver(parsed.hostname, port)
|
||||||
|
except (OSError, UnicodeError) as exc:
|
||||||
|
raise UnsafeTargetError("target hostname could not be resolved") from exc
|
||||||
if not addresses:
|
if not addresses:
|
||||||
raise OSError("DNS resolution returned no addresses")
|
raise UnsafeTargetError("target hostname returned no addresses")
|
||||||
for address in addresses:
|
for value 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:
|
||||||
if parsed.port is not None:
|
address = ipaddress.ip_address(value)
|
||||||
netloc = f"{host}:{parsed.port}"
|
except ValueError as exc:
|
||||||
|
raise UnsafeTargetError("resolver returned an invalid address") from exc
|
||||||
|
if not address.is_global:
|
||||||
|
raise UnsafeTargetError("target resolves to a non-public address")
|
||||||
|
return url
|
||||||
|
|
||||||
|
|
||||||
|
def redacted_url(url: str) -> str:
|
||||||
|
parsed = urlsplit(url)
|
||||||
|
host = parsed.hostname or "invalid"
|
||||||
|
try:
|
||||||
|
port = parsed.port
|
||||||
except ValueError:
|
except ValueError:
|
||||||
netloc = host
|
port = None
|
||||||
|
netloc = f"{host}:{port}" if port else host
|
||||||
return urlunsplit((parsed.scheme, netloc, parsed.path, "", ""))
|
return urlunsplit((parsed.scheme, netloc, parsed.path, "", ""))
|
||||||
|
|||||||
Reference in New Issue
Block a user