feat(monitors): a DNS monitor can name a resolver; the host page puts Ansible above Docker (#5214)
CI / lint (push) Successful in 2s
CI / integration (push) Successful in 53s
CI / unit (push) Successful in 1m19s
CI / publish (push) Successful in 1m20s

DNS monitors take an optional Resolver, a server's IP. Set, the check asks
that server directly for A and AAAA over UDP and records how long it took
to answer; empty keeps today's lookup through the container's resolver.
"Does my local DNS still answer for public names" and "does 1.1.1.1" are
now separate monitors. No new dependency.

On a host's page the Ansible card moves above the Docker list, which can
run to dozens of containers and hid it.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
2026-10-07 16:14:11 -04:00
co-authored by Claude Opus 5.5
parent c6fc02def5
commit 6788ce60f1
12 changed files with 270 additions and 26 deletions
+123 -6
View File
@@ -1,30 +1,53 @@
"""DNS resolution check — pure probe returning a result dict."""
"""DNS resolution check — pure probe returning a result dict.
With no resolver, the name is looked up the way the container looks names
up (getaddrinfo: /etc/hosts, then the resolvers in resolv.conf). With one,
an A and an AAAA query go straight to that server over UDP, and the time
it took to answer is the check's response time — so "does my local DNS
still answer for public names" and "does 1.1.1.1" are separate monitors.
"""
from __future__ import annotations
import asyncio
import ipaddress
import logging
import os
import socket
import struct
import time
logger = logging.getLogger(__name__)
QUERY_TIMEOUT_S = 5.0
_RCODES = {1: "FORMERR", 2: "SERVFAIL", 3: "NXDOMAIN", 4: "NOTIMP", 5: "REFUSED"}
_A, _AAAA = 1, 28
async def dns_check(address: str, expected_ip: str | None = None) -> dict:
"""Resolve `address`. Returns {is_up, resolved_ip, error_msg}.
async def dns_check(address: str, expected_ip: str | None = None,
resolver: str | None = None) -> dict:
"""Resolve `address`. Returns {is_up, resolved_ip, error_msg} and, when a
resolver is given, response_ms.
Up = resolution succeeded AND (no expected_ip, or at least one returned
record matches expected_ip). resolved_ip is the first A/AAAA record.
"""
result = await _resolve(address)
if resolver:
result = await _query_server(address, resolver)
else:
result = await _resolve(address)
out = {"response_ms": result["ms"]} if result.get("ms") is not None else {}
if not result["resolved"]:
return {"is_up": False, "resolved_ip": None, "error_msg": "Resolution failed"}
return {**out, "is_up": False, "resolved_ip": None,
"error_msg": result.get("error") or "Resolution failed"}
resolved_ip = result["ip"]
if expected_ip and expected_ip not in result["all_ips"]:
return {
**out,
"is_up": False,
"resolved_ip": resolved_ip,
"error_msg": f"Expected {expected_ip} not in {', '.join(result['all_ips'])}",
}
return {"is_up": True, "resolved_ip": resolved_ip, "error_msg": None}
return {**out, "is_up": True, "resolved_ip": resolved_ip, "error_msg": None}
async def _resolve(address: str) -> dict:
@@ -41,3 +64,97 @@ async def _resolve(address: str) -> dict:
except Exception as exc:
logger.debug("DNS resolution failed for %s: %s", address, exc)
return {"resolved": False, "ip": None, "all_ips": []}
async def _query_server(address: str, resolver: str) -> dict:
"""Ask `resolver` directly for A and AAAA, together; the slower answer is the time."""
started = time.monotonic()
answers = await asyncio.gather(_ask(address, resolver, _A), _ask(address, resolver, _AAAA),
return_exceptions=True)
ms = round((time.monotonic() - started) * 1000, 1)
ips, errors = [], []
for got in answers:
(errors if isinstance(got, Exception) else ips).append(got)
all_ips = [ip for batch in ips for ip in batch]
if all_ips:
return {"resolved": True, "ip": all_ips[0], "all_ips": all_ips, "ms": ms}
if errors:
# A timeout on both queries leaves no time worth recording.
timed_out = all(isinstance(e, TimeoutError) for e in errors) and not ips
return {"resolved": False, "ip": None, "all_ips": [], "ms": None if timed_out else ms,
"error": f"{resolver}: {errors[0]}"}
return {"resolved": False, "ip": None, "all_ips": [], "ms": ms,
"error": f"{resolver} has no A or AAAA record for {address}"}
class _Reply(asyncio.DatagramProtocol):
def __init__(self, qid: int):
self.qid = qid
self.done = asyncio.get_running_loop().create_future()
def datagram_received(self, data, addr):
if len(data) >= 2 and struct.unpack("!H", data[:2])[0] == self.qid and not self.done.done():
self.done.set_result(data)
def error_received(self, exc):
if not self.done.done():
self.done.set_exception(exc)
async def _ask(name: str, resolver: str, qtype: int) -> list[str]:
"""One UDP query; the addresses of `qtype` in the answer, or an exception saying why not."""
qid = struct.unpack("!H", os.urandom(2))[0]
loop = asyncio.get_running_loop()
transport, proto = await loop.create_datagram_endpoint(
lambda: _Reply(qid), remote_addr=(resolver, 53))
try:
transport.sendto(build_query(qid, name, qtype))
try:
data = await asyncio.wait_for(proto.done, QUERY_TIMEOUT_S)
except asyncio.TimeoutError:
raise TimeoutError(f"no answer within {QUERY_TIMEOUT_S:.0f}s") from None
finally:
transport.close()
return parse_answer(data, qtype)
def build_query(qid: int, name: str, qtype: int) -> bytes:
"""A standard recursive query for `name`."""
header = struct.pack("!HHHHHH", qid, 0x0100, 1, 0, 0, 0) # RD set, one question
labels = b"".join(bytes([len(p)]) + p.encode("idna") for p in name.strip(".").split(".") if p)
return header + labels + b"\x00" + struct.pack("!HH", qtype, 1)
def parse_answer(data: bytes, qtype: int) -> list[str]:
"""The `qtype` addresses in a reply; ValueError naming the server's error code."""
if len(data) < 12:
raise ValueError("short reply")
_, flags, qd, an, _, _ = struct.unpack("!HHHHHH", data[:12])
rcode = flags & 0x000F
if rcode:
raise ValueError(_RCODES.get(rcode, f"error code {rcode}"))
at = 12
for _ in range(qd):
at = _skip_name(data, at) + 4
ips = []
for _ in range(an):
at = _skip_name(data, at)
rtype, _, _, length = struct.unpack("!HHIH", data[at:at + 10])
at += 10
rdata = data[at:at + length]
at += length
if rtype == qtype == _A and length == 4:
ips.append(str(ipaddress.IPv4Address(rdata)))
elif rtype == qtype == _AAAA and length == 16:
ips.append(str(ipaddress.IPv6Address(rdata)))
return ips
def _skip_name(data: bytes, at: int) -> int:
while True:
n = data[at]
if n == 0:
return at + 1
if n & 0xC0 == 0xC0: # compression pointer ends the name
return at + 2
at += n + 1
+1 -1
View File
@@ -28,7 +28,7 @@ def _form_opts(form, mtype: str) -> dict:
if mtype == MonitorType.tcp.value:
return {"port": form.get("port")}
if mtype == MonitorType.dns.value:
return {"expected_ip": form.get("expected_ip")}
return {"expected_ip": form.get("expected_ip"), "resolver": form.get("resolver")}
if mtype == MonitorType.http.value:
return {
"method": form.get("method"),
+2 -1
View File
@@ -36,7 +36,8 @@ async def run_monitor(monitor: Monitor) -> dict:
elif monitor.type == MonitorType.tcp.value:
result.update(await tcp_check(monitor.target, int(cfg.get("port") or DEFAULT_TCP_PORT)))
elif monitor.type == MonitorType.dns.value:
result.update(await dns_check(monitor.target, cfg.get("expected_ip") or None))
result.update(await dns_check(monitor.target, cfg.get("expected_ip") or None,
cfg.get("resolver") or None))
elif monitor.type == MonitorType.http.value:
result.update(await http_check(
url=monitor.target,
+15 -2
View File
@@ -6,6 +6,7 @@ pipeline) call the same code and refuse the same input for the same reasons.
"""
from __future__ import annotations
import ipaddress
import json
from collections.abc import Mapping
from datetime import datetime, timedelta, timezone
@@ -21,7 +22,7 @@ HTTP_METHODS = ("GET", "HEAD", "POST")
CONFIG_KEYS: dict[str, tuple[str, ...]] = {
MonitorType.icmp.value: (),
MonitorType.tcp.value: ("port",),
MonitorType.dns.value: ("expected_ip",),
MonitorType.dns.value: ("expected_ip", "resolver"),
MonitorType.http.value: ("method", "expected_status", "content_match", "headers",
"timeout_seconds", "follow_redirects", "verify_ssl"),
}
@@ -56,6 +57,17 @@ def _headers(value) -> dict:
return value
def _resolver(value) -> str | None:
"""A DNS server's IP address, or None for the container's own resolver."""
value = str(value or "").strip()
if not value:
return None
try:
return str(ipaddress.ip_address(value))
except ValueError:
raise ValueError(f"resolver must be a DNS server's IP address (e.g. 1.1.1.1), got {value!r}.") from None
def normalise_target(mtype: str, target: str) -> str:
"""For http, default a bare host to https://; others pass through."""
target = (target or "").strip()
@@ -77,7 +89,8 @@ def build_config(mtype: str, opts: Mapping) -> dict:
if mtype == MonitorType.tcp.value:
return {"port": _whole("port", opts.get("port"), 80, 1, 65535)}
if mtype == MonitorType.dns.value:
return {"expected_ip": (str(opts.get("expected_ip") or "").strip() or None)}
return {"expected_ip": (str(opts.get("expected_ip") or "").strip() or None),
"resolver": _resolver(opts.get("resolver"))}
if mtype == MonitorType.http.value:
method = str(opts.get("method") or "GET").strip().upper()
if method not in HTTP_METHODS: