diff --git a/.github/workflows/test-docker.yml b/.github/workflows/test-integration.yml similarity index 62% rename from .github/workflows/test-docker.yml rename to .github/workflows/test-integration.yml index 5c0138c..9f88d92 100644 --- a/.github/workflows/test-docker.yml +++ b/.github/workflows/test-integration.yml @@ -1,4 +1,4 @@ -name: test docker +name: test integration on: push: @@ -24,11 +24,6 @@ jobs: run: | docker build -t a-healthy-dns:test . - - name: test docker image builds successfully - run: | - # Verify the image was created - docker images a-healthy-dns:test - - name: test docker image with alias zone configuration run: | # Create an isolated network for deterministic container-to-container checks @@ -49,7 +44,7 @@ jobs: -e DNS_HOSTED_ZONE="test.example.com" \ -e DNS_ALIAS_ZONES='["test.other.com","test.another.com"]' \ -e DNS_ZONE_RESOLUTIONS='{"www":{"ips":["172.28.0.10"],"health_port":80}}' \ - -e DNS_NAME_SERVERS='["ns1.test.example.com"]' \ + -e DNS_NAME_SERVERS='["ns1.example.com"]' \ -e DNS_PORT="53053" \ -e DNS_TEST_MIN_INTERVAL="1" \ -e DNS_TEST_TIMEOUT="1" \ @@ -75,7 +70,6 @@ jobs: DNS_HOST="127.0.0.1" DNS_PORT="53053" BACKEND_IP="172.28.0.10" - EXPECTED_NS="ns1.test.example.com." wait_for_a_record() { local fqdn="$1" @@ -95,39 +89,59 @@ jobs: return 1 } - assert_dns_status() { - local fqdn="$1" - local rtype="$2" - local expected_status="$3" - local output - local actual_status + for fqdn in \ + "www.test.example.com" \ + "www.test.other.com" \ + "www.test.another.com"; do + wait_for_a_record "${fqdn}" + done + + - name: test health-check-driven dns state transitions + run: | + set -euo pipefail - output="$(dig +time=1 +tries=1 +noall +comments @"${DNS_HOST}" -p "${DNS_PORT}" "${fqdn}" "${rtype}")" - actual_status="$(printf '%s\n' "${output}" | sed -n 's/.*status: \([A-Z]*\).*/\1/p' | head -n 1)" + DNS_HOST="127.0.0.1" + DNS_PORT="53053" + BACKEND_IP="172.28.0.10" + MAX_RETRIES=20 - if [ "${actual_status}" = "${expected_status}" ]; then - echo "[OK] ${rtype} ${fqdn} ${actual_status}" - return 0 - fi + wait_for_a_record() { + local fqdn="$1" + local answer - echo "[FAIL] ${rtype} ${fqdn} expected=${expected_status} got=${actual_status:-none}" - printf '%s\n' "${output}" + for _ in $(seq 1 "${MAX_RETRIES}"); do + answer="$(dig +short +time=1 +tries=1 @"${DNS_HOST}" -p "${DNS_PORT}" "${fqdn}" A)" + printf '%s\n' "${answer}" | grep -qx "${BACKEND_IP}" && { + echo "[OK] A present: ${fqdn}" + return 0 + } + sleep 1 + done + + echo "[FAIL] A never appeared: ${fqdn}" + dig +nocmd +noall +comments +answer @"${DNS_HOST}" -p "${DNS_PORT}" "${fqdn}" A || true return 1 } - assert_ns_record() { - local zone="$1" - local ns_answer - - ns_answer="$(dig +short +time=1 +tries=1 @"${DNS_HOST}" -p "${DNS_PORT}" "${zone}" NS)" - printf '%s\n' "${ns_answer}" | grep -qx "${EXPECTED_NS}" || { - echo "[FAIL] NS ${zone}" - printf '%s\n' "${ns_answer}" - exit 1 - } - echo "[OK] NS ${zone}" + wait_for_a_record_gone() { + local fqdn="$1" + local answer + + for _ in $(seq 1 "${MAX_RETRIES}"); do + answer="$(dig +short +time=1 +tries=1 @"${DNS_HOST}" -p "${DNS_PORT}" "${fqdn}" A)" + [ -z "${answer}" ] && { + echo "[OK] A removed: ${fqdn}" + return 0 + } + sleep 1 + done + + echo "[FAIL] A still present after backend went down: ${fqdn}" + dig +nocmd +noall +comments +answer @"${DNS_HOST}" -p "${DNS_PORT}" "${fqdn}" A || true + return 1 } + # Verify state transitions for hosted zone and all alias zones for fqdn in \ "www.test.example.com" \ "www.test.other.com" \ @@ -135,16 +149,23 @@ jobs: wait_for_a_record "${fqdn}" done - for zone in \ - "test.example.com" \ - "test.other.com" \ - "test.another.com"; do - assert_ns_record "${zone}" + # Stop backend — health checks will fail; DNS must remove A records from all zones + docker stop a-healthy-dns-backend + for fqdn in \ + "www.test.example.com" \ + "www.test.other.com" \ + "www.test.another.com"; do + wait_for_a_record_gone "${fqdn}" done - assert_dns_status "www.test.other.com" "AAAA" "NOERROR" - assert_dns_status "missing.test.other.com" "A" "NXDOMAIN" - assert_dns_status "www.not-configured.example.org" "A" "NXDOMAIN" + # Restart backend — health checks will succeed; DNS must re-add A records to all zones + docker start a-healthy-dns-backend + for fqdn in \ + "www.test.example.com" \ + "www.test.other.com" \ + "www.test.another.com"; do + wait_for_a_record "${fqdn}" + done - name: test docker-compose configuration run: | diff --git a/.github/workflows/validate-tests.yml b/.github/workflows/validate-tests.yml index 8206a48..709d1d1 100644 --- a/.github/workflows/validate-tests.yml +++ b/.github/workflows/validate-tests.yml @@ -2,7 +2,7 @@ name: validate tests on: workflow_run: - workflows: ["test docker", "test python code", "test version"] + workflows: ["test integration", "test python code", "test version"] types: - completed branches: @@ -24,7 +24,7 @@ jobs: COMMIT_SHA="${{ github.event.workflow_run.head_sha }}" echo "Validating workflows for commit: $COMMIT_SHA" - WORKFLOWS=("Test Docker" "Test Python Code" "Test Version") + WORKFLOWS=("test integration" "test python code" "test version") for workflow in "${WORKFLOWS[@]}"; do echo "Checking workflow: $workflow" diff --git a/README.md b/README.md index 9e2da5e..f939f07 100644 --- a/README.md +++ b/README.md @@ -59,6 +59,7 @@ Requires Python 3.10+. | [docs/docker.md](docs/docker.md) | Docker deployment: image details, Compose, deployment patterns, container management, security, and orchestration | | [docs/configuration-reference.md](docs/configuration-reference.md) | All CLI flags and Docker env vars with defaults and examples | | [docs/troubleshooting.md](docs/troubleshooting.md) | Common issues, debugging, and operational procedures | +| [docs/RFC-conformance.md](docs/RFC-conformance.md) | RFC conformance reference: Level 1 authoritative UDP scope, minimum RFC set, current coverage per RFC, and broader-than-Level-1 scope limits | | [docs/project-brief.md](docs/project-brief.md) | Goals, non-goals, constraints, requirements | | [docs/system-patterns.md](docs/system-patterns.md) | Architecture and design patterns | | [docs/project-rules.md](docs/project-rules.md) | Toolchain, QA commands, CI/CD workflow, naming conventions | diff --git a/docker-compose.example.yml b/docker-compose.example.yml index ae01630..7ec3bcd 100644 --- a/docker-compose.example.yml +++ b/docker-compose.example.yml @@ -5,7 +5,7 @@ services: build: . ports: # Map container port to host port (host:container) - - "53053:53053/udp" + - "53:53/udp" environment: # Required parameters DNS_HOSTED_ZONE: "example.com" diff --git a/docs/RFC-conformance.md b/docs/RFC-conformance.md new file mode 100644 index 0000000..1a4e38c --- /dev/null +++ b/docs/RFC-conformance.md @@ -0,0 +1,185 @@ +# RFC Conformance + +RFC conformance reference for **A Healthy DNS** — Level 1 authoritative UDP subset. + +--- + +## 1. General purpose and scope + +### What this server is + +A Healthy DNS is an **authoritative DNS server**: it holds the definitive answers for one or more configured DNS zones and answers queries about names within those zones. It does not perform recursive lookups, does not cache answers from other servers, and does not forward queries. + +This document describes RFC conformance for the current implementation scope. Its intended audience is anyone contributing to or planning work on this project — technical readers who are not necessarily DNS specialists. + +### What "RFC conformance" means here + +DNS behaviour is standardised in a series of documents called RFCs (Request For Comments), published by the IETF. A conformant DNS server must produce responses that match the requirements in those RFCs. Failing to do so can cause resolvers, monitoring tools, or other servers to misinterpret or reject responses. + +For this project "RFC conformance" means producing wire-correct responses for every query type within the documented Level 1 scope. + +### What Level 1 covers + +Level 1 is a deliberately limited scope. It covers the minimum behaviour required to be a correct authoritative UDP server for the core authoritative responses this project is built around: A, SOA, and NS, plus the negative-response semantics that make those answers wire-correct. When DNSSEC is enabled, the implementation also publishes DNSKEY/NSEC/RRSIG material generated by `dnspython.sign_zone()`, but that broader DNSSEC artifact surface is outside the Level 1 checklist in this document. + +| Behaviour | Level 1 target | +|---|---| +| Query is for a name **outside** all hosted zones | Return **REFUSED** | +| Query is for a name **inside** a hosted zone but the owner name does not exist | Return **NXDOMAIN** (name does not exist) | +| Owner name exists but the queried record type is absent | Return **NOERROR** with an empty answer section (a **NODATA** response) | +| NODATA or NXDOMAIN response | Include the apex **SOA** record in the authority section | +| Query has malformed wire format with a recoverable DNS header (>= 12 bytes) | Return **FORMERR** | +| Query payload is shorter than the DNS header (< 12 bytes) | Drop silently (no response) | +| Query uses an unsupported opcode | Return **NOTIMP** | +| Query is not for the **IN** (Internet) class | Return **REFUSED** | +| Query has more or fewer than exactly one question | Treat as a format error | + +### What Level 1 does not cover + +- Recursive or iterative resolution +- Zone transfers (AXFR / IXFR) +- EDNS(0) extension processing +- TCP transport +- IPv6 (AAAA records) +- DNSKEY / NSEC query handling and other DNSSEC artifact details, even though the current signing path publishes those RRsets when DNSSEC is enabled + +### Key term glossary + +| Term | Meaning | +|---|---| +| **Authoritative** | The server holds the definitive records for a zone and sets the AA (Authoritative Answer) flag in its responses | +| **NXDOMAIN** | "Non-Existent Domain" — the queried name does not exist in the zone at all | +| **NODATA / NOERROR empty answer** | The queried name exists but has no records of the requested type; the response code is NOERROR (not an error) and the answer section is empty | +| **SOA in authority** | For negative responses (NXDOMAIN and NODATA) the server includes the zone's Start of Authority record in the authority section so that negative caching behaviour is well-defined | +| **REFUSED** | The server refuses to answer because the query is for a zone it does not serve | +| **FORMERR** | "Format Error" — the server cannot interpret the query because it is malformed | +| **Opcode** | A 4-bit field in the DNS message header indicating the type of operation (e.g. standard query, inverse query, notify) | +| **QCLASS / IN** | The class field in a DNS question; IN (Internet, value 1) is the only class used in modern DNS practice | + +--- + +## 2. Minimum RFCs required to fully meet the described scope + +The table below identifies the smallest set of RFCs whose requirements must be met to produce correct Level 1 responses. RFC 7766 (DNS over TCP) is not listed because Level 1 uses UDP only. + +| RFC | Title | Why it matters here | Link | +|---|---|---|---| +| RFC 1034 | Domain Names — Concepts and Facilities | Defines the authoritative server model, zone concept, NXDOMAIN, and NOERROR semantics | https://www.rfc-editor.org/rfc/rfc1034 | +| RFC 1035 | Domain Names — Implementation and Specification | Defines the DNS wire format, QDCOUNT, opcode field, response codes FORMERR and NOTIMP, and the message header | https://www.rfc-editor.org/rfc/rfc1035 | +| RFC 2181 | Clarifications to the DNS Specification | Clarifies that a DNS message must contain exactly one question (QDCOUNT = 1); tightens several ambiguities in RFC 1035 | https://www.rfc-editor.org/rfc/rfc2181 | +| RFC 2308 | Negative Caching of DNS Queries (DNS NCACHE) | Specifies that NXDOMAIN and NODATA responses must include the apex SOA in the authority section so resolvers can cache negative results correctly | https://www.rfc-editor.org/rfc/rfc2308 | + +--- + +## 3. Current coverage + +The assessments below reflect the current implementation in `indisoluble/a_healthy_dns/dns_server_udp_handler.py` and supporting modules. All Level 1 behaviours are implemented. + +--- + +### 3.1 RFC 1034 — Domain Names: Concepts and Facilities + +RFC 1034 establishes the conceptual model for authoritative DNS servers: a server is authoritative for one or more zones, answers queries about names in those zones with the AA flag set, and uses defined response codes for names that are absent or that fall outside its zones. + +RFC 1034 §6.2 — https://www.rfc-editor.org/rfc/rfc1034 describes the algorithm an authoritative server uses to process a query. + +| Behaviour | Status | Notes | +|---|---|---| +| Authoritative Answer (AA) flag set on all responses | **Implemented** | `indisoluble/a_healthy_dns/dns_server_udp_handler.py:126` sets `dns.flags.AA` on every response | +| REFUSED for queries outside all served zones | **Implemented** | `indisoluble/a_healthy_dns/dns_server_udp_handler.py:67` returns `dns.rcode.REFUSED` when the query name does not fall within any hosted or alias zone | +| NXDOMAIN when owner name is absent from an in-zone query | **Implemented** | `indisoluble/a_healthy_dns/dns_server_udp_handler.py:90` returns `dns.rcode.NXDOMAIN` when `txn.get_node(relative_name)` returns nothing | +| NOERROR when owner name exists and matching records are found | **Implemented** | Handler adds the matching RRset to the answer section | +| SOA in authority for NXDOMAIN responses (RFC 2308 §3) | **Implemented** | `_build_authority_with_apex_soa()` appends the apex SOA to `response.authority` in the NXDOMAIN branch (`indisoluble/a_healthy_dns/dns_server_udp_handler.py:91`) | +| SOA in authority for NODATA responses (RFC 2308 §2.1) | **Implemented** | Same helper populates the authority section for NOERROR/empty-answer responses (`indisoluble/a_healthy_dns/dns_server_udp_handler.py:87`) | + +--- + +### 3.2 RFC 1035 — Domain Names: Implementation and Specification + +RFC 1035 defines the DNS wire format: the message header structure (including the QDCOUNT field and opcode field), all standard record types, and the FORMERR and NOTIMP response codes. + +- RFC 1035 §4.1.1 defines the header format, including QDCOUNT and OPCODE — https://www.rfc-editor.org/rfc/rfc1035 +- RFC 1035 §4.1.2 defines the question section format +- RFC 1035 §4.1.3 defines answer, authority, and additional section formats + +| Behaviour | Status | Notes | +|---|---|---| +| Wire parsing of incoming queries | **Implemented** | `dns.message.from_wire()` is used; `dns.exception.DNSException` is caught | +| FORMERR when wire parsing fails | **Implemented** | When `dns.message.from_wire()` raises a `DNSException` other than `ShortHeader` (DNS header readable), the handler extracts the transaction ID from bytes 0–1 and responds with FORMERR (`indisoluble/a_healthy_dns/dns_server_udp_handler.py:112-123`). When `ShortHeader` is raised (payload < 12 bytes), the packet is dropped silently (`indisoluble/a_healthy_dns/dns_server_udp_handler.py:107-111`). See the note below for details. | +| Opcode validation — NOTIMP for non-QUERY opcodes | **Implemented** | `indisoluble/a_healthy_dns/dns_server_udp_handler.py:128-133`: `query.opcode() != dns.opcode.QUERY` check returns `dns.rcode.NOTIMP`. Tested with STATUS (opcode 2) and NOTIFY (opcode 4). UPDATE messages (opcode 5) are rejected by dnspython's wire parser before this check is reached. | +| QDCOUNT validation — FORMERR for ≠ 1 question | **Implemented** | `indisoluble/a_healthy_dns/dns_server_udp_handler.py:134-139`: `len(query.question) != 1` check; zero or more-than-one questions return `dns.rcode.FORMERR`. Confirmed: dnspython preserves all questions for QDCOUNT > 1 wire messages. See also RFC 2181 §5.1. | +| QCLASS / IN class validation | **Implemented** | `indisoluble/a_healthy_dns/dns_server_udp_handler.py:142-147`: `question.rdclass != dns.rdataclass.IN` check; non-IN queries return `dns.rcode.REFUSED`. Project decision: REFUSED because the server exclusively serves IN-class data. | +| Wire serialisation of responses | **Implemented** | `response.to_wire()` is called before every `sendto()` | +| A, SOA, NS record types in responses | **Implemented** | All three record types are populated by the zone updater | + +Note on malformed-wire inputs: to reply to a malformed query the server needs the transaction ID from the DNS message header, which occupies the first two bytes of every DNS packet (RFC 1035 §4.1.1). If the payload is shorter than the 12-byte DNS header those bytes are not present, so no reply is possible — the packet is dropped silently. If the payload is at least 12 bytes long, the transaction ID is readable even when the rest of the message is corrupt; in that case the server replies with a minimal FORMERR, preserving the original transaction ID so the client can match the response to its request. + +The two code paths are distinguished by the exception that dnspython raises. `dns.message.ShortHeader` is raised exclusively for payloads shorter than 12 bytes — the silent-drop case. Any other `dns.exception.DNSException` subclass (`FormError`, `BadPointer`, etc.) means the header was present but the body was malformed — the FORMERR case. Both paths are confirmed by `test_handle_malformed_wire_input_drops_silently` and `test_handle_malformed_wire_with_recoverable_header_returns_formerr` in `tests/indisoluble/a_healthy_dns/test_dns_server_udp_handler.py`; the FORMERR path is also validated by `TestMalformedWireInput` in the component integration tests. + +--- + +### 3.3 RFC 2181 — Clarifications to the DNS Specification + +RFC 2181 corrects and tightens several ambiguities in RFC 1035. The requirement most relevant to Level 1 is found in §5.1: a DNS query must contain exactly one question; a server receiving a message with QDCOUNT ≠ 1 should return FORMERR — https://www.rfc-editor.org/rfc/rfc2181. + +RFC 2181 §4 also clarifies that the AA flag applies to the entire response when the server is authoritative. + +| Behaviour | Status | Notes | +|---|---|---| +| AA flag set correctly | **Implemented** | `indisoluble/a_healthy_dns/dns_server_udp_handler.py:126` | +| FORMERR for QDCOUNT ≠ 1 (RFC 2181 §5.1) | **Implemented** | `indisoluble/a_healthy_dns/dns_server_udp_handler.py:134-139`: `len(query.question) != 1` check. Verified: dnspython preserves all questions for QDCOUNT > 1 wire messages; the check is necessary and effective. | + +No remaining Level 1 gaps in RFC 2181 coverage. + +--- + +### 3.4 RFC 2308 — Negative Caching of DNS Queries + +RFC 2308 defines how negative responses (NXDOMAIN and NODATA) must be structured so that resolvers can cache them correctly. Both response types must include the zone's apex SOA record in the authority section — RFC 2308 §3 (NXDOMAIN) and RFC 2308 §2.1 (NODATA/NOERROR) — https://www.rfc-editor.org/rfc/rfc2308. + +Without the SOA in the authority section, resolvers either cannot cache the negative result or cache it with an undefined TTL, leading to repeated unnecessary queries. + +RFC 2308 §5 defines the SOA minimum TTL field as the negative caching TTL; this project populates `SOA MINIMUM` via `calculate_soa_min_ttl()` in `records/time.py`. + +| Behaviour | Status | Notes | +|---|---|---| +| SOA record with correct `MINIMUM` field exists in zone | **Implemented** | `soa_record.py` populates the minimum TTL from `calculate_soa_min_ttl()` | +| SOA in authority section for NXDOMAIN (RFC 2308 §3) | **Implemented** | `_build_authority_with_apex_soa()` at `indisoluble/a_healthy_dns/dns_server_udp_handler.py:29-41` retrieves the apex SOA via `txn.get(dns.name.empty, dns.rdatatype.SOA)` and appends it to `response.authority` | +| SOA in authority section for NODATA (RFC 2308 §2.1) | **Implemented** | Same helper populates the authority section for NOERROR/empty-answer responses | + +No remaining Level 1 gaps in RFC 2308 coverage. + +--- + +## 4. Test coverage mapping + +| Behaviour | Test location | +|---|---| +| Positive A/SOA/NS responses (NOERROR) | `tests/indisoluble/a_healthy_dns/test_dns_server_udp_integration.py` (component integration) | +| NXDOMAIN with SOA in authority | `tests/indisoluble/a_healthy_dns/test_dns_server_udp_integration.py` (component integration) | +| NODATA with SOA in authority | `tests/indisoluble/a_healthy_dns/test_dns_server_udp_integration.py` (component integration) | +| Out-of-zone REFUSED | `tests/indisoluble/a_healthy_dns/test_dns_server_udp_integration.py` (component integration) | +| Non-IN-class REFUSED | `tests/indisoluble/a_healthy_dns/test_dns_server_udp_integration.py` (component integration) | +| NOTIMP for unsupported opcodes | `tests/indisoluble/a_healthy_dns/test_dns_server_udp_integration.py` (component integration) | +| FORMERR for QDCOUNT ≠ 1 (wire-level) | `tests/indisoluble/a_healthy_dns/test_dns_server_udp_integration.py` (component integration) | +| FORMERR for malformed wire with recoverable header (≥ 12 bytes) | `tests/indisoluble/a_healthy_dns/test_dns_server_udp_integration.py` (component integration) + `tests/indisoluble/a_healthy_dns/test_dns_server_udp_handler.py` (unit) | +| Silent drop for malformed wire shorter than 12 bytes | `tests/indisoluble/a_healthy_dns/test_dns_server_udp_handler.py` (unit — no transaction ID to reply to) | +| Response header fields (QR, ID, AA, RA, TC) | `tests/indisoluble/a_healthy_dns/test_dns_server_udp_integration.py` (component integration) | +| Health-check-driven A-record addition/removal | `.github/workflows/test-integration.yml` (Docker end-to-end) | +| Container startup, Docker networking, alias zone routing | `.github/workflows/test-integration.yml` (Docker end-to-end) | + +--- + +## 5. Out of scope for Level 1 + +All Level 1 behaviours are implemented and covered by automated tests (unit tests in `tests/indisoluble/a_healthy_dns/test_dns_server_udp_handler.py` and component integration tests in `tests/indisoluble/a_healthy_dns/test_dns_server_udp_integration.py`). + +The following broader-than-Level-1 behaviours are explicitly outside the current scope: + +- Additional out-of-zone handling such as referrals to delegated zones +- Full EDNS(0) handling (OPT pseudo-RR) +- Additional record types (AAAA, MX, TXT, etc.) +- RFC 2308 §4 referral responses — this server does not delegate sub-zones +- RFC 2308 §6 server-side negative caching — this server is authoritative and does not cache resolver results +- RFC 2181 §8 (class-in-data semantics) and §9 (TTL semantics) — informational for this scope +- RFC 7766 (DNS over TCP) — Level 1 uses UDP only diff --git a/docs/configuration-reference.md b/docs/configuration-reference.md index cada173..af6e2af 100644 --- a/docs/configuration-reference.md +++ b/docs/configuration-reference.md @@ -149,7 +149,7 @@ See [docs/system-patterns.md § 7](system-patterns.md#7-multi-domain-support-via ## DNSSEC parameters (optional) -Both parameters are optional. If `--priv-key-path` / `DNS_PRIV_KEY_PATH` is omitted, DNSSEC signing is disabled entirely and no RRSIG records are produced. +Both parameters are optional. If `--priv-key-path` / `DNS_PRIV_KEY_PATH` is omitted, DNSSEC signing is disabled entirely and no DNSSEC-generated records (`DNSKEY`, `NSEC`, `RRSIG`) are produced. ### Private key path diff --git a/docs/docker.md b/docs/docker.md index 7fff403..125beca 100644 --- a/docs/docker.md +++ b/docs/docker.md @@ -54,7 +54,7 @@ docker run -d \ ### Security Features - Runs as non-root user (`appuser`) - Minimal attack surface (slim base, minimal dependencies) -- Capability management (`CAP_NET_BIND_SERVICE` for port 53) +- File capability on the Python binary for port-53 binds; hardened runtimes that drop all capabilities must add back `NET_BIND_SERVICE` - Tini init system for proper signal handling - No unnecessary packages or build tools in final image @@ -314,11 +314,12 @@ docker inspect a-healthy-dns | jq '.[0].Config.Env' # Get shell access docker exec -it a-healthy-dns sh -# Check listening ports -docker exec a-healthy-dns netstat -uln +# Locate the installed CLI and package +docker exec a-healthy-dns which a-healthy-dns +docker exec a-healthy-dns python3 -c "import indisoluble.a_healthy_dns.main as m; print(m.__file__)" -# Test health check connectivity -docker exec a-healthy-dns nc -zv 192.168.1.100 8080 +# Test health check connectivity using the Python runtime already in the image +docker exec a-healthy-dns python3 -c "import socket; socket.create_connection(('192.168.1.100', 8080), 2).close(); print('ok')" # View Python packages docker exec a-healthy-dns pip list @@ -419,15 +420,14 @@ networks: ### DNS Resolution from Container -The container needs to reach backend IPs for health checks: +The container needs to reach backend IPs for health checks. The production image intentionally omits troubleshooting tools such as `ping`, `nc`, `nslookup`, and `netstat`, so use host-side Docker inspection plus the bundled Python runtime for connectivity checks: ```bash -# Test connectivity from container -docker exec a-healthy-dns ping -c 1 192.168.1.100 -docker exec a-healthy-dns nc -zv 192.168.1.100 8080 +# Inspect the container network from the host +docker inspect a-healthy-dns | jq '.[0].NetworkSettings' -# Check DNS resolution (if using hostnames) -docker exec a-healthy-dns nslookup backend1.local +# Test backend connectivity from inside the container +docker exec a-healthy-dns python3 -c "import socket; socket.create_connection(('192.168.1.100', 8080), 2).close(); print('ok')" ``` ## Security Hardening @@ -456,6 +456,8 @@ docker run -d \ indisoluble/a-healthy-dns ``` +Use the extra `NET_BIND_SERVICE` capability only when you intentionally harden the runtime with `--cap-drop=ALL` (or equivalent) and still want the process to bind container port `53`. With Docker's default capability bounding set, the image's built-in `setcap` is sufficient for a non-root bind to port `53`. + ### Read-Only Root Filesystem ```bash @@ -504,14 +506,14 @@ For Docker-specific issues — container exits immediately, port binding failure docker pull indisoluble/a-healthy-dns:latest # Pull specific version -docker pull indisoluble/a-healthy-dns:v0.1.26 +docker pull indisoluble/a-healthy-dns: ``` ### Version Pinning ```bash # Pin to specific version (recommended for production) -docker run -d ... indisoluble/a-healthy-dns:v0.1.26 +docker run -d ... indisoluble/a-healthy-dns: # Use latest (for development) docker run -d ... indisoluble/a-healthy-dns:latest @@ -524,7 +526,7 @@ docker run -d ... indisoluble/a-healthy-dns:latest docker image prune -a # Remove specific image -docker rmi indisoluble/a-healthy-dns:v0.1.25 +docker rmi indisoluble/a-healthy-dns: # Remove dangling images docker image prune diff --git a/docs/project-brief.md b/docs/project-brief.md index 106073b..bc048b2 100644 --- a/docs/project-brief.md +++ b/docs/project-brief.md @@ -11,9 +11,9 @@ Standard authoritative DNS servers return static records. When a backend becomes ## Goals 1. **Automatic failover** — remove unhealthy IP addresses from DNS responses without manual intervention. -2. **Authoritative DNS** — act as a fully authoritative UDP DNS server for one or more configured zones. +2. **Authoritative DNS** — act as a fully authoritative UDP DNS server for one hosted zone plus any configured alias zones that reuse the same health-checked data. 3. **Multi-domain support** — serve multiple domain aliases from a single set of health-checked records, without duplicating health check logic. -4. **Optional DNSSEC** — sign zones on-the-fly when a private key is provided, producing RRSIG records for A, NS, and SOA records. +4. **Optional DNSSEC** — sign zones on-the-fly when a private key is provided, publishing the DNSSEC material that `dnspython.sign_zone()` generates for the zone (including DNSKEY, NSEC, and corresponding RRSIG records). 5. **Configurable health checking** — allow operators to tune check interval, TCP timeout, and health port per subdomain. 6. **Operational simplicity** — deployable as a single Python process or Docker container with environment-variable configuration. @@ -32,10 +32,10 @@ Standard authoritative DNS servers return static records. When a backend becomes |---|---| | Python version | ≥ 3.10 | | Transport | UDP only (standard DNS port or configurable alternative) | -| DNS library | `dnspython ≥ 2.8, < 3.0` | -| DNSSEC crypto | `cryptography ≥ 46.0.5, < 47.0` | +| DNS library | `dnspython ≥ 2.8.0, < 3.0.0` | +| DNSSEC crypto | `cryptography ≥ 46.0.5, < 47.0.0` | | Health check protocol | TCP connectivity test (`socket.create_connection`) | -| Record type served | A records only (plus SOA, NS, RRSIG when DNSSEC is enabled) | +| Record type served | Base records: A, SOA, and NS. With DNSSEC enabled, the zone also publishes DNSKEY, NSEC, and corresponding RRSIG records | | Concurrency model | Single UDP server thread + one background zone-updater thread | ## Key requirements @@ -49,7 +49,7 @@ Standard authoritative DNS servers return static records. When a backend becomes - **R5** Return `NXDOMAIN` (no records) when all IPs for a subdomain are unhealthy. - **R6** Support alias zones that resolve identically to the primary zone without separate health check state. - **R7** Compute SOA timing values (TTL, refresh, retry, expire, minimum TTL) from health check parameters, keeping them consistent with check intervals. -- **R8** When a DNSSEC private key is provided, sign all zone records (A, NS, SOA) and include RRSIG records in responses. +- **R8** When a DNSSEC private key is provided, sign the zone and publish the DNSSEC artifacts generated by `dnspython.sign_zone()` (DNSKEY, NSEC, and RRSIGs for signed RRsets) alongside the base A, NS, and SOA data. ### Operational diff --git a/docs/project-rules.md b/docs/project-rules.md index 4adf2b0..6d0a839 100644 --- a/docs/project-rules.md +++ b/docs/project-rules.md @@ -97,13 +97,28 @@ pytest --cov=indisoluble.a_healthy_dns --cov-report=html ## 6. Test conventions +### 6.1 Unit tests + - **Framework:** `pytest` with standard fixtures and `unittest.mock`. -- **Test file location:** must mirror the source path. Example: `indisoluble/a_healthy_dns/records/a_healthy_ip.py` → `tests/indisoluble/a_healthy_dns/records/test_a_healthy_ip.py`. +- **Test file location:** for module-focused tests, mirror the source path. Example: `indisoluble/a_healthy_dns/records/a_healthy_ip.py` → `tests/indisoluble/a_healthy_dns/records/test_a_healthy_ip.py`. Cross-cutting behavior tests that do not map to a single source module may live at `tests/indisoluble/a_healthy_dns/`. - **Test file naming:** `test_.py`. - **No real network calls in unit tests.** Mock `can_create_connection` or `socket.create_connection` for any test that exercises health logic. - **No real time dependencies.** Mock `time.time`, `datetime.datetime.now`, or `uint32_current_time` as needed to keep tests deterministic. - **One assert per test when practical** (AGENTS.md §5.9). -- **Coverage exclusions** (`.coveragerc`) include: `__repr__`, `raise NotImplementedError`, `if __name__ == '__main__'`, `pass`, and `pragma: no cover` markers. +- **Coverage exclusions** (`.coveragerc`) include: `__repr__`, `raise NotImplementedError`, `raise ImportError`, `if __name__ == '__main__'`, `pass`, and `pragma: no cover` markers. + +### 6.2 Component integration tests + +Component integration tests exercise a production component end-to-end over real I/O (e.g. real UDP sockets), but with pre-populated in-memory state rather than the live health-check lifecycle. + +- **Test file location:** same directory as the corresponding unit tests, mirroring the source tree. +- **Test file naming:** `test__integration.py` — the `_integration` suffix distinguishes them from unit tests. Example: `tests/indisoluble/a_healthy_dns/test_dns_server_udp_integration.py`. +- **No real health-check lifecycle.** Zone state is pre-populated via `DnsServerZoneUpdater.update(check_ips=False)`. Tests that verify dynamic A-record changes driven by TCP health checks belong in `test-integration.yml`. +- **One assert per test when practical** (same as unit tests). + +### 6.3 Docker end-to-end tests + +Docker end-to-end tests validate the fully packaged application, including health-check-driven DNS state transitions. They live in `.github/workflows/test-integration.yml` and use an isolated Docker network with a real nginx backend. See §7 below. --- @@ -113,16 +128,18 @@ All workflows target the `master` branch. | Workflow | File | Trigger | Purpose | |---|---|---|---| -| `test python code` | `test-py-code.yml` | push/PR → master | Runs pytest with coverage, uploads to Codecov | -| `test docker` | `test-docker.yml` | push/PR → master | Builds Docker image, runs end-to-end DNS resolution tests | +| `test python code` | `test-py-code.yml` | push/PR → master | Runs pytest (unit + component integration tests) with coverage, uploads to Codecov | +| `test integration` | `test-integration.yml` | push/PR → master | Builds Docker image; runs end-to-end tests including health-check-driven DNS state transitions | | `test version` | `test-version.yml` | push/PR → master | Verifies version in `setup.py` was increased | -| `validate tests` | `validate-tests.yml` | after above three complete | Gate: all three must pass for the same commit | +| `validate tests` | `validate-tests.yml` | `workflow_run` on any of the three above | Gate: all three must pass for the same commit | | `release version` | `release-version.yml` | after `validate tests` succeeds | Creates git tag + GitHub release from `setup.py` version | | `release docker` | `release-docker.yml` | after `release version` succeeds | Pushes Docker image to Docker Hub | | `security scan` | `security-scan.yml` | push → master | Trivy vulnerability scan on Docker image, uploads SARIF to GitHub Security tab | **Rule:** never push directly to `master` from a branch that hasn't passed all three gate workflows. +**`validate tests` trigger model:** GitHub Actions `workflow_run` fires each time *any one* of the listed upstream workflows completes — not once after all three are done. This means `validate tests` may run before the other two upstream workflows have finished; early runs that fail because a sibling workflow hasn't completed yet are expected and not the final picture. The meaningful result is the run that executes after all three required workflows for the same commit SHA have completed. This is an implementation detail of the current `workflow_run` model; the intended policy (all three must pass) is unchanged. + --- ## 8. Docker conventions @@ -136,6 +153,7 @@ The `Dockerfile` uses a **multi-stage build**: - The container runs as non-root (`appuser`). Do not change this. - DNSSEC keys are mounted into `/app/keys` (mode `700`, owned by `appuser`). - The Python binary is granted `CAP_NET_BIND_SERVICE` via `setcap` to allow binding to port 53 without root. +- If a deployment hardens the container with `cap_drop: [ALL]` or equivalent, it must add back `NET_BIND_SERVICE` explicitly for port 53 binds. - Entrypoint variables are the `DNS_*` environment variables (see [docs/configuration-reference.md](configuration-reference.md)). Docker end-to-end tests in CI use an isolated `172.28.0.0/24` bridge network with a real `nginx:alpine` backend so health checks exercise an actual TCP connection. @@ -147,7 +165,7 @@ Docker end-to-end tests in CI use an isolated `172.28.0.0/24` bridge network wit 1. Update `version` in `setup.py` to a new PEP 440 value higher than the current one. 2. Merge to `master`. 3. CI automatically: - - runs `test python code`, `test docker`, `test version`, + - runs `test python code`, `test integration`, `test version`, - if all three pass: `validate tests` succeeds, - `release version` creates the git tag and GitHub release, - `release docker` pushes the tagged image to Docker Hub. diff --git a/docs/system-patterns.md b/docs/system-patterns.md index 88015a9..6ffafc7 100644 --- a/docs/system-patterns.md +++ b/docs/system-patterns.md @@ -13,7 +13,7 @@ The system is built around two independent, concurrently-running concerns separa │ main.py (_main) │ │ │ │ ┌──────────────────────────────┐ │ -│ │ DnsServerZoneUpdaterThreated │ ← background daemon thread │ +│ │ DnsServerZoneUpdaterThreaded │ ← background daemon thread │ │ │ ┌──────────────────────┐ │ │ │ │ │ DnsServerZoneUpdater │ │ ← health check + zone write │ │ │ └──────────────────────┘ │ │ @@ -45,7 +45,7 @@ Layer 0 – Entry-point indisoluble/a_healthy_dns/main.py Layer 1 – Server orchestration - dns_server_zone_updater_threated.py (threading wrapper) + dns_server_zone_updater_threaded.py (threading wrapper) dns_server_udp_handler.py (UDP query handler) Layer 2 – Domain logic @@ -58,7 +58,7 @@ Layer 3 – Records records/a_record.py (DNS A rdataset factory) records/ns_record.py (DNS NS rdataset factory) records/soa_record.py (DNS SOA rdataset factory) - records/dnssec.py (RRSIG key + timing) + records/dnssec.py (DNSSEC signing inputs + timing) records/zone_origins.py (primary + alias zone set) records/time.py (TTL and signature timing calculations) @@ -119,7 +119,7 @@ _recreate_zone() │ ├─ NS record (apex) │ ├─ SOA record (apex) │ └─ A records (one per healthy subdomain; omitted if all IPs unhealthy) - └─ _sign_zone() — DNSSEC RRSIG (if key is configured) + └─ _sign_zone() — DNSSEC artifacts (if key is configured) ``` The `dns.versioned.Zone` writer is used inside a `with` block; the transaction is committed atomically on exit and rolled back on exception. @@ -173,8 +173,8 @@ Origins are sorted by descending specificity (length) to ensure the most specifi DNSSEC is an additive, opt-in behaviour controlled by the presence of `DnsServerConfig.ext_private_key`: -- `None` → no signing, no RRSIG records, standard A/NS/SOA responses. -- set → `_sign_zone()` is called at the end of each zone-recreation transaction. +- `None` → no signing; the zone contains only the base A/NS/SOA records. +- set → `_sign_zone()` is called at the end of each zone-recreation transaction, and `dnspython.sign_zone()` adds DNSKEY, NSEC, and corresponding RRSIG datasets to the zone. RRSIG key rotation timing is managed by `records/dnssec.iter_rrsig_key()`, a stateful generator that yields a new `ExtendedRRSigKey` each time signing is invoked. The zone updater tracks `resign` time and forces a zone recreation before the current signature expires. @@ -197,6 +197,6 @@ RRSIG key rotation timing is managed by `records/dnssec.iter_rrsig_key()`, a sta ## 10. Tooling conventions - **Pure utility functions** belong in `tools/`. They must have no imports from `indisoluble.a_healthy_dns` (no circular dependencies). -- **Validation functions** (e.g. `is_valid_ip`, `is_valid_subdomain`) return `(bool, Optional[str])` — success flag and an error message. +- **Validation functions** (e.g. `is_valid_ip`, `is_valid_subdomain`) return `(bool, str)` — success flag and an error message (empty string on success). - **Record factories** (e.g. `make_a_record`, `make_ns_record`) are module-level functions, not methods. They take scalar inputs and return `dns.rdataset.Rdataset` (or `None`). - **Iterators as stateful generators** are used for sequences requiring internal state (SOA serial increments, RRSIG key rotation) — see `records/soa_record.iter_soa_record()` and `records/dnssec.iter_rrsig_key()`. diff --git a/docs/table-of-contents.md b/docs/table-of-contents.md index 14e1d56..2349899 100644 --- a/docs/table-of-contents.md +++ b/docs/table-of-contents.md @@ -14,6 +14,7 @@ These documents must be read before proposing or applying changes to this reposi | [`docs/project-brief.md`](project-brief.md) | Goals, non-goals, constraints, and requirements | | [`docs/system-patterns.md`](system-patterns.md) | Architecture patterns and conventions | | [`docs/project-rules.md`](project-rules.md) | Language/tool specifics and QA commands | +| [`docs/RFC-conformance.md`](RFC-conformance.md) | Level 1 authoritative UDP conformance target: scope, minimum RFC set, current coverage per RFC, and broader-than-Level-1 scope limits | --- @@ -31,6 +32,7 @@ These documents must be read before proposing or applying changes to this reposi - [`docs/configuration-reference.md`](configuration-reference.md) — Full CLI and Docker environment variable reference - [`docs/docker.md`](docker.md) — Docker deployment guide: image details, Docker Compose, deployment patterns, container management, security hardening, and orchestration - [`docs/troubleshooting.md`](troubleshooting.md) — Common issues, debugging, log interpretation, and operational procedures +- [`docs/RFC-conformance.md`](RFC-conformance.md) — RFC conformance reference: minimum RFC set, current coverage per RFC, and broader-than-Level-1 scope limits for the Level 1 authoritative UDP subset --- diff --git a/docs/troubleshooting.md b/docs/troubleshooting.md index 1199a20..08f1150 100644 --- a/docs/troubleshooting.md +++ b/docs/troubleshooting.md @@ -17,8 +17,7 @@ Common issues, debugging techniques, and operational procedures for **A Healthy ps aux | grep a-healthy-dns # Check port binding -netstat -uln | grep 53053 # Or your configured port -lsof -i UDP:53053 +lsof -nP -iUDP:53053 # Or your configured port ``` #### Docker @@ -44,7 +43,7 @@ dig @localhost -p 53053 www.example.local nslookup www.example.local 127.0.0.1 -port=53053 # Using host -host www.example.local 127.0.0.1 +host -p 53053 www.example.local 127.0.0.1 # Check SOA record dig @localhost -p 53053 example.local SOA @@ -78,8 +77,7 @@ journalctl -u a-healthy-dns | grep -i "health\|checked ip" docker logs a-healthy-dns # Check if port is already in use -netstat -uln | grep 53053 -lsof -i UDP:53053 +lsof -nP -iUDP:53053 ``` #### Solutions @@ -109,15 +107,18 @@ docker run -d \ **Permission denied (port 53):** ```bash -# Add NET_BIND_SERVICE capability +# Hardened container binding directly to container port 53: add NET_BIND_SERVICE back docker run -d \ --cap-add=NET_BIND_SERVICE \ + -p 53:53/udp \ + -e DNS_PORT="53" \ + ... + +# Or avoid privileged binds inside the container entirely +docker run -d \ -p 53:53053/udp \ -e DNS_PORT="53053" \ ... - -# Or run as root (not recommended) -docker run -d --user root ... ``` ### Issue: DNS Queries Not Responding @@ -130,7 +131,7 @@ docker run -d --user root ... #### Diagnosis ```bash # Verify server is listening -netstat -uln | grep 53053 +lsof -nP -iUDP:53053 # Check if firewall is blocking sudo iptables -L -n | grep 53053 @@ -234,7 +235,7 @@ dig @localhost -p 53053 www.example.local +short 1. **All IPs unhealthy** → NXDOMAIN (by design) 2. **Subdomain not in configuration** → NXDOMAIN -3. **Wrong zone queried** → NXDOMAIN +3. **Wrong zone queried** → REFUSED #### Solutions @@ -291,7 +292,7 @@ sudo tcpdump -i any -n port 53053 -c 100 - Health checks run in separate thread; shouldn't block queries - Verify using debug logs: ```bash - docker logs a-healthy-dns 2>&1 | grep "writer\|reader" + docker logs a-healthy-dns 2>&1 | grep -E "Checking A record|Checked IP|Updating zone" ``` **Docker networking overhead:** @@ -304,7 +305,7 @@ sudo tcpdump -i any -n port 53053 -c 100 #### Symptoms - DNSSEC-aware resolvers reject responses -- `dig +dnssec` shows no RRSIG records +- `dig +dnssec` shows no DNSSEC records (`RRSIG`, `DNSKEY`, `NSEC`) - Key loading errors in logs #### Diagnosis @@ -530,9 +531,10 @@ docker run -it --entrypoint sh indisoluble/a-healthy-dns # Inspect filesystem ls -la /app -cat /app/setup.py +which a-healthy-dns -# Check Python environment +# Check installed package location and Python environment +python3 -c "import indisoluble.a_healthy_dns.main as m; print(m.__file__)" python3 --version pip list ``` @@ -540,15 +542,15 @@ pip list ### Network Debugging ```bash -# Inside container network namespace -docker exec a-healthy-dns netstat -uln -docker exec a-healthy-dns ip addr +# Inspect the container network from the host +docker inspect a-healthy-dns | jq '.[0].NetworkSettings' +docker port a-healthy-dns # Packet capture sudo tcpdump -i any -n port 53053 -w dns-traffic.pcap -# Test health check connectivity from container -docker exec a-healthy-dns nc -zv 192.168.1.100 8080 +# Test health check connectivity from the runtime image using Python +docker exec a-healthy-dns python3 -c "import socket; socket.create_connection(('192.168.1.100', 8080), 2).close(); print('ok')" ``` ### Zone State Inspection diff --git a/indisoluble/a_healthy_dns/dns_server_udp_handler.py b/indisoluble/a_healthy_dns/dns_server_udp_handler.py index 4874c9d..e411f68 100644 --- a/indisoluble/a_healthy_dns/dns_server_udp_handler.py +++ b/indisoluble/a_healthy_dns/dns_server_udp_handler.py @@ -9,18 +9,49 @@ import logging import socketserver +from typing import List + import dns.exception import dns.flags import dns.message import dns.name +import dns.opcode import dns.rcode +import dns.rdataclass import dns.rdatatype import dns.rrset import dns.versioned +import dns.zone from indisoluble.a_healthy_dns.records.zone_origins import ZoneOrigins +def _build_authority_with_apex_soa( + zone: dns.versioned.Zone, txn: dns.zone.Transaction +) -> List[dns.rrset.RRset]: + soa_rdataset = txn.get(dns.name.empty, dns.rdatatype.SOA) + if soa_rdataset is None: + return [] + + soa_rrset = dns.rrset.RRset(zone.origin, soa_rdataset.rdclass, soa_rdataset.rdtype) + soa_rrset.ttl = soa_rdataset.ttl + for rdata in soa_rdataset: + soa_rrset.add(rdata) + + return [soa_rrset] + + +def _build_answer( + query_name: dns.name.Name, rdataset: dns.rdataset.Rdataset +) -> List[dns.rrset.RRset]: + rrset = dns.rrset.RRset(query_name, rdataset.rdclass, rdataset.rdtype) + rrset.ttl = rdataset.ttl + for rdata in rdataset: + rrset.add(rdata) + + return [rrset] + + def _update_response( response: dns.message.Message, query_name: dns.name.Name, @@ -33,33 +64,35 @@ def _update_response( logging.warning( "Received query for domain not in hosted or alias zones: %s", query_name ) - response.set_rcode(dns.rcode.NXDOMAIN) + response.set_rcode(dns.rcode.REFUSED) return with zone.reader() as txn: + rcode = dns.rcode.NOERROR + authority = [] + answer = [] + node = txn.get_node(relative_name) - if not node: + if node: + rdataset = node.get_rdataset(zone.rdclass, query_type) + if rdataset: + answer = _build_answer(query_name, rdataset) + logging.debug("Answered query for %s with %s", query_name, answer) + else: + logging.debug( + "Subdomain %s exists but has no %s records", + query_name, + dns.rdatatype.to_text(query_type), + ) + authority = _build_authority_with_apex_soa(zone, txn) + else: logging.warning("Received query for unknown subdomain: %s", query_name) - response.set_rcode(dns.rcode.NXDOMAIN) - return - - rdataset = node.get_rdataset(zone.rdclass, query_type) - if not rdataset: - logging.debug( - "Subdomain %s exists but has no %s records", - query_name, - dns.rdatatype.to_text(query_type), - ) - response.set_rcode(dns.rcode.NOERROR) - return - - rrset = dns.rrset.RRset(query_name, rdataset.rdclass, rdataset.rdtype) - rrset.ttl = rdataset.ttl - for rdata in rdataset: - rrset.add(rdata) + rcode = dns.rcode.NXDOMAIN + authority = _build_authority_with_apex_soa(zone, txn) - response.answer.append(rrset) - logging.debug("Answered query for %s with %s", query_name, rrset) + response.set_rcode(rcode) + response.authority.extend(authority) + response.answer.extend(answer) class DnsServerUdpHandler(socketserver.BaseRequestHandler): @@ -71,25 +104,55 @@ def handle(self): try: query = dns.message.from_wire(data) + except dns.message.ShortHeader as ex: + # Payload too short to contain a DNS header — no transaction ID to + # recover, drop silently (RFC 1035 §4.1.1). + logging.warning("Failed to parse DNS query: %s", ex) + return except dns.exception.DNSException as ex: + # Header is readable but message is malformed — recover the + # transaction ID and respond with FORMERR (RFC 1035 §4.1.1). logging.warning("Failed to parse DNS query: %s", ex) + + msg_id = int.from_bytes(data[:2], "big") + formerr = dns.message.Message(id=msg_id) + formerr.flags = dns.flags.QR + formerr.set_rcode(dns.rcode.FORMERR) + + sock.sendto(formerr.to_wire(), self.client_address) return response = dns.message.make_response(query) response.flags |= dns.flags.AA # Authoritative Answer - if query.question: - question = query.question[0] - _update_response( - response, - question.name, - question.rdtype, - self.server.zone, - self.server.zone_origins, + if query.opcode() != dns.opcode.QUERY: + logging.warning( + "Received query with unsupported opcode %s, expected QUERY", + dns.opcode.to_text(query.opcode()), + ) + response.set_rcode(dns.rcode.NOTIMP) + elif len(query.question) != 1: + logging.warning( + "Received query with %d questions, expected exactly 1", + len(query.question), ) - else: - logging.warning("Received query without question section") response.set_rcode(dns.rcode.FORMERR) + else: + question = query.question[0] + if question.rdclass != dns.rdataclass.IN: + logging.warning( + "Received query for unsupported class %s, expected IN", + dns.rdataclass.to_text(question.rdclass), + ) + response.set_rcode(dns.rcode.REFUSED) + else: + _update_response( + response, + question.name, + question.rdtype, + self.server.zone, + self.server.zone_origins, + ) # Send the response back to the client sock.sendto(response.to_wire(), self.client_address) diff --git a/indisoluble/a_healthy_dns/dns_server_zone_updater_threated.py b/indisoluble/a_healthy_dns/dns_server_zone_updater_threaded.py similarity index 98% rename from indisoluble/a_healthy_dns/dns_server_zone_updater_threated.py rename to indisoluble/a_healthy_dns/dns_server_zone_updater_threaded.py index a76aa70..7ace840 100644 --- a/indisoluble/a_healthy_dns/dns_server_zone_updater_threated.py +++ b/indisoluble/a_healthy_dns/dns_server_zone_updater_threaded.py @@ -19,7 +19,7 @@ ) -class DnsServerZoneUpdaterThreated: +class DnsServerZoneUpdaterThreaded: """Threaded DNS zone updater that runs health checks in background.""" @property diff --git a/indisoluble/a_healthy_dns/main.py b/indisoluble/a_healthy_dns/main.py index c48b38d..b1cd246 100644 --- a/indisoluble/a_healthy_dns/main.py +++ b/indisoluble/a_healthy_dns/main.py @@ -25,8 +25,8 @@ make_config, ) from indisoluble.a_healthy_dns.dns_server_udp_handler import DnsServerUdpHandler -from indisoluble.a_healthy_dns.dns_server_zone_updater_threated import ( - DnsServerZoneUpdaterThreated, +from indisoluble.a_healthy_dns.dns_server_zone_updater_threaded import ( + DnsServerZoneUpdaterThreaded, ) @@ -218,13 +218,13 @@ def _main(args: Dict[str, Any]): format="%(asctime)s - %(levelname)s - %(module)s.%(funcName)s - %(message)s", ) - # Copose config + # Compose config config = make_config(args) if not config: return # Start zone updater - zone_updater = DnsServerZoneUpdaterThreated( + zone_updater = DnsServerZoneUpdaterThreaded( args[_ARG_MIN_TEST_INTERVAL], args[_ARG_CONNECTION_TIMEOUT], config ) zone_updater.start() diff --git a/indisoluble/a_healthy_dns/records/a_healthy_record.py b/indisoluble/a_healthy_dns/records/a_healthy_record.py index 2c53fdc..f7944a1 100644 --- a/indisoluble/a_healthy_dns/records/a_healthy_record.py +++ b/indisoluble/a_healthy_dns/records/a_healthy_record.py @@ -33,7 +33,7 @@ def __init__(self, subdomain: dns.name.Name, healthy_ips: List[AHealthyIp]): def updated_ips(self, updated_ips: List[AHealthyIp]) -> "AHealthyRecord": """Return new record with updated IP list if changed.""" - if updated_ips == self.healthy_ips: + if frozenset(updated_ips) == self.healthy_ips: return self return AHealthyRecord(subdomain=self.subdomain, healthy_ips=updated_ips) diff --git a/setup.py b/setup.py index 3d33b6f..317331a 100644 --- a/setup.py +++ b/setup.py @@ -2,7 +2,7 @@ setup( name="a_healthy_dns", - version="0.1.28", + version="0.1.29", description="A healthy DNS project", packages=find_packages(), python_requires=">=3.10", diff --git a/tests/indisoluble/a_healthy_dns/records/test_a_healthy_record.py b/tests/indisoluble/a_healthy_dns/records/test_a_healthy_record.py index 316ffb4..58bc74e 100644 --- a/tests/indisoluble/a_healthy_dns/records/test_a_healthy_record.py +++ b/tests/indisoluble/a_healthy_dns/records/test_a_healthy_record.py @@ -8,14 +8,12 @@ def test_init_with_valid_parameters(): subdomain = dns.name.from_text("test.example.com") - healthy_ips = frozenset( - [AHealthyIp("192.168.1.1", 80, True), AHealthyIp("192.168.1.2", 80, True)] - ) + healthy_ips = [AHealthyIp("192.168.1.1", 80, True), AHealthyIp("192.168.1.2", 80, True)] record = AHealthyRecord(subdomain=subdomain, healthy_ips=healthy_ips) assert record.subdomain == subdomain - assert record.healthy_ips == healthy_ips + assert record.healthy_ips == frozenset(healthy_ips) def test_equality_with_same_subdomain(): @@ -23,11 +21,11 @@ def test_equality_with_same_subdomain(): record1 = AHealthyRecord( subdomain=subdomain, - healthy_ips=frozenset([AHealthyIp("192.168.1.1", 80, True)]), + healthy_ips=[AHealthyIp("192.168.1.1", 80, True)], ) record2 = AHealthyRecord( subdomain=subdomain, - healthy_ips=frozenset([AHealthyIp("192.168.1.2", 443, False)]), + healthy_ips=[AHealthyIp("192.168.1.2", 443, False)], ) assert record1 == record2 @@ -42,11 +40,11 @@ def test_equality_with_different_subdomain(): record1 = AHealthyRecord( subdomain=dns.name.from_text("test1.example.com"), - healthy_ips=frozenset([healthy_ip]), + healthy_ips=[healthy_ip], ) record2 = AHealthyRecord( subdomain=dns.name.from_text("test2.example.com"), - healthy_ips=frozenset([healthy_ip]), + healthy_ips=[healthy_ip], ) assert record1 != record2 @@ -56,7 +54,7 @@ def test_equality_with_different_subdomain(): def test_equality_with_non_wrong_type(): record = AHealthyRecord( subdomain=dns.name.from_text("test.example.com"), - healthy_ips=frozenset([AHealthyIp("192.168.1.1", 80, True)]), + healthy_ips=[AHealthyIp("192.168.1.1", 80, True)], ) assert record != "test.example.com" @@ -66,7 +64,7 @@ def test_repr(): healthy_ip1 = AHealthyIp("192.168.1.1", 80, True) healthy_ip2 = AHealthyIp("192.168.1.2", 80, True) record = AHealthyRecord( - subdomain=subdomain, healthy_ips=frozenset([healthy_ip1, healthy_ip2]) + subdomain=subdomain, healthy_ips=[healthy_ip1, healthy_ip2] ) as_text = f"{record}" @@ -78,33 +76,27 @@ def test_repr(): def test_updated_ips_with_new_ips(): subdomain = dns.name.from_text("test.example.com") - original_ips = frozenset( - [AHealthyIp("192.168.1.1", 80, True), AHealthyIp("192.168.1.2", 80, True)] - ) + original_ips = [AHealthyIp("192.168.1.1", 80, True), AHealthyIp("192.168.1.2", 80, True)] record = AHealthyRecord(subdomain=subdomain, healthy_ips=original_ips) - new_ips = frozenset( - [AHealthyIp("192.168.1.3", 80, True), AHealthyIp("192.168.1.4", 443, True)] - ) + new_ips = [AHealthyIp("192.168.1.3", 80, True), AHealthyIp("192.168.1.4", 443, True)] updated_record = record.updated_ips(new_ips) assert updated_record is not record assert updated_record == record assert updated_record.subdomain == record.subdomain assert updated_record.healthy_ips != record.healthy_ips - assert updated_record.healthy_ips == new_ips + assert updated_record.healthy_ips == frozenset(new_ips) def test_updated_ips_with_same_ips(): subdomain = dns.name.from_text("test.example.com") - healthy_ips = frozenset( - [AHealthyIp("192.168.1.1", 80, True), AHealthyIp("192.168.1.2", 80, True)] - ) + healthy_ips = [AHealthyIp("192.168.1.1", 80, True), AHealthyIp("192.168.1.2", 80, True)] record = AHealthyRecord(subdomain=subdomain, healthy_ips=healthy_ips) updated_record = record.updated_ips(healthy_ips) assert updated_record is record - assert updated_record.healthy_ips == healthy_ips + assert updated_record.healthy_ips == frozenset(healthy_ips) diff --git a/tests/indisoluble/a_healthy_dns/test_dns_server_udp_handler.py b/tests/indisoluble/a_healthy_dns/test_dns_server_udp_handler.py index 43ac6e3..d759600 100644 --- a/tests/indisoluble/a_healthy_dns/test_dns_server_udp_handler.py +++ b/tests/indisoluble/a_healthy_dns/test_dns_server_udp_handler.py @@ -4,6 +4,7 @@ import dns.flags import dns.message import dns.name +import dns.opcode import dns.rcode import dns.rdataclass import dns.rdatatype @@ -19,6 +20,25 @@ ) from indisoluble.a_healthy_dns.records.zone_origins import ZoneOrigins +# Captured before any test patches dns.message.from_wire so we can still parse +# response wire bytes inside tests that mock from_wire. +_real_from_wire = dns.message.from_wire + + +def _make_multi_question_wire(*question_names): + queries = [ + dns.message.make_query(question_name, dns.rdatatype.A) + for question_name in question_names + ] + + wire = bytearray(queries[0].to_wire()) + wire[4:6] = len(queries).to_bytes(2, byteorder="big") + + for query in queries[1:]: + wire.extend(query.to_wire()[12:]) + + return bytes(wire) + @pytest.fixture def mock_reader(): @@ -57,6 +77,24 @@ def mock_rdataset(): return mock_rdataset +@pytest.fixture +def mock_soa_rdata(): + mock_soa_rdata = MagicMock() + mock_soa_rdata.rdclass = dns.rdataclass.IN + mock_soa_rdata.rdtype = dns.rdatatype.SOA + return mock_soa_rdata + + +@pytest.fixture +def mock_soa_rdataset(mock_soa_rdata): + mock_soa_rdataset = MagicMock() + mock_soa_rdataset.rdclass = dns.rdataclass.IN + mock_soa_rdataset.rdtype = dns.rdatatype.SOA + mock_soa_rdataset.ttl = 300 + mock_soa_rdataset.__iter__ = MagicMock(return_value=iter([mock_soa_rdata])) + return mock_soa_rdataset + + @pytest.fixture def mock_server(mock_zone, mock_zone_origins): server = MagicMock() @@ -66,23 +104,28 @@ def mock_server(mock_zone, mock_zone_origins): @pytest.fixture -def dns_response(): +def mock_dns_response(): return dns.message.make_response(dns.message.make_query("dummy", dns.rdatatype.A)) @pytest.fixture -def dns_request(): +def mock_dns_request(): query = dns.message.make_query("test.example.com.", dns.rdatatype.A) return (query.to_wire(), MagicMock()) @pytest.fixture -def dns_client_address(): +def mock_dns_client_address(): return ("127.0.0.1", 12345) def test_update_response_with_relative_name_found( - mock_zone, mock_reader, mock_rdata, mock_rdataset, dns_response, mock_zone_origins + mock_zone, + mock_reader, + mock_rdata, + mock_rdataset, + mock_dns_response, + mock_zone_origins, ): # Setup query_name = dns.name.from_text("test", origin=None) @@ -99,23 +142,36 @@ def test_update_response_with_relative_name_found( mock_zone.reader.return_value.__enter__.return_value = mock_reader # Call function - _update_response(dns_response, query_name, query_type, mock_zone, mock_zone_origins) + _update_response( + mock_dns_response, query_name, query_type, mock_zone, mock_zone_origins + ) # Assertions mock_zone.reader.assert_called_once() mock_reader.get_node.assert_called_once_with(query_name) mock_node.get_rdataset.assert_called_once_with(mock_zone.rdclass, query_type) - assert dns_response.rcode() == dns.rcode.NOERROR - assert len(dns_response.answer) == 1 - assert dns_response.answer[0].name == query_name - assert dns_response.answer[0].rdclass == mock_rdataset.rdclass - assert dns_response.answer[0].rdtype == query_type - assert dns_response.answer[0].ttl == mock_rdataset.ttl - assert list(dns_response.answer[0]) == [mock_rdata] + mock_reader.get.assert_not_called() # SOA lookup should not be needed when node is found + + assert mock_dns_response.rcode() == dns.rcode.NOERROR + + assert len(mock_dns_response.additional) == 0 + assert len(mock_dns_response.authority) == 0 + + assert len(mock_dns_response.answer) == 1 + assert mock_dns_response.answer[0].name == query_name + assert mock_dns_response.answer[0].rdclass == mock_rdataset.rdclass + assert mock_dns_response.answer[0].rdtype == query_type + assert mock_dns_response.answer[0].ttl == mock_rdataset.ttl + assert list(mock_dns_response.answer[0]) == [mock_rdata] def test_update_response_with_absolute_name_found( - mock_zone, mock_reader, mock_rdata, mock_rdataset, dns_response, mock_zone_origins + mock_zone, + mock_reader, + mock_rdata, + mock_rdataset, + mock_dns_response, + mock_zone_origins, ): # Setup query_name = dns.name.from_text("test", origin=mock_zone_origins.primary) @@ -132,7 +188,9 @@ def test_update_response_with_absolute_name_found( mock_zone.reader.return_value.__enter__.return_value = mock_reader # Call function - _update_response(dns_response, query_name, query_type, mock_zone, mock_zone_origins) + _update_response( + mock_dns_response, query_name, query_type, mock_zone, mock_zone_origins + ) # Assertions mock_zone.reader.assert_called_once() @@ -140,33 +198,46 @@ def test_update_response_with_absolute_name_found( query_name.relativize(mock_zone_origins.primary) ) mock_node.get_rdataset.assert_called_once_with(mock_zone.rdclass, query_type) - assert dns_response.rcode() == dns.rcode.NOERROR - assert len(dns_response.answer) == 1 - assert dns_response.answer[0].name == query_name - assert dns_response.answer[0].rdclass == mock_rdataset.rdclass - assert dns_response.answer[0].rdtype == query_type - assert dns_response.answer[0].ttl == mock_rdataset.ttl - assert list(dns_response.answer[0]) == [mock_rdata] + mock_reader.get.assert_not_called() # SOA lookup should not be needed when node is found + + assert mock_dns_response.rcode() == dns.rcode.NOERROR + + assert len(mock_dns_response.additional) == 0 + assert len(mock_dns_response.authority) == 0 + + assert len(mock_dns_response.answer) == 1 + assert mock_dns_response.answer[0].name == query_name + assert mock_dns_response.answer[0].rdclass == mock_rdataset.rdclass + assert mock_dns_response.answer[0].rdtype == query_type + assert mock_dns_response.answer[0].ttl == mock_rdataset.ttl + assert list(mock_dns_response.answer[0]) == [mock_rdata] def test_update_response_with_absolute_name_outside_zone_origins( - mock_zone, dns_response, mock_zone_origins + mock_zone, mock_dns_response, mock_zone_origins ): # Setup query_name = dns.name.from_text("test", origin=dns.name.from_text("other.com")) query_type = dns.rdatatype.A # Call function - _update_response(dns_response, query_name, query_type, mock_zone, mock_zone_origins) + _update_response( + mock_dns_response, query_name, query_type, mock_zone, mock_zone_origins + ) # Assertions mock_zone.reader.assert_not_called() - assert dns_response.rcode() == dns.rcode.NXDOMAIN - assert len(dns_response.answer) == 0 + + assert mock_dns_response.rcode() == dns.rcode.REFUSED + + assert len(mock_dns_response.additional) == 0 + assert len(mock_dns_response.authority) == 0 + + assert len(mock_dns_response.answer) == 0 def test_update_response_domain_not_found( - mock_zone, mock_reader, dns_response, mock_zone_origins + mock_zone, mock_reader, mock_dns_response, mock_zone_origins, mock_soa_rdataset ): # Setup query_name = dns.name.from_text("nonexistent", origin=mock_zone_origins.primary) @@ -174,23 +245,35 @@ def test_update_response_domain_not_found( # Mock zone.reader.get_node to return None mock_reader.get_node.return_value = None + mock_reader.get.return_value = mock_soa_rdataset mock_zone.reader.return_value.__enter__.return_value = mock_reader # Call function - _update_response(dns_response, query_name, query_type, mock_zone, mock_zone_origins) + _update_response( + mock_dns_response, query_name, query_type, mock_zone, mock_zone_origins + ) # Assertions mock_zone.reader.assert_called_once() mock_reader.get_node.assert_called_once_with( query_name.relativize(mock_zone_origins.primary) ) - assert dns_response.rcode() == dns.rcode.NXDOMAIN - assert len(dns_response.answer) == 0 + mock_reader.get.assert_called_once_with(dns.name.empty, dns.rdatatype.SOA) + + assert mock_dns_response.rcode() == dns.rcode.NXDOMAIN + + assert len(mock_dns_response.additional) == 0 + assert len(mock_dns_response.authority) == 1 + assert mock_dns_response.authority[0].rdtype == dns.rdatatype.SOA + assert mock_dns_response.authority[0].name == mock_zone_origins.primary + assert mock_dns_response.authority[0].ttl == mock_soa_rdataset.ttl + + assert len(mock_dns_response.answer) == 0 def test_update_response_record_type_not_found( - mock_zone, mock_reader, dns_response, mock_zone_origins + mock_zone, mock_reader, mock_dns_response, mock_zone_origins, mock_soa_rdataset ): # Setup query_name = dns.name.from_text("test", origin=mock_zone_origins.primary) @@ -201,11 +284,14 @@ def test_update_response_record_type_not_found( mock_node.get_rdataset.return_value = None mock_reader.get_node.return_value = mock_node + mock_reader.get.return_value = mock_soa_rdataset mock_zone.reader.return_value.__enter__.return_value = mock_reader # Call function - _update_response(dns_response, query_name, query_type, mock_zone, mock_zone_origins) + _update_response( + mock_dns_response, query_name, query_type, mock_zone, mock_zone_origins + ) # Assertions mock_zone.reader.assert_called_once() @@ -213,19 +299,28 @@ def test_update_response_record_type_not_found( query_name.relativize(mock_zone_origins.primary) ) mock_node.get_rdataset.assert_called_once_with(mock_zone.rdclass, query_type) - assert dns_response.rcode() == dns.rcode.NOERROR - assert len(dns_response.answer) == 0 + mock_reader.get.assert_called_once_with(dns.name.empty, dns.rdatatype.SOA) + + assert mock_dns_response.rcode() == dns.rcode.NOERROR + + assert len(mock_dns_response.additional) == 0 + assert len(mock_dns_response.authority) == 1 + assert mock_dns_response.authority[0].rdtype == dns.rdatatype.SOA + assert mock_dns_response.authority[0].name == mock_zone_origins.primary + assert mock_dns_response.authority[0].ttl == mock_soa_rdataset.ttl + + assert len(mock_dns_response.answer) == 0 @patch("indisoluble.a_healthy_dns.dns_server_udp_handler._update_response") def test_handle_valid_query( - mock_update_response, dns_request, dns_client_address, mock_server + mock_update_response, mock_dns_request, mock_dns_client_address, mock_server ): # No need to call handle() as it's called automatically by the constructor - _ = DnsServerUdpHandler(dns_request, dns_client_address, mock_server) + _ = DnsServerUdpHandler(mock_dns_request, mock_dns_client_address, mock_server) # Get the original query for comparison - query_data = dns_request[0] + query_data = mock_dns_request[0] query = dns.message.from_wire(query_data) question = query.question[0] @@ -237,60 +332,203 @@ def test_handle_valid_query( assert mock_update_response.call_args[0][4] == mock_server.zone_origins # Check response was sent - mock_sock = dns_request[1] + mock_sock = mock_dns_request[1] mock_sock.sendto.assert_called_once() - # Verify AA flag is set in the response + # Verify response header fields (RFC 1035 §4.1.1) sent_data = mock_sock.sendto.call_args[0][0] response = dns.message.from_wire(sent_data) - assert response.flags & dns.flags.AA + assert response.id == query.id + assert bool(response.flags & dns.flags.AA) + assert bool(response.flags & dns.flags.QR) + assert not bool(response.flags & dns.flags.RA) + assert not bool(response.flags & dns.flags.TC) @patch("dns.message.from_wire") +@patch("indisoluble.a_healthy_dns.dns_server_udp_handler._update_response") def test_handle_exception_parsing_query( - mock_from_wire, dns_request, dns_client_address, mock_server + mock_update_response, + mock_from_wire, + mock_dns_request, + mock_dns_client_address, + mock_server, ): - # Setup to simulate an exception when parsing DNS query + # Simulate a malformed message that passes the 12-byte ShortHeader check + # but fails full parsing (DNSException). mock_from_wire.side_effect = dns.exception.DNSException("Test exception") - # No need to call handle() as it's called automatically by the constructor - with patch("logging.warning") as mock_logging: - _ = DnsServerUdpHandler(dns_request, dns_client_address, mock_server) + _ = DnsServerUdpHandler(mock_dns_request, mock_dns_client_address, mock_server) - mock_logging.assert_called_once() - assert "Failed to parse DNS query" in mock_logging.call_args[0][0] - - # Assertions - query_data = dns_request[0] + query_data = mock_dns_request[0] mock_from_wire.assert_called_once_with(query_data) - # Check no response was sent - mock_sock = dns_request[1] + # The handler must respond with FORMERR (RFC 1035 §4.1.1), + # preserving the original transaction ID. + mock_sock = mock_dns_request[1] + mock_sock.sendto.assert_called_once() + + sent_wire = mock_sock.sendto.call_args[0][0] + response = _real_from_wire(sent_wire) + assert response.id == int.from_bytes(query_data[:2], "big") + assert response.rcode() == dns.rcode.FORMERR + assert not bool(response.flags & dns.flags.AA) + assert bool(response.flags & dns.flags.QR) + assert not bool(response.flags & dns.flags.RA) + assert not bool(response.flags & dns.flags.TC) + + assert mock_update_response.call_not_called() + + +@pytest.mark.parametrize("wire_data", [b"", b"\x00\x01\x00\x00"]) +@patch("indisoluble.a_healthy_dns.dns_server_udp_handler._update_response") +def test_handle_malformed_wire_input_drops_silently( + mock_update_response, wire_data, mock_dns_client_address, mock_server +): + mock_sock = MagicMock() + request = (wire_data, mock_sock) + + _ = DnsServerUdpHandler(request, mock_dns_client_address, mock_server) + + # Payload too short to recover a DNS header: drop silently, no response. mock_sock.sendto.assert_not_called() + assert mock_update_response.call_not_called() + + +# Wire bytes that fail dns.message.from_wire() but are ≥ 12 bytes so the DNS +# header (and transaction ID) can still be recovered. The handler must respond +# with FORMERR, preserving the original transaction ID (RFC 1035 §4.1.1). +@pytest.mark.parametrize( + "wire_data", + [ + # Valid 12-byte header claiming QDCOUNT=1 but question section missing + b"\x12\x34\x01\x00\x00\x01\x00\x00\x00\x00\x00\x00", + # Self-referential compression pointer (offset 12 points to itself) + b"\x12\x34\x01\x00\x00\x01\x00\x00\x00\x00\x00\x00\xc0\x0c\x00\x01\x00\x01", + # Garbage bytes that do not form a valid DNS message + bytes(range(32)), + ], +) +@patch("indisoluble.a_healthy_dns.dns_server_udp_handler._update_response") +def test_handle_malformed_wire_with_recoverable_header_returns_formerr( + mock_update_response, wire_data, mock_dns_client_address, mock_server +): + mock_sock = MagicMock() + request = (wire_data, mock_sock) + _ = DnsServerUdpHandler(request, mock_dns_client_address, mock_server) + mock_sock.sendto.assert_called_once() + sent_wire = mock_sock.sendto.call_args[0][0] + + # Minimal 12-byte FORMERR response: transaction ID preserved, QR=1. + assert len(sent_wire) == 12 + assert sent_wire[0] == wire_data[0] + assert sent_wire[1] == wire_data[1] + assert sent_wire[2] & 0x80 # QR=1 + assert (sent_wire[3] & 0x0F) == dns.rcode.FORMERR + + assert mock_update_response.call_not_called() + + +@pytest.mark.parametrize( + "opcode", + [ + dns.opcode.STATUS, + dns.opcode.NOTIFY, + # dns.opcode.UPDATE is excluded: dnspython rejects the wire format as + # malformed when opcode=UPDATE appears in a standard-query-shaped message, + # so the handler never reaches the opcode check for that case. + ], +) @patch("indisoluble.a_healthy_dns.dns_server_udp_handler._update_response") -def test_handle_query_without_question( - mock_update_response, dns_client_address, mock_server +def test_handle_query_with_unsupported_opcode_returns_notimp( + mock_update_response, opcode, mock_dns_client_address, mock_server ): - # Create a request with an empty question section - query = dns.message.Message() + query = dns.message.make_query("test.example.com.", dns.rdatatype.A) + query.set_opcode(opcode) mock_sock = MagicMock() request = (query.to_wire(), mock_sock) - # No need to call handle() as it's called automatically by the constructor - with patch("logging.warning") as mock_logging: - _ = DnsServerUdpHandler(request, dns_client_address, mock_server) + _ = DnsServerUdpHandler(request, mock_dns_client_address, mock_server) - mock_logging.assert_called_once() - assert "Received query without question section" in mock_logging.call_args[0][0] - - # Assertions mock_update_response.assert_not_called() + mock_sock.sendto.assert_called_once() - # Check response was sent with FORMERR + sent_data = mock_sock.sendto.call_args[0][0] + response = dns.message.from_wire(sent_data) + assert response.rcode() == dns.rcode.NOTIMP + assert len(response.answer) == 0 + assert len(response.authority) == 0 + assert len(response.additional) == 0 + + # Header field assertions (RFC 1035 §4.1.1) + assert response.id == query.id + assert bool(response.flags & dns.flags.AA) + assert bool(response.flags & dns.flags.QR) + assert not bool(response.flags & dns.flags.RA) + assert not bool(response.flags & dns.flags.TC) + + +@pytest.mark.parametrize( + "query_data", + [ + dns.message.Message().to_wire(), + _make_multi_question_wire("example.com.", "test.com."), + ], +) +@patch("indisoluble.a_healthy_dns.dns_server_udp_handler._update_response") +def test_handle_query_with_invalid_question_count_returns_formerr( + mock_update_response, query_data, mock_dns_client_address, mock_server +): + mock_sock = MagicMock() + request = (query_data, mock_sock) + + _ = DnsServerUdpHandler(request, mock_dns_client_address, mock_server) + + mock_update_response.assert_not_called() mock_sock.sendto.assert_called_once() sent_data = mock_sock.sendto.call_args[0][0] response = dns.message.from_wire(sent_data) assert response.rcode() == dns.rcode.FORMERR + assert len(response.answer) == 0 + assert len(response.authority) == 0 + assert len(response.additional) == 0 + + # Header field assertions (RFC 1035 §4.1.1) + assert response.id == dns.message.from_wire(query_data).id + assert bool(response.flags & dns.flags.AA) + assert bool(response.flags & dns.flags.QR) + assert not bool(response.flags & dns.flags.RA) + assert not bool(response.flags & dns.flags.TC) + + +@patch("indisoluble.a_healthy_dns.dns_server_udp_handler._update_response") +def test_handle_query_with_non_in_class_returns_refused( + mock_update_response, mock_dns_client_address, mock_server +): + query = dns.message.make_query( + "test.example.com.", dns.rdatatype.A, rdclass=dns.rdataclass.CH + ) + mock_sock = MagicMock() + request = (query.to_wire(), mock_sock) + + _ = DnsServerUdpHandler(request, mock_dns_client_address, mock_server) + + mock_update_response.assert_not_called() + mock_sock.sendto.assert_called_once() + + sent_data = mock_sock.sendto.call_args[0][0] + response = dns.message.from_wire(sent_data) + assert response.rcode() == dns.rcode.REFUSED + assert len(response.answer) == 0 + assert len(response.authority) == 0 + assert len(response.additional) == 0 + + # Header field assertions (RFC 1035 §4.1.1) + assert response.id == query.id + assert bool(response.flags & dns.flags.AA) + assert bool(response.flags & dns.flags.QR) + assert not bool(response.flags & dns.flags.RA) + assert not bool(response.flags & dns.flags.TC) diff --git a/tests/indisoluble/a_healthy_dns/test_dns_server_udp_integration.py b/tests/indisoluble/a_healthy_dns/test_dns_server_udp_integration.py new file mode 100644 index 0000000..7a4abe3 --- /dev/null +++ b/tests/indisoluble/a_healthy_dns/test_dns_server_udp_integration.py @@ -0,0 +1,360 @@ +#!/usr/bin/env python3 + +"""Component integration tests for the authoritative UDP serving layer. + +See docs/RFC-conformance.md §4 for the full test coverage mapping. +""" + +import socket +import socketserver +import threading +import time + +import dns.flags +import dns.message +import dns.name +import dns.opcode +import dns.rcode +import dns.rdataclass +import dns.rdatatype +import pytest + +from indisoluble.a_healthy_dns.dns_server_config_factory import DnsServerConfig +from indisoluble.a_healthy_dns.dns_server_udp_handler import DnsServerUdpHandler +from indisoluble.a_healthy_dns.dns_server_zone_updater import DnsServerZoneUpdater +from indisoluble.a_healthy_dns.records.a_healthy_ip import AHealthyIp +from indisoluble.a_healthy_dns.records.a_healthy_record import AHealthyRecord +from indisoluble.a_healthy_dns.records.zone_origins import ZoneOrigins + + +# --------------------------------------------------------------------------- +# Zone constants used throughout all test classes +# --------------------------------------------------------------------------- + +_ZONE = "example.integration.test" +_NS = "ns1.integration.test." +_SUBDOMAIN = "www" +_SUBDOMAIN_IP = "192.0.2.1" # RFC 5737 TEST-NET-1 +_ABSENT_SUBDOMAIN = "missing" + +_ZONE_FQDN = f"{_ZONE}." +_SUBDOMAIN_FQDN = f"{_SUBDOMAIN}.{_ZONE}." +_ABSENT_FQDN = f"{_ABSENT_SUBDOMAIN}.{_ZONE}." +_OUT_OF_ZONE_FQDN = "www.unrelated.test." + +# A wire payload that is ≥ 12 bytes (DNS header is readable) but not a valid +# DNS message. The handler must recover the transaction ID and return FORMERR +# (RFC 1035 §4.1.1). Exactly 12 bytes: valid DNS header with QDCOUNT=1 but +# the question section is absent. +_MALFORMED_HEADER_ONLY_WIRE = b"\x12\x34\x01\x00\x00\x01\x00\x00\x00\x00\x00\x00" +_MALFORMED_HEADER_ONLY_ID = 0x1234 + +# Pause after server.serve_forever() starts before sending queries. +# 50 ms is well above any observable startup latency on CI runners. +_SERVER_READY_WAIT = 0.05 + + +# --------------------------------------------------------------------------- +# Shared helper +# --------------------------------------------------------------------------- + + +def _udp_query( + host: str, + port: int, + query: dns.message.Message, + timeout: float = 2.0, +) -> dns.message.Message: + """Send *query* over UDP and return the parsed response.""" + sock = socket.socket(socket.AF_INET, socket.SOCK_DGRAM) + sock.settimeout(timeout) + try: + sock.sendto(query.to_wire(), (host, port)) + data, _ = sock.recvfrom(4096) + return dns.message.from_wire(data) + finally: + sock.close() + + +def _udp_raw_query( + host: str, + port: int, + wire: bytes, + timeout: float = 2.0, +) -> dns.message.Message: + """Send raw *wire* bytes over UDP and return the parsed response.""" + sock = socket.socket(socket.AF_INET, socket.SOCK_DGRAM) + sock.settimeout(timeout) + try: + sock.sendto(wire, (host, port)) + data, _ = sock.recvfrom(4096) + return dns.message.from_wire(data) + finally: + sock.close() + + +def _make_two_question_wire(name1: str, name2: str) -> bytes: + """Build a valid DNS wire message with QDCOUNT=2. + + Constructs the first query normally, then patches QDCOUNT to 2 and + appends the question section from a second query. dnspython does not + natively produce multi-question messages; this reproduces the exact + wire format used by the handler unit tests. + """ + q1 = dns.message.make_query(name1, dns.rdatatype.A) + q2 = dns.message.make_query(name2, dns.rdatatype.A) + wire = bytearray(q1.to_wire()) + wire[4:6] = (2).to_bytes(2, byteorder="big") + wire.extend(q2.to_wire()[12:]) + return bytes(wire) + + +def _assert_response_flags(resp: dns.message.Message, *, aa: bool = True): + assert bool(resp.flags & dns.flags.AA) == aa + assert bool(resp.flags & dns.flags.QR) + assert not bool(resp.flags & dns.flags.RA) + assert not bool(resp.flags & dns.flags.TC) + + +def _assert_section_counts( + resp: dns.message.Message, + *, + additional: int = 0, + authority: int = 0, + answer: int = 0, +): + assert len(resp.additional) == additional + assert len(resp.authority) == authority + assert len(resp.answer) == answer + + +# --------------------------------------------------------------------------- +# Module-scoped server fixture +# --------------------------------------------------------------------------- + + +@pytest.fixture(scope="module") +def live_server(): + """Start a real UDP server on 127.0.0.1:. + + Zone layout + ----------- + Zone: example.integration.test. + NS @ → ns1.integration.test. + SOA @ (auto-generated via DnsServerZoneUpdater) + A www → 192.0.2.1 (RFC 5737 TEST-NET-1) + + IPs are pre-marked healthy so check_ips=False populates the zone + immediately without making real TCP connections. + """ + zone_origins = ZoneOrigins(_ZONE, []) + + a_record = AHealthyRecord( + subdomain=dns.name.from_text(_SUBDOMAIN, origin=zone_origins.primary), + healthy_ips=[AHealthyIp(ip=_SUBDOMAIN_IP, health_port=8080, is_healthy=True)], + ) + + config = DnsServerConfig( + zone_origins=zone_origins, + name_servers=frozenset([_NS]), + a_records=frozenset([a_record]), + ext_private_key=None, + ) + + # min_interval drives TTL calculations for NS/SOA/A records; 30 s is a + # reasonable value for tests — it has no effect on timing because + # update() is called exactly once with check_ips=False. + updater = DnsServerZoneUpdater(min_interval=30, connection_timeout=2, config=config) + # Populate zone without health checks — IPs already marked healthy above + updater.update(check_ips=False) + + server = socketserver.UDPServer(("127.0.0.1", 0), DnsServerUdpHandler) + server.zone = updater.zone + server.zone_origins = zone_origins + + thread = threading.Thread(target=server.serve_forever) + thread.daemon = True + thread.start() + + # Pause long enough for serve_forever() to enter its select loop. + time.sleep(_SERVER_READY_WAIT) + + host, port = server.server_address + yield host, port + + server.shutdown() + thread.join(timeout=5) + + +# --------------------------------------------------------------------------- +# Positive responses — RFC 1034 §6.2 +# Response header fields — RFC 1035 §4.1.1 +# --------------------------------------------------------------------------- + + +class TestPositiveResponses: + """NOERROR responses for in-zone owner names and record types.""" + + def test_a_query_returns_noerror_expected_ip_and_empty_authority(self, live_server): + host, port = live_server + query = dns.message.make_query(_SUBDOMAIN_FQDN, dns.rdatatype.A) + resp = _udp_query(host, port, query) + + assert resp.rcode() == dns.rcode.NOERROR + assert resp.id == query.id + + _assert_section_counts(resp, answer=1) + assert resp.answer[0].rdtype == dns.rdatatype.A + assert any(str(rdata) == _SUBDOMAIN_IP for rdata in resp.answer[0]) + + _assert_response_flags(resp) + + def test_soa_query_answer_contains_soa_at_apex(self, live_server): + host, port = live_server + query = dns.message.make_query(_ZONE_FQDN, dns.rdatatype.SOA) + resp = _udp_query(host, port, query) + + assert resp.rcode() == dns.rcode.NOERROR + assert resp.id == query.id + + _assert_section_counts(resp, answer=1) + assert resp.answer[0].rdtype == dns.rdatatype.SOA + assert resp.answer[0].name == dns.name.from_text(_ZONE_FQDN) + + _assert_response_flags(resp) + + def test_ns_query_answer_contains_expected_ns(self, live_server): + host, port = live_server + query = dns.message.make_query(_ZONE_FQDN, dns.rdatatype.NS) + resp = _udp_query(host, port, query) + + assert resp.rcode() == dns.rcode.NOERROR + assert resp.id == query.id + + _assert_section_counts(resp, answer=1) + assert resp.answer[0].rdtype == dns.rdatatype.NS + ns_targets = {str(rdata.target) for rdata in resp.answer[0]} + assert _NS in ns_targets + + _assert_response_flags(resp) + + +# --------------------------------------------------------------------------- +# Negative responses — RFC 2308 §2.1 (NODATA) and §3 (NXDOMAIN) +# Response header fields — RFC 1035 §4.1.1 +# --------------------------------------------------------------------------- + + +class TestNegativeResponses: + """NXDOMAIN and NODATA responses must carry the apex SOA in authority.""" + + @pytest.mark.parametrize( + "qname,rdtype,expected_rcode", + [ + (_ABSENT_FQDN, dns.rdatatype.A, dns.rcode.NXDOMAIN), + (_SUBDOMAIN_FQDN, dns.rdatatype.AAAA, dns.rcode.NOERROR), + ], + ids=["nxdomain-absent-owner", "nodata-absent-type"], + ) + def test_negative_response_has_soa_authority( + self, live_server, qname, rdtype, expected_rcode + ): + host, port = live_server + query = dns.message.make_query(qname, rdtype) + resp = _udp_query(host, port, query) + + assert resp.rcode() == expected_rcode + assert resp.id == query.id + + _assert_section_counts(resp, authority=1) + assert resp.authority[0].rdtype == dns.rdatatype.SOA + assert resp.authority[0].name == dns.name.from_text(_ZONE_FQDN) + + _assert_response_flags(resp) + + +# --------------------------------------------------------------------------- +# Rejected queries — RFC 1034 §6.2, RFC 1035 §4.1.1, RFC 2181 §5.1 +# Response header fields — RFC 1035 §4.1.1 +# --------------------------------------------------------------------------- + + +class TestRejectedQueries: + """Queries that must be rejected per the documented Level 1 policy.""" + + @pytest.mark.parametrize( + "query,expected_rcode", + [ + ( + dns.message.make_query(_OUT_OF_ZONE_FQDN, dns.rdatatype.A), + dns.rcode.REFUSED, + ), + ( + dns.message.make_query( + _SUBDOMAIN_FQDN, dns.rdatatype.A, rdclass=dns.rdataclass.CH + ), + dns.rcode.REFUSED, + ), + # dns.message.Message() with no questions produces QDCOUNT=0; handler must return FORMERR (RFC 2181 §5.1) + (dns.message.Message(), dns.rcode.FORMERR), + ], + ids=["out-of-zone", "non-in-class", "zero-question"], + ) + def test_rejected_query_returns_expected_rcode( + self, live_server, query, expected_rcode + ): + host, port = live_server + resp = _udp_query(host, port, query) + + assert resp.rcode() == expected_rcode + assert resp.id == query.id + + _assert_section_counts(resp) + _assert_response_flags(resp) + + def test_multi_question_query_returns_formerr(self, live_server): + host, port = live_server + # Build a structurally well-formed but RFC-noncompliant DNS wire message + # with QDCOUNT=2. RFC 2181 §5.1 requires exactly one question per query. + # dnspython preserves both questions when parsing; the handler must return + # FORMERR because len(query.question) != 1. + wire = _make_two_question_wire(_SUBDOMAIN_FQDN, _ABSENT_FQDN) + resp = _udp_raw_query(host, port, wire) + + assert resp.rcode() == dns.rcode.FORMERR + assert resp.id == dns.message.from_wire(wire).id + + _assert_section_counts(resp) + _assert_response_flags(resp) + + def test_status_opcode_returns_notimp(self, live_server): + host, port = live_server + query = dns.message.make_query(_SUBDOMAIN_FQDN, dns.rdatatype.A) + query.set_opcode(dns.opcode.STATUS) + resp = _udp_query(host, port, query) + + assert resp.rcode() == dns.rcode.NOTIMP + assert resp.id == query.id + + _assert_section_counts(resp) + _assert_response_flags(resp) + + +# --------------------------------------------------------------------------- +# Malformed wire input — RFC 1035 §4.1.1 +# Response header fields — RFC 1035 §4.1.1 +# --------------------------------------------------------------------------- + + +class TestMalformedWireInput: + """Malformed UDP payloads with a recoverable DNS header return FORMERR.""" + + def test_malformed_wire_with_header_returns_formerr(self, live_server): + host, port = live_server + resp = _udp_raw_query(host, port, _MALFORMED_HEADER_ONLY_WIRE) + + assert resp.rcode() == dns.rcode.FORMERR + assert resp.id == _MALFORMED_HEADER_ONLY_ID + + _assert_section_counts(resp) + _assert_response_flags(resp, aa=False) diff --git a/tests/indisoluble/a_healthy_dns/test_dns_server_zone_updater_threated.py b/tests/indisoluble/a_healthy_dns/test_dns_server_zone_updater_threaded.py similarity index 86% rename from tests/indisoluble/a_healthy_dns/test_dns_server_zone_updater_threated.py rename to tests/indisoluble/a_healthy_dns/test_dns_server_zone_updater_threaded.py index 059442e..a40eefa 100644 --- a/tests/indisoluble/a_healthy_dns/test_dns_server_zone_updater_threated.py +++ b/tests/indisoluble/a_healthy_dns/test_dns_server_zone_updater_threaded.py @@ -14,8 +14,8 @@ DELTA_PER_RECORD_MANAGEMENT, DnsServerZoneUpdater, ) -from indisoluble.a_healthy_dns.dns_server_zone_updater_threated import ( - DnsServerZoneUpdaterThreated, +from indisoluble.a_healthy_dns.dns_server_zone_updater_threaded import ( + DnsServerZoneUpdaterThreaded, ) from indisoluble.a_healthy_dns.records.a_healthy_ip import AHealthyIp from indisoluble.a_healthy_dns.records.a_healthy_record import AHealthyRecord @@ -73,7 +73,7 @@ def mock_thread(): @patch("threading.Event") @patch( - "indisoluble.a_healthy_dns.dns_server_zone_updater_threated.DnsServerZoneUpdater" + "indisoluble.a_healthy_dns.dns_server_zone_updater_threaded.DnsServerZoneUpdater" ) def test_init_success( mock_updater_class, mock_event_class, mock_updater, mock_event, mock_config @@ -85,7 +85,7 @@ def test_init_success( connection_timeout = 10 assert ( - DnsServerZoneUpdaterThreated(min_interval, connection_timeout, mock_config) + DnsServerZoneUpdaterThreaded(min_interval, connection_timeout, mock_config) is not None ) @@ -97,7 +97,7 @@ def test_init_success( @patch("threading.Event") @patch( - "indisoluble.a_healthy_dns.dns_server_zone_updater_threated.DnsServerZoneUpdater" + "indisoluble.a_healthy_dns.dns_server_zone_updater_threaded.DnsServerZoneUpdater" ) def test_init_failure_raises_value_error( mock_updater_class, mock_event_class, mock_config @@ -107,14 +107,14 @@ def test_init_failure_raises_value_error( with pytest.raises( ValueError, match="Failed to initialize updater: Initialization failed" ): - DnsServerZoneUpdaterThreated(5, 10, mock_config) + DnsServerZoneUpdaterThreaded(5, 10, mock_config) mock_event_class.assert_not_called() @patch("threading.Event") @patch( - "indisoluble.a_healthy_dns.dns_server_zone_updater_threated.DnsServerZoneUpdater" + "indisoluble.a_healthy_dns.dns_server_zone_updater_threaded.DnsServerZoneUpdater" ) def test_zone_property( mock_updater_class, @@ -127,13 +127,13 @@ def test_zone_property( mock_updater_class.return_value = mock_updater mock_event_class.return_value = mock_event - assert DnsServerZoneUpdaterThreated(5, 10, mock_config).zone == mock_zone + assert DnsServerZoneUpdaterThreaded(5, 10, mock_config).zone == mock_zone @patch("threading.Thread") @patch("threading.Event") @patch( - "indisoluble.a_healthy_dns.dns_server_zone_updater_threated.DnsServerZoneUpdater" + "indisoluble.a_healthy_dns.dns_server_zone_updater_threaded.DnsServerZoneUpdater" ) def test_start_success( mock_updater_class, @@ -146,7 +146,7 @@ def test_start_success( ): mock_updater_class.return_value = mock_updater mock_event_class.return_value = mock_event - updater = DnsServerZoneUpdaterThreated(5, 10, mock_config) + updater = DnsServerZoneUpdaterThreaded(5, 10, mock_config) mock_thread_class.return_value = mock_thread @@ -164,7 +164,7 @@ def test_start_success( @patch("threading.Thread") @patch("threading.Event") @patch( - "indisoluble.a_healthy_dns.dns_server_zone_updater_threated.DnsServerZoneUpdater" + "indisoluble.a_healthy_dns.dns_server_zone_updater_threaded.DnsServerZoneUpdater" ) def test_start_already_running( mock_updater_class, @@ -177,7 +177,7 @@ def test_start_already_running( ): mock_updater_class.return_value = mock_updater mock_event_class.return_value = mock_event - updater = DnsServerZoneUpdaterThreated(5, 10, mock_config) + updater = DnsServerZoneUpdaterThreaded(5, 10, mock_config) mock_thread_class.return_value = mock_thread mock_thread.is_alive.return_value = True @@ -200,14 +200,14 @@ def test_start_already_running( @patch("threading.Event") @patch( - "indisoluble.a_healthy_dns.dns_server_zone_updater_threated.DnsServerZoneUpdater" + "indisoluble.a_healthy_dns.dns_server_zone_updater_threaded.DnsServerZoneUpdater" ) def test_stop_not_running( mock_updater_class, mock_event_class, mock_updater, mock_event, mock_config ): mock_updater_class.return_value = mock_updater mock_event_class.return_value = mock_event - updater = DnsServerZoneUpdaterThreated(5, 10, mock_config) + updater = DnsServerZoneUpdaterThreaded(5, 10, mock_config) assert updater.stop() is True @@ -218,7 +218,7 @@ def test_stop_not_running( @patch("threading.Thread") @patch("threading.Event") @patch( - "indisoluble.a_healthy_dns.dns_server_zone_updater_threated.DnsServerZoneUpdater" + "indisoluble.a_healthy_dns.dns_server_zone_updater_threaded.DnsServerZoneUpdater" ) def test_stop_with_different_join_result( mock_updater_class, @@ -234,7 +234,7 @@ def test_stop_with_different_join_result( mock_event_class.return_value = mock_event connection_timeout = 10 - updater = DnsServerZoneUpdaterThreated(5, connection_timeout, mock_config) + updater = DnsServerZoneUpdaterThreaded(5, connection_timeout, mock_config) mock_thread_class.return_value = mock_thread @@ -258,7 +258,7 @@ def test_stop_with_different_join_result( @patch("time.time") @patch("threading.Event") @patch( - "indisoluble.a_healthy_dns.dns_server_zone_updater_threated.DnsServerZoneUpdater" + "indisoluble.a_healthy_dns.dns_server_zone_updater_threaded.DnsServerZoneUpdater" ) def test_update_zone( mock_updater_class, @@ -273,7 +273,7 @@ def test_update_zone( mock_updater_class.return_value = mock_updater mock_event_class.return_value = mock_event - updater = DnsServerZoneUpdaterThreated(min_interval, 10, mock_config) + updater = DnsServerZoneUpdaterThreaded(min_interval, 10, mock_config) update_count = 3 is_set_results = [False] * update_count + [True] diff --git a/tests/indisoluble/a_healthy_dns/test_main.py b/tests/indisoluble/a_healthy_dns/test_main.py index 4cc98da..9a698e9 100644 --- a/tests/indisoluble/a_healthy_dns/test_main.py +++ b/tests/indisoluble/a_healthy_dns/test_main.py @@ -39,7 +39,7 @@ def mock_config(): @patch("indisoluble.a_healthy_dns.main.logging") @patch("indisoluble.a_healthy_dns.main.make_config") -@patch("indisoluble.a_healthy_dns.main.DnsServerZoneUpdaterThreated") +@patch("indisoluble.a_healthy_dns.main.DnsServerZoneUpdaterThreaded") @patch("indisoluble.a_healthy_dns.main.socketserver.UDPServer") def test_main_success( mock_udp_server, @@ -80,7 +80,7 @@ def test_main_success( @patch("indisoluble.a_healthy_dns.main.logging") @patch("indisoluble.a_healthy_dns.main.make_config") -@patch("indisoluble.a_healthy_dns.main.DnsServerZoneUpdaterThreated") +@patch("indisoluble.a_healthy_dns.main.DnsServerZoneUpdaterThreaded") @patch("indisoluble.a_healthy_dns.main.socketserver.UDPServer") def test_main_with_failed_config( mock_udp_server, mock_zone_updater, mock_make_config, mock_logging, default_args