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>
79 lines
3.0 KiB
Python
79 lines
3.0 KiB
Python
"""DNS monitors that name a resolver ask it directly (#5214)."""
|
|
import asyncio
|
|
import struct
|
|
|
|
import pytest
|
|
|
|
from steward.monitors import dns
|
|
|
|
|
|
def _reply(qid, name, records, rcode=0):
|
|
"""A reply to build_query(qid, name, A): the question, then `records`
|
|
as (type, rdata), each owner name a pointer back to the question's."""
|
|
question = dns.build_query(qid, name, 1)[12:]
|
|
head = struct.pack("!HHHHHH", qid, 0x8180 | rcode, 1, len(records), 0, 0)
|
|
body = b"".join(b"\xc0\x0c" + struct.pack("!HHIH", rtype, 1, 60, len(rdata)) + rdata
|
|
for rtype, rdata in records)
|
|
return head + question + body
|
|
|
|
|
|
def test_the_query_asks_one_recursive_question():
|
|
q = dns.build_query(0x1234, "google.com.", 28)
|
|
assert q[:12] == struct.pack("!HHHHHH", 0x1234, 0x0100, 1, 0, 0, 0)
|
|
assert q[12:] == b"\x06google\x03com\x00" + struct.pack("!HH", 28, 1)
|
|
|
|
|
|
def test_an_answer_yields_its_addresses_past_a_cname():
|
|
cname = b"\x03www\xc0\x0c"
|
|
data = _reply(7, "example.com", [(5, cname), (1, bytes([93, 184, 216, 34])), (1, bytes([10, 0, 0, 1]))])
|
|
assert dns.parse_answer(data, 1) == ["93.184.216.34", "10.0.0.1"]
|
|
assert dns.parse_answer(data, 28) == []
|
|
|
|
|
|
def test_a_server_error_is_named():
|
|
with pytest.raises(ValueError, match="NXDOMAIN"):
|
|
dns.parse_answer(_reply(7, "nope.example", [], rcode=3), 1)
|
|
|
|
|
|
def _fake_ask(answers):
|
|
async def ask(name, resolver, qtype):
|
|
got = answers[qtype]
|
|
if isinstance(got, Exception):
|
|
raise got
|
|
return got
|
|
return ask
|
|
|
|
|
|
def test_a_resolver_check_is_up_and_timed(monkeypatch):
|
|
monkeypatch.setattr(dns, "_ask", _fake_ask({1: ["142.250.80.46"], 28: ["2607:f8b0::200e"]}))
|
|
res = asyncio.run(dns.dns_check("google.com", resolver="1.1.1.1"))
|
|
assert res["is_up"] and res["resolved_ip"] == "142.250.80.46"
|
|
assert isinstance(res["response_ms"], float)
|
|
|
|
|
|
def test_a_resolver_that_says_no_such_name_is_down_with_why(monkeypatch):
|
|
monkeypatch.setattr(dns, "_ask", _fake_ask({1: ValueError("NXDOMAIN"), 28: ValueError("NXDOMAIN")}))
|
|
res = asyncio.run(dns.dns_check("nope.example", resolver="192.168.0.9"))
|
|
assert not res["is_up"] and res["error_msg"] == "192.168.0.9: NXDOMAIN"
|
|
|
|
|
|
def test_a_silent_resolver_records_no_time(monkeypatch):
|
|
silent = TimeoutError("no answer within 5s")
|
|
monkeypatch.setattr(dns, "_ask", _fake_ask({1: silent, 28: silent}))
|
|
res = asyncio.run(dns.dns_check("google.com", resolver="10.9.9.9"))
|
|
assert not res["is_up"] and "response_ms" not in res
|
|
assert res["error_msg"] == "10.9.9.9: no answer within 5s"
|
|
|
|
|
|
def test_no_resolver_keeps_the_containers_lookup(monkeypatch):
|
|
called = {}
|
|
|
|
async def resolve(address):
|
|
called["address"] = address
|
|
return {"resolved": True, "ip": "10.0.0.2", "all_ips": ["10.0.0.2"]}
|
|
|
|
monkeypatch.setattr(dns, "_resolve", resolve)
|
|
monkeypatch.setattr(dns, "_ask", _fake_ask({})) # would KeyError if reached
|
|
res = asyncio.run(dns.dns_check("nas.lan"))
|
|
assert called == {"address": "nas.lan"} and res["is_up"] and "response_ms" not in res
|