From fd380b10244664764581d36b0fa27aae74113042 Mon Sep 17 00:00:00 2001 From: Bryan Van Deusen Date: Tue, 17 Mar 2026 19:50:49 -0400 Subject: [PATCH] feat: ping and DNS monitor coroutines --- fablednetmon/monitors/__init__.py | 0 fablednetmon/monitors/dns.py | 71 +++++++++++++++++++++++++++++++ fablednetmon/monitors/ping.py | 69 ++++++++++++++++++++++++++++++ 3 files changed, 140 insertions(+) create mode 100644 fablednetmon/monitors/__init__.py create mode 100644 fablednetmon/monitors/dns.py create mode 100644 fablednetmon/monitors/ping.py diff --git a/fablednetmon/monitors/__init__.py b/fablednetmon/monitors/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/fablednetmon/monitors/dns.py b/fablednetmon/monitors/dns.py new file mode 100644 index 0000000..5a29788 --- /dev/null +++ b/fablednetmon/monitors/dns.py @@ -0,0 +1,71 @@ +from __future__ import annotations +import asyncio +import logging +import socket + +from sqlalchemy import select +from sqlalchemy.ext.asyncio import AsyncSession + +from fablednetmon.core.alerts import record_metric +from fablednetmon.models.hosts import Host +from fablednetmon.models.monitors import DnsResult, DnsStatus + +logger = logging.getLogger(__name__) + + +async def dns_check(host: Host, session: AsyncSession) -> None: + """Resolve host and write dns_result + metrics.""" + result = await _resolve(host.address) + + if result["resolved"]: + resolved_ip = result["ip"] # first A/AAAA record (stored in DB) + all_ips = result["all_ips"] + + # Check against expected IP: pass only if at least one returned record matches + if host.dns_expected_ip and host.dns_expected_ip not in all_ips: + status = DnsStatus.failed + dns_resolved_metric = 0.0 + else: + status = DnsStatus.resolved + dns_resolved_metric = 1.0 + + # Detect IP change vs most recent successful resolution + prev_result = await session.execute( + select(DnsResult) + .where(DnsResult.host_id == host.id, DnsResult.status == DnsStatus.resolved) + .order_by(DnsResult.resolved_at.desc()) + .limit(1) + ) + prev = prev_result.scalar_one_or_none() + ip_changed = 1.0 if (prev and prev.resolved_ip != resolved_ip) else 0.0 + else: + resolved_ip = None + status = DnsStatus.failed + dns_resolved_metric = 0.0 + ip_changed = 0.0 + + session.add(DnsResult( + host_id=host.id, + status=status, + resolved_ip=resolved_ip, + )) + await session.flush() + + await record_metric(session, "dns", host.name, "resolved", dns_resolved_metric) + await record_metric(session, "dns", host.name, "ip_changed", ip_changed) + + +async def _resolve(address: str) -> dict: + """Resolve hostname. Returns all A/AAAA records; stores first as resolved_ip.""" + try: + loop = asyncio.get_running_loop() + infos = await loop.run_in_executor( + None, socket.getaddrinfo, address, None, socket.AF_UNSPEC, socket.SOCK_STREAM + ) + if infos: + all_ips = [info[4][0] for info in infos] + return {"resolved": True, "ip": all_ips[0], "all_ips": all_ips} + return {"resolved": False, "ip": None, "all_ips": []} + except Exception as exc: + logger.debug(f"DNS resolution failed for {address}: {exc}") + return {"resolved": False, "ip": None, "all_ips": []} diff --git a/fablednetmon/monitors/ping.py b/fablednetmon/monitors/ping.py new file mode 100644 index 0000000..264999c --- /dev/null +++ b/fablednetmon/monitors/ping.py @@ -0,0 +1,69 @@ +from __future__ import annotations +import asyncio +import logging +import time + +from sqlalchemy.ext.asyncio import AsyncSession + +from fablednetmon.core.alerts import record_metric +from fablednetmon.models.hosts import Host, ProbeType +from fablednetmon.models.monitors import PingResult, PingStatus + +logger = logging.getLogger(__name__) + +TCP_TIMEOUT = 5.0 +DEFAULT_TCP_PORT = 80 + + +async def ping_check(host: Host, session: AsyncSession) -> None: + """Probe a single host and write ping_result + metrics.""" + if host.probe_type == ProbeType.icmp: + result = await _icmp_ping(host.address) + else: + result = await _tcp_ping(host.address, host.probe_port or DEFAULT_TCP_PORT) + + status = PingStatus.up if result["up"] else PingStatus.down + session.add(PingResult( + host_id=host.id, + status=status, + response_time_ms=result.get("response_time_ms"), + )) + await session.flush() + + await record_metric(session, "ping", host.name, "up", 1.0 if result["up"] else 0.0) + await record_metric( + session, "ping", host.name, "response_time_ms", + result.get("response_time_ms") or 0.0, + ) + + +async def _tcp_ping(address: str, port: int) -> dict: + start = time.monotonic() + try: + reader, writer = await asyncio.wait_for( + asyncio.open_connection(address, port), + timeout=TCP_TIMEOUT, + ) + writer.close() + await writer.wait_closed() + return {"up": True, "response_time_ms": round((time.monotonic() - start) * 1000, 2)} + except Exception: + return {"up": False, "response_time_ms": None} + + +async def _icmp_ping(address: str) -> dict: + """Use system ping command (setuid binary; no raw socket privilege needed in Python).""" + start = time.monotonic() + try: + proc = await asyncio.create_subprocess_exec( + "ping", "-c", "1", "-W", "2", address, + stdout=asyncio.subprocess.DEVNULL, + stderr=asyncio.subprocess.DEVNULL, + ) + await asyncio.wait_for(proc.wait(), timeout=5.0) + if proc.returncode == 0: + return {"up": True, "response_time_ms": round((time.monotonic() - start) * 1000, 2)} + return {"up": False, "response_time_ms": None} + except Exception as exc: + logger.warning(f"ICMP ping failed for {address}: {exc}, falling back to TCP") + return await _tcp_ping(address, DEFAULT_TCP_PORT)