229 lines
6.6 KiB
Python
229 lines
6.6 KiB
Python
import asyncio
|
|
import ssl
|
|
import time
|
|
from dataclasses import dataclass
|
|
from datetime import UTC, datetime
|
|
|
|
from app.models.health import HealthCheckEntry, HealthSnapshot
|
|
from app.models.proxy import HealthCheckDocument, ProxyDocument
|
|
|
|
|
|
@dataclass(slots=True)
|
|
class Target:
|
|
name: str
|
|
host: str
|
|
port: int
|
|
protocol: str
|
|
|
|
|
|
async def check_proxy(proxy: ProxyDocument) -> HealthSnapshot:
|
|
timestamp = datetime.now(UTC)
|
|
if not proxy.healthcheck.enabled:
|
|
return HealthSnapshot(
|
|
proxy_port=proxy.proxy_port,
|
|
timestamp=timestamp,
|
|
enabled=False,
|
|
overall="disabled",
|
|
checks=[],
|
|
)
|
|
|
|
upstream_port = proxy.service.upstream_port or proxy.service.port
|
|
checks = list(
|
|
await asyncio.gather(
|
|
check_target(
|
|
Target(
|
|
name="upstream",
|
|
host=proxy.service.host,
|
|
port=upstream_port,
|
|
protocol=health_protocol(proxy),
|
|
),
|
|
proxy.healthcheck,
|
|
),
|
|
check_target(
|
|
Target(
|
|
name="proxy",
|
|
host=listen_host_for_check(proxy.listen_host),
|
|
port=proxy.proxy_port,
|
|
protocol=health_protocol(proxy),
|
|
),
|
|
proxy.healthcheck,
|
|
),
|
|
)
|
|
)
|
|
overall = "healthy" if all(check.healthy for check in checks) else "unhealthy"
|
|
return HealthSnapshot(
|
|
proxy_port=proxy.proxy_port,
|
|
timestamp=timestamp,
|
|
enabled=True,
|
|
overall=overall,
|
|
checks=checks,
|
|
)
|
|
|
|
|
|
def health_protocol(proxy: ProxyDocument) -> str:
|
|
return proxy.healthcheck.protocol or proxy.protocol
|
|
|
|
|
|
def listen_host_for_check(host: str) -> str:
|
|
if host in {"", "0.0.0.0", "::"}:
|
|
return "127.0.0.1"
|
|
return host
|
|
|
|
|
|
async def check_target(
|
|
target: Target,
|
|
config: HealthCheckDocument,
|
|
) -> HealthCheckEntry:
|
|
try:
|
|
match target.protocol:
|
|
case "http":
|
|
return await check_http(target, config, use_tls=False)
|
|
case "https":
|
|
return await check_http(target, config, use_tls=True)
|
|
case "tcp":
|
|
return await check_tcp(target, config, use_tls=False)
|
|
case "tls":
|
|
return await check_tcp(target, config, use_tls=True)
|
|
case "udp" | "dtls" | "quic" | "dns" | "http3":
|
|
return await check_udp(target, config)
|
|
case _:
|
|
return result(target, False, f"Unsupported protocol: {target.protocol}")
|
|
except TimeoutError:
|
|
return result(target, False, "Timed out")
|
|
except OSError as error:
|
|
return result(target, False, str(error))
|
|
|
|
|
|
async def check_http(
|
|
target: Target,
|
|
config: HealthCheckDocument,
|
|
*,
|
|
use_tls: bool,
|
|
) -> HealthCheckEntry:
|
|
started = time.perf_counter()
|
|
reader, writer = await open_connection(target, config, use_tls=use_tls)
|
|
try:
|
|
path = config.path if config.path.startswith("/") else f"/{config.path}"
|
|
request = (
|
|
f"{config.method} {path} HTTP/1.1\r\n"
|
|
f"Host: {target.host}\r\n"
|
|
"Connection: close\r\n\r\n"
|
|
)
|
|
writer.write(request.encode("ascii"))
|
|
await writer.drain()
|
|
line = await asyncio.wait_for(reader.readline(), config.timeout_seconds)
|
|
finally:
|
|
writer.close()
|
|
await writer.wait_closed()
|
|
|
|
parts = line.decode("iso-8859-1", errors="replace").split()
|
|
status = int(parts[1]) if len(parts) >= 2 and parts[1].isdigit() else None
|
|
expected = config.expected_status
|
|
healthy = status == expected if expected is not None else status is not None
|
|
return result(
|
|
target,
|
|
healthy,
|
|
None,
|
|
{"status": status},
|
|
benchmark_ms=elapsed_ms(started) if config.benchmark_enabled else None,
|
|
)
|
|
|
|
|
|
async def check_tcp(
|
|
target: Target,
|
|
config: HealthCheckDocument,
|
|
*,
|
|
use_tls: bool,
|
|
) -> HealthCheckEntry:
|
|
started = time.perf_counter()
|
|
_reader, writer = await open_connection(target, config, use_tls=use_tls)
|
|
writer.close()
|
|
await writer.wait_closed()
|
|
return result(
|
|
target,
|
|
True,
|
|
benchmark_ms=elapsed_ms(started) if config.benchmark_enabled else None,
|
|
)
|
|
|
|
|
|
async def check_udp(target: Target, config: HealthCheckDocument) -> HealthCheckEntry:
|
|
started = time.perf_counter()
|
|
loop = asyncio.get_running_loop()
|
|
transport, protocol = await loop.create_datagram_endpoint(
|
|
lambda: DatagramProbe(config.expect_response),
|
|
remote_addr=(target.host, target.port),
|
|
)
|
|
try:
|
|
payload = config.udp_payload.encode()
|
|
transport.sendto(payload)
|
|
if not config.expect_response:
|
|
return result(target, True, None, {"mode": "send-only"})
|
|
data = await asyncio.wait_for(protocol.response, config.timeout_seconds)
|
|
return result(
|
|
target,
|
|
True,
|
|
None,
|
|
{"bytes": len(data)},
|
|
benchmark_ms=elapsed_ms(started) if config.benchmark_enabled else None,
|
|
)
|
|
finally:
|
|
transport.close()
|
|
|
|
|
|
async def open_connection(
|
|
target: Target,
|
|
config: HealthCheckDocument,
|
|
*,
|
|
use_tls: bool,
|
|
):
|
|
if use_tls and config.verify_tls:
|
|
context = ssl.create_default_context()
|
|
elif use_tls:
|
|
context = ssl.create_default_context()
|
|
context.check_hostname = False
|
|
context.verify_mode = ssl.CERT_NONE
|
|
else:
|
|
context = None
|
|
return await asyncio.wait_for(
|
|
asyncio.open_connection(target.host, target.port, ssl=context),
|
|
config.timeout_seconds,
|
|
)
|
|
|
|
|
|
class DatagramProbe(asyncio.DatagramProtocol):
|
|
def __init__(self, expect_response: bool):
|
|
self.response = asyncio.get_running_loop().create_future()
|
|
if not expect_response:
|
|
self.response.set_result(b"")
|
|
|
|
def datagram_received(self, data, addr):
|
|
if not self.response.done():
|
|
self.response.set_result(data)
|
|
|
|
def error_received(self, exc):
|
|
if not self.response.done():
|
|
self.response.set_exception(exc)
|
|
|
|
|
|
def result(
|
|
target: Target,
|
|
healthy: bool,
|
|
error: str | None = None,
|
|
details: dict[str, object] | None = None,
|
|
benchmark_ms: float | None = None,
|
|
) -> HealthCheckEntry:
|
|
return HealthCheckEntry(
|
|
target=target.name,
|
|
host=target.host,
|
|
port=target.port,
|
|
protocol=target.protocol,
|
|
healthy=healthy,
|
|
error=error,
|
|
duration_ms=benchmark_ms,
|
|
details=details or {},
|
|
)
|
|
|
|
|
|
def elapsed_ms(started: float) -> float:
|
|
return round((time.perf_counter() - started) * 1000, 2)
|