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>
161 lines
6.2 KiB
Python
161 lines
6.2 KiB
Python
"""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,
|
|
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.
|
|
"""
|
|
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 {**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 {**out, "is_up": True, "resolved_ip": resolved_ip, "error_msg": None}
|
|
|
|
|
|
async def _resolve(address: str) -> dict:
|
|
"""Resolve hostname. Returns all A/AAAA records; first is the 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("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
|