diff --git a/.github/workflows/test-docker.yml b/.github/workflows/test-docker.yml index 812a93f..5c0138c 100644 --- a/.github/workflows/test-docker.yml +++ b/.github/workflows/test-docker.yml @@ -29,34 +29,122 @@ jobs: # Verify the image was created docker images a-healthy-dns:test - - name: test docker image with minimal configuration + - name: test docker image with alias zone configuration run: | - # Start the DNS server in the background with minimal valid config + # Create an isolated network for deterministic container-to-container checks + docker network create --subnet 172.28.0.0/24 a-healthy-dns-test-net + + # Start backend service that health checks can reach + docker run -d \ + --name a-healthy-dns-backend \ + --network a-healthy-dns-test-net \ + --ip 172.28.0.10 \ + nginx:alpine + + # Start DNS server with hosted zone plus alias zones docker run -d \ --name a-healthy-dns-test \ + --network a-healthy-dns-test-net \ -p 53053:53053/udp \ -e DNS_HOSTED_ZONE="test.example.com" \ - -e DNS_ZONE_RESOLUTIONS='{"www":{"ips":["127.0.0.1"],"health_port":80}}' \ + -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_PORT="53053" \ + -e DNS_TEST_MIN_INTERVAL="1" \ + -e DNS_TEST_TIMEOUT="1" \ -e DNS_LOG_LEVEL="debug" \ a-healthy-dns:test - name: wait for dns server to start run: | # Wait a bit for the server to fully start - sleep 10 + sleep 5 - # Check if container is still running + # Check if containers are still running + docker ps | grep a-healthy-dns-backend docker ps | grep a-healthy-dns-test - name: test dns server functionality run: | + set -euo pipefail + # Install dig for testing sudo apt-get update && sudo apt-get install -y dnsutils - - # Test DNS query (should get a response, even if NXDOMAIN) - # Using timeout to avoid hanging - timeout 10s dig @127.0.0.1 -p 53053 www.test.example.com || echo "DNS query completed (expected behavior for test)" + + 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" + local answer + + for _ in $(seq 1 20); 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 ${fqdn}" + return 0 + } + sleep 1 + done + + echo "[FAIL] A ${fqdn}" + dig +nocmd +noall +comments +answer @"${DNS_HOST}" -p "${DNS_PORT}" "${fqdn}" A || true + return 1 + } + + assert_dns_status() { + local fqdn="$1" + local rtype="$2" + local expected_status="$3" + local output + local actual_status + + 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)" + + if [ "${actual_status}" = "${expected_status}" ]; then + echo "[OK] ${rtype} ${fqdn} ${actual_status}" + return 0 + fi + + echo "[FAIL] ${rtype} ${fqdn} expected=${expected_status} got=${actual_status:-none}" + printf '%s\n' "${output}" + 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}" + } + + for fqdn in \ + "www.test.example.com" \ + "www.test.other.com" \ + "www.test.another.com"; do + wait_for_a_record "${fqdn}" + done + + for zone in \ + "test.example.com" \ + "test.other.com" \ + "test.another.com"; do + assert_ns_record "${zone}" + 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" - name: test docker-compose configuration run: | @@ -66,7 +154,7 @@ jobs: sudo apt-get install -y docker-compose # Validate the compose file syntax - docker-compose -f docker-compose.example.yml config > /dev/null && echo "✓ docker-compose.example.yml is valid" + docker-compose -f docker-compose.example.yml config > /dev/null && echo "[OK] docker-compose.example.yml is valid" else echo "docker-compose.example.yml not found" fi @@ -74,9 +162,14 @@ jobs: - name: cleanup if: always() run: | - # Stop and remove test container + # Stop and remove test containers + docker stop a-healthy-dns-backend || true docker stop a-healthy-dns-test || true + docker rm a-healthy-dns-backend || true docker rm a-healthy-dns-test || true + + # Remove test network + docker network rm a-healthy-dns-test-net || true # Remove test image docker rmi a-healthy-dns:test || true diff --git a/Dockerfile b/Dockerfile index 3295f4d..ab48eea 100644 --- a/Dockerfile +++ b/Dockerfile @@ -63,15 +63,16 @@ ENV PATH="/home/appuser/.local/bin:$PATH" \ WORKDIR /app # Default environment variables for all parameters -ENV DNS_HOSTED_ZONE="" \ +ENV DNS_PORT="53" \ + DNS_LOG_LEVEL="" \ + DNS_HOSTED_ZONE="" \ + DNS_ALIAS_ZONES="" \ DNS_ZONE_RESOLUTIONS="" \ + DNS_TEST_MIN_INTERVAL="" \ + DNS_TEST_TIMEOUT="" \ DNS_NAME_SERVERS="" \ - DNS_PORT="53" \ - DNS_LOG_LEVEL="info" \ - DNS_TEST_MIN_INTERVAL="30" \ - DNS_TEST_TIMEOUT="2" \ DNS_PRIV_KEY_PATH="" \ - DNS_PRIV_KEY_ALG="RSASHA256" + DNS_PRIV_KEY_ALG="" # Expose the default DNS port (static at build time) EXPOSE 53/udp @@ -94,16 +95,33 @@ ENTRYPOINT ["tini", "--", "sh", "-c", "\ echo 'Error: DNS_NAME_SERVERS environment variable is required'; \ exit 1; \ fi; \ - ARGS=\"--hosted-zone $DNS_HOSTED_ZONE\"; \ - ARGS=\"$ARGS --zone-resolutions $DNS_ZONE_RESOLUTIONS\"; \ - ARGS=\"$ARGS --ns $DNS_NAME_SERVERS\"; \ - ARGS=\"$ARGS --port $DNS_PORT\"; \ + ARGS=\"--port $DNS_PORT\"; \ + if [ -n \"$DNS_LOG_LEVEL\" ]; then \ ARGS=\"$ARGS --log-level $DNS_LOG_LEVEL\"; \ + fi; \ + if [ -n \"$DNS_HOSTED_ZONE\" ]; then \ + ARGS=\"$ARGS --hosted-zone $DNS_HOSTED_ZONE\"; \ + fi; \ + if [ -n \"$DNS_ALIAS_ZONES\" ]; then \ + ARGS=\"$ARGS --alias-zones $DNS_ALIAS_ZONES\"; \ + fi; \ + if [ -n \"$DNS_ZONE_RESOLUTIONS\" ]; then \ + ARGS=\"$ARGS --zone-resolutions $DNS_ZONE_RESOLUTIONS\"; \ + fi; \ + if [ -n \"$DNS_TEST_MIN_INTERVAL\" ]; then \ ARGS=\"$ARGS --test-min-interval $DNS_TEST_MIN_INTERVAL\"; \ + fi; \ + if [ -n \"$DNS_TEST_TIMEOUT\" ]; then \ ARGS=\"$ARGS --test-timeout $DNS_TEST_TIMEOUT\"; \ + fi; \ + if [ -n \"$DNS_NAME_SERVERS\" ]; then \ + ARGS=\"$ARGS --ns $DNS_NAME_SERVERS\"; \ + fi; \ if [ -n \"$DNS_PRIV_KEY_PATH\" ]; then \ ARGS=\"$ARGS --priv-key-path $DNS_PRIV_KEY_PATH\"; \ fi; \ + if [ -n \"$DNS_PRIV_KEY_ALG\" ]; then \ ARGS=\"$ARGS --priv-key-alg $DNS_PRIV_KEY_ALG\"; \ + fi; \ echo \"Starting a-healthy-dns with arguments: $ARGS\"; \ exec a-healthy-dns $ARGS"] diff --git a/README.md b/README.md index 2d9e746..8c7ac2c 100644 --- a/README.md +++ b/README.md @@ -12,6 +12,7 @@ This ensures that DNS queries only return healthy endpoints, providing automatic - **Health Checking**: Continuously monitors IP addresses via TCP connectivity tests - **Dynamic Updates**: Automatically updates DNS zones based on health check results +- **Multi-Domain Support**: Serve multiple domains with the same records without duplicating health checks - **Configurable TTL**: TTL calculation based on health check intervals - **Threaded Operations**: Background health checking - **Multiple Records**: Support for multiple IP addresses per subdomain with individual health tracking @@ -120,6 +121,7 @@ a-healthy-dns \ - `--test-min-interval`: Minimum interval between connectivity tests in seconds (default: 30) - `--test-timeout`: Maximum time to wait for health check response in seconds (default: 2) - `--log-level`: Logging level (debug, info, warning, error, critical) (default: info) +- `--alias-zones`: Additional domain names that resolve to the same records as the hosted zone (JSON array, default: []) #### DNSSEC Parameters @@ -161,6 +163,23 @@ The `--zone-resolutions` parameter accepts a JSON object with the following stru - Health checks run continuously in the background at the configured interval - TTL values are automatically calculated based on health check intervals +### Multi-Domain Support + +The DNS server supports serving multiple domains that resolve to the same IP addresses without duplicating health checks. This is achieved through the `--alias-zones` parameter: + +```bash +a-healthy-dns \ + --hosted-zone primary.com \ + --alias-zones '["alias1.com", "alias2.com"]' \ + --zone-resolutions '{"www": {"ips": ["192.168.1.100"], "health_port": 8080}}' \ + --ns '["ns1.primary.com"]' +``` + +With this configuration: +- `www.primary.com` → resolves to `192.168.1.100` +- `www.alias1.com` → resolves to `192.168.1.100` (same IP) +- `www.alias2.com` → resolves to `192.168.1.100` (same IP) + ### Example Deployment For a deployment serving `example.com`: @@ -232,6 +251,7 @@ docker-compose up -d - `DNS_LOG_LEVEL`: Logging level (default: info) - `DNS_TEST_MIN_INTERVAL`: Minimum interval between connectivity tests in seconds (default: 30) - `DNS_TEST_TIMEOUT`: Timeout for each connection test in seconds (default: 2) +- `DNS_ALIAS_ZONES`: Additional domain names that resolve to the same records (JSON array, default: []) - `DNS_PRIV_KEY_PATH`: Path to DNSSEC private key PEM file - `DNS_PRIV_KEY_ALG`: DNSSEC private key algorithm (default: RSASHA256) diff --git a/docker-compose.example.yml b/docker-compose.example.yml index bbe68fe..62ea1bd 100644 --- a/docker-compose.example.yml +++ b/docker-compose.example.yml @@ -11,16 +11,17 @@ services: DNS_HOSTED_ZONE: "example.com" DNS_ZONE_RESOLUTIONS: '{"www":{"ips":["192.168.1.100","192.168.1.101"],"health_port":8080},"api":{"ips":["192.168.1.102"],"health_port":8000}}' DNS_NAME_SERVERS: '["ns1.example.com", "ns2.example.com"]' - + # Optional parameters (with their default values) # DNS_PORT: "53053" # DNS_LOG_LEVEL: "info" + # DNS_ALIAS_ZONES: '[]' # DNS_TEST_MIN_INTERVAL: "30" # DNS_TEST_TIMEOUT: "2" - # DNS_PRIV_KEY_ALG: "RSASHA256" - - # Optional DNSSEC private key path (if you have one) + + # Optional DNSSEC parameters (if you have one) # DNS_PRIV_KEY_PATH: "/app/keys/private.pem" + # DNS_PRIV_KEY_ALG: "RSASHA256" # volumes: # Mount a directory for DNSSEC keys (optional) diff --git a/indisoluble/a_healthy_dns/dns_server_config_factory.py b/indisoluble/a_healthy_dns/dns_server_config_factory.py index 3d7e326..917b15d 100644 --- a/indisoluble/a_healthy_dns/dns_server_config_factory.py +++ b/indisoluble/a_healthy_dns/dns_server_config_factory.py @@ -13,10 +13,11 @@ import dns.dnssecalgs import dns.name -from typing import Any, Dict, FrozenSet, List, NamedTuple, Optional, Union +from typing import Any, Dict, FrozenSet, NamedTuple, Optional from indisoluble.a_healthy_dns.records.a_healthy_record import AHealthyRecord from indisoluble.a_healthy_dns.records.a_healthy_ip import AHealthyIp +from indisoluble.a_healthy_dns.records.zone_origins import ZoneOrigins from indisoluble.a_healthy_dns.tools.is_valid_subdomain import is_valid_subdomain @@ -30,12 +31,13 @@ class ExtendedPrivateKey(NamedTuple): class DnsServerConfig(NamedTuple): """DNS server configuration containing zone data and security settings.""" - origin_name: dns.name.Name + zone_origins: ZoneOrigins name_servers: FrozenSet[str] a_records: FrozenSet[AHealthyRecord] ext_private_key: Optional[ExtendedPrivateKey] +ARG_ALIAS_ZONES = "alias_zones" ARG_DNSSEC_ALGORITHM = "priv_key_alg" ARG_DNSSEC_PRIVATE_KEY_PATH = "priv_key_path" ARG_HOSTED_ZONE = "zone" @@ -45,20 +47,30 @@ class DnsServerConfig(NamedTuple): ARG_ZONE_RESOLUTIONS = "resolutions" -def _make_origin_name(args: Dict[str, Any]) -> Optional[dns.name.Name]: +def _make_zone_origins(args: Dict[str, Any]) -> Optional[ZoneOrigins]: hosted_zone = args[ARG_HOSTED_ZONE] - success, error = is_valid_subdomain(hosted_zone) - if not success: - logging.error("Hosted zone '%s' is not a valid FQDN: %s", hosted_zone, error) + + try: + alias_zones = json.loads(args[ARG_ALIAS_ZONES]) + except json.JSONDecodeError as ex: + logging.error("Failed to parse alias zones: %s", ex) + return None + + if not isinstance(alias_zones, list): + logging.error("Alias zones must be a list, got %s", type(alias_zones).__name__) + return None + + try: + zone_origins = ZoneOrigins(hosted_zone, alias_zones) + except ValueError as ex: + logging.error("Failed to create zone origins: %s", ex) return None - return dns.name.from_text(hosted_zone, origin=dns.name.root) + return zone_origins def _make_healthy_a_record( - origin_name: dns.name.Name, - subdomain: str, - sub_config: Dict[str, Union[List[str], int]], + origin_name: dns.name.Name, subdomain: Any, sub_config: Any ) -> Optional[AHealthyRecord]: success, error = is_valid_subdomain(subdomain) if not success: @@ -77,7 +89,15 @@ def _make_healthy_a_record( ) return None - ip_list = sub_config[ARG_SUBDOMAIN_IP_LIST] + ip_list = sub_config.get(ARG_SUBDOMAIN_IP_LIST) + if ip_list is None: + logging.error( + "Zone resolution for '%s' must include '%s' key", + subdomain, + ARG_SUBDOMAIN_IP_LIST, + ) + return None + if not isinstance(ip_list, list): logging.error( "IP list for '%s' must be a list, got %s", subdomain, type(ip_list).__name__ @@ -88,19 +108,19 @@ def _make_healthy_a_record( logging.error("IP list for '%s' cannot be empty", subdomain) return None - health_port = sub_config[ARG_SUBDOMAIN_HEALTH_PORT] - if not isinstance(health_port, int): + health_port = sub_config.get(ARG_SUBDOMAIN_HEALTH_PORT) + if health_port is None: logging.error( - "Health port for '%s' must be an integer, got %s", + "Zone resolution for '%s' must include '%s' key", subdomain, - type(health_port).__name__, + ARG_SUBDOMAIN_HEALTH_PORT, ) return None try: healthy_ips = [AHealthyIp(ip, health_port, False) for ip in ip_list] except ValueError as ex: - logging.error("Invalid IP address in '%s': %s", subdomain, ex) + logging.error("Invalid IP/port address in '%s': %s", subdomain, ex) return None return AHealthyRecord(subdomain_name, healthy_ips) @@ -129,7 +149,7 @@ def _make_a_records( a_records = [] for subdomain, sub_config in raw_resolutions.items(): a_record = _make_healthy_a_record(origin_name, subdomain, sub_config) - if not a_record: + if a_record is None: logging.error("Failed to create A record for '%s'", subdomain) return None @@ -180,7 +200,7 @@ def _load_dnssec_private_key(key_path: str) -> Optional[bytes]: def _make_private_key(args: Dict[str, Any]) -> Optional[ExtendedPrivateKey]: priv_key_pem = _load_dnssec_private_key(args[ARG_DNSSEC_PRIVATE_KEY_PATH]) - if not priv_key_pem: + if priv_key_pem is None: return None try: @@ -197,26 +217,26 @@ def _make_private_key(args: Dict[str, Any]) -> Optional[ExtendedPrivateKey]: def make_config(args: Dict[str, Any]) -> Optional[DnsServerConfig]: """Create complete DNS server configuration from command-line arguments.""" - origin_name = _make_origin_name(args) - if not origin_name: + zone_origins = _make_zone_origins(args) + if zone_origins is None: return None - a_records = _make_a_records(origin_name, args) - if not a_records: + a_records = _make_a_records(zone_origins.primary, args) + if a_records is None: return None name_servers = _make_name_servers(args) - if not name_servers: + if name_servers is None: return None ext_private_key = None if args[ARG_DNSSEC_PRIVATE_KEY_PATH]: ext_private_key = _make_private_key(args) - if not ext_private_key: + if ext_private_key is None: return None return DnsServerConfig( - origin_name=origin_name, + zone_origins=zone_origins, name_servers=name_servers, a_records=a_records, ext_private_key=ext_private_key, diff --git a/indisoluble/a_healthy_dns/dns_server_udp_handler.py b/indisoluble/a_healthy_dns/dns_server_udp_handler.py index d45b44e..4874c9d 100644 --- a/indisoluble/a_healthy_dns/dns_server_udp_handler.py +++ b/indisoluble/a_healthy_dns/dns_server_udp_handler.py @@ -18,33 +18,39 @@ import dns.rrset import dns.versioned +from indisoluble.a_healthy_dns.records.zone_origins import ZoneOrigins + def _update_response( response: dns.message.Message, query_name: dns.name.Name, query_type: dns.rdatatype.RdataType, zone: dns.versioned.Zone, + zone_origins: ZoneOrigins, ): - with zone.reader() as txn: - node = None - if not query_name.is_absolute(): - node = txn.get_node(query_name) - elif query_name.is_subdomain(zone.origin): - node = txn.get_node(query_name.relativize(zone.origin)) + relative_name = zone_origins.relativize(query_name) + if relative_name is None: + logging.warning( + "Received query for domain not in hosted or alias zones: %s", query_name + ) + response.set_rcode(dns.rcode.NXDOMAIN) + return + with zone.reader() as txn: + node = txn.get_node(relative_name) if not node: - logging.warning("Received query for unknown domain: %s", query_name) + 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( - "Domain %s exists but has no %s records", + "Subdomain %s exists but has no %s records", query_name, dns.rdatatype.to_text(query_type), ) - response.set_rcode(dns.rcode.NXDOMAIN) + response.set_rcode(dns.rcode.NOERROR) return rrset = dns.rrset.RRset(query_name, rdataset.rdclass, rdataset.rdtype) @@ -74,7 +80,13 @@ def handle(self): if query.question: question = query.question[0] - _update_response(response, question.name, question.rdtype, self.server.zone) + _update_response( + response, + question.name, + question.rdtype, + self.server.zone, + self.server.zone_origins, + ) else: logging.warning("Received query without question section") response.set_rcode(dns.rcode.FORMERR) diff --git a/indisoluble/a_healthy_dns/dns_server_zone_updater.py b/indisoluble/a_healthy_dns/dns_server_zone_updater.py index 4fe0831..77748de 100644 --- a/indisoluble/a_healthy_dns/dns_server_zone_updater.py +++ b/indisoluble/a_healthy_dns/dns_server_zone_updater.py @@ -92,7 +92,7 @@ def __init__( self._ns_rec = make_ns_record(max_interval, config.name_servers) self._soa_rec = iter_soa_record( - max_interval, config.origin_name, next(iter(config.name_servers)) + max_interval, config.zone_origins.primary, next(iter(config.name_servers)) ) self._rrsig_action = ( RRSigAction( @@ -109,7 +109,7 @@ def __init__( can_create_connection, timeout=float(connection_timeout) ) - self._zone = dns.versioned.Zone(config.origin_name) + self._zone = dns.versioned.Zone(config.zone_origins.primary) self._is_zone_recreated_at_least_once = False def _clear_zone(self, txn: dns.transaction.Transaction): diff --git a/indisoluble/a_healthy_dns/main.py b/indisoluble/a_healthy_dns/main.py index c312151..c48b38d 100644 --- a/indisoluble/a_healthy_dns/main.py +++ b/indisoluble/a_healthy_dns/main.py @@ -1,6 +1,7 @@ #!/usr/bin/env python3 import argparse +import json import logging import signal import socketserver @@ -13,6 +14,7 @@ from typing import Any, Dict from indisoluble.a_healthy_dns.dns_server_config_factory import ( + ARG_ALIAS_ZONES, ARG_DNSSEC_ALGORITHM, ARG_DNSSEC_PRIVATE_KEY_PATH, ARG_HOSTED_ZONE, @@ -37,6 +39,7 @@ _GRP_GENERAL = "general arguments" _GRP_NS_RECORDS = "name server (NS) arguments" _GRP_ZONE_RESOLUTIONS = "zone resolution arguments" +_NAME_ALIAS_ZONES = "alias-zones" _NAME_HOSTED_ZONE = "hosted-zone" _NAME_LOG_LEVEL = "log-level" _NAME_NAME_SERVERS = "ns" @@ -46,6 +49,7 @@ _NAME_TEST_MIN_INTERVAL = "test-min-interval" _NAME_TEST_TIMEOUT = "test-timeout" _NAME_ZONE_RESOLUTIONS = "zone-resolutions" +_VAL_ALIAS_ZONES = json.dumps([]) _VAL_CONNECTION_TIMEOUT = 2 _VAL_DNSSEC_ALGORITHM = dns.dnssec.algorithm_to_text( dns.dnssectypes.Algorithm.RSASHA256 @@ -68,10 +72,12 @@ def _make_arg_parser() -> argparse.ArgumentParser: {_GRP_ZONE_RESOLUTIONS} {len(_GRP_ZONE_RESOLUTIONS) * '-'} --{_NAME_HOSTED_ZONE}: The domain name for which this DNS server is authoritative. +--{_NAME_ALIAS_ZONES}: Additional domain names that resolve to the same records. --{_NAME_ZONE_RESOLUTIONS}: JSON configuration defining subdomains, their IP addresses, and health check ports. Examples: --{_NAME_HOSTED_ZONE} example.com + --{_NAME_ALIAS_ZONES} '["alias1.com", "alias2.com"]' --{_NAME_ZONE_RESOLUTIONS} '{{"www":{{"ips":["192.168.1.100","192.168.1.101"],"health_port":8080}},"api":{{"ips":["192.168.1.102"],"health_port":8000}}}}' {_GRP_CONNECTIVITY_TESTS} @@ -130,6 +136,16 @@ def _make_arg_parser() -> argparse.ArgumentParser: dest=ARG_HOSTED_ZONE, help="Hosted zone name", ) + res_group.add_argument( + f"--{_NAME_ALIAS_ZONES}", + type=str, + default=_VAL_ALIAS_ZONES, + dest=ARG_ALIAS_ZONES, + help=( + "Alias zones that resolve to the same records as the hosted zone " + f'(ex. ["alias1.com", "alias2.com"], default: {_VAL_ALIAS_ZONES})' + ), + ) res_group.add_argument( f"--{_NAME_ZONE_RESOLUTIONS}", type=str, @@ -222,6 +238,7 @@ def _main(args: Dict[str, Any]): logging.info("DNS server listening on port %d...", args[_ARG_PORT]) server.zone = zone_updater.zone + server.zone_origins = config.zone_origins server.serve_forever() # Stop zone updater diff --git a/indisoluble/a_healthy_dns/records/a_healthy_ip.py b/indisoluble/a_healthy_dns/records/a_healthy_ip.py index 320ae77..4721525 100644 --- a/indisoluble/a_healthy_dns/records/a_healthy_ip.py +++ b/indisoluble/a_healthy_dns/records/a_healthy_ip.py @@ -6,6 +6,8 @@ for use in health-aware DNS A records. """ +from typing import Any + from indisoluble.a_healthy_dns.tools.is_valid_ip import is_valid_ip from indisoluble.a_healthy_dns.tools.is_valid_port import is_valid_port from indisoluble.a_healthy_dns.tools.normalize_ip import normalize_ip @@ -29,7 +31,7 @@ def is_healthy(self) -> bool: """Get the current health status.""" return self._is_healthy - def __init__(self, ip: str, health_port: int, is_healthy: bool): + def __init__(self, ip: Any, health_port: Any, is_healthy: bool): """Initialize healthy IP with validation of IP address and port.""" success, error = is_valid_ip(ip) if not success: diff --git a/indisoluble/a_healthy_dns/records/zone_origins.py b/indisoluble/a_healthy_dns/records/zone_origins.py new file mode 100644 index 0000000..ad4b141 --- /dev/null +++ b/indisoluble/a_healthy_dns/records/zone_origins.py @@ -0,0 +1,61 @@ +#!/usr/bin/env python3 + +"""Zone origins helper for DNS name normalization.""" + +import dns.name + +from typing import Any, List, Optional + +from indisoluble.a_healthy_dns.tools.is_valid_subdomain import is_valid_subdomain + + +def _to_abs_name(raw_name: Any) -> dns.name.Name: + success, error = is_valid_subdomain(raw_name) + if not success: + raise ValueError(f"Invalid domain '{raw_name}': {error}") + + return dns.name.from_text(raw_name, origin=dns.name.root) + + +class ZoneOrigins: + """Read-only holder of primary and alias zone origins.""" + + @property + def primary(self) -> dns.name.Name: + """Get the primary zone origin.""" + return self._primary + + def __init__(self, primary: Any, aliases: List[Any]): + """Initialize zone origins with a primary and alias set.""" + self._primary = _to_abs_name(primary) + + # Prefer the most specific matching zone and keep deterministic order. + self._origins = sorted( + {self._primary, *(_to_abs_name(alias) for alias in aliases)}, + key=lambda zone: (-len(zone), zone.to_text()), + ) + + def relativize(self, name: dns.name.Name) -> Optional[dns.name.Name]: + """Return relative name using matching origin, or None when unmatched.""" + if not name.is_absolute(): + return name + + zone = next( + (origin for origin in self._origins if name.is_subdomain(origin)), None + ) + return name.relativize(zone) if zone else None + + def __eq__(self, other: object) -> bool: + if not isinstance(other, ZoneOrigins): + return False + + return self._primary == other._primary and self._origins == other._origins + + def __hash__(self) -> int: + return hash((self._primary, tuple(self._origins))) + + def __repr__(self) -> str: + aliases = [ + origin.to_text() for origin in self._origins if origin != self._primary + ] + return f"ZoneOrigins(primary={self._primary.to_text()!r}, aliases={aliases!r})" diff --git a/indisoluble/a_healthy_dns/tools/is_valid_ip.py b/indisoluble/a_healthy_dns/tools/is_valid_ip.py index 96252e6..522c0fb 100644 --- a/indisoluble/a_healthy_dns/tools/is_valid_ip.py +++ b/indisoluble/a_healthy_dns/tools/is_valid_ip.py @@ -6,11 +6,14 @@ and numeric ranges. """ -from typing import Tuple +from typing import Any, Tuple -def is_valid_ip(ip: str) -> Tuple[bool, str]: +def is_valid_ip(ip: Any) -> Tuple[bool, str]: """Validate IPv4 address format and octet ranges.""" + if not isinstance(ip, str): + return (False, "It must be a string") + parts = ip.split(".") if len(parts) != 4: return (False, "IP address must have 4 octets") diff --git a/indisoluble/a_healthy_dns/tools/is_valid_port.py b/indisoluble/a_healthy_dns/tools/is_valid_port.py index d8a1797..42bed23 100644 --- a/indisoluble/a_healthy_dns/tools/is_valid_port.py +++ b/indisoluble/a_healthy_dns/tools/is_valid_port.py @@ -6,11 +6,14 @@ range of 1-65535. """ -from typing import Tuple +from typing import Any, Tuple -def is_valid_port(port: int) -> Tuple[bool, str]: +def is_valid_port(port: Any) -> Tuple[bool, str]: """Validate port number is within valid range (1-65535).""" + if not isinstance(port, int): + return (False, "Port must be an integer") + if not (1 <= port <= 65535): return (False, "Port must be between 1 and 65535") diff --git a/indisoluble/a_healthy_dns/tools/is_valid_subdomain.py b/indisoluble/a_healthy_dns/tools/is_valid_subdomain.py index d953c03..a0a6948 100644 --- a/indisoluble/a_healthy_dns/tools/is_valid_subdomain.py +++ b/indisoluble/a_healthy_dns/tools/is_valid_subdomain.py @@ -6,11 +6,14 @@ DNS naming rules and character restrictions. """ -from typing import Tuple +from typing import Any, Tuple -def is_valid_subdomain(name: str) -> Tuple[bool, str]: +def is_valid_subdomain(name: Any) -> Tuple[bool, str]: """Validate subdomain name format and character restrictions.""" + if not isinstance(name, str): + return (False, "It must be a string") + if not name: return (False, "It cannot be empty") diff --git a/setup.py b/setup.py index 7071dfe..92111dc 100644 --- a/setup.py +++ b/setup.py @@ -2,11 +2,11 @@ setup( name="a_healthy_dns", - version="0.1.25", + version="0.1.26", description="A healthy DNS project", packages=find_packages(), python_requires=">=3.10", - install_requires=["cryptography>=46.0.3,<47.0.0", "dnspython>=2.8.0,<3.0.0"], + install_requires=["cryptography>=46.0.5,<47.0.0", "dnspython>=2.8.0,<3.0.0"], entry_points={ "console_scripts": ["a-healthy-dns = indisoluble.a_healthy_dns.main:main"] }, diff --git a/tests/indisoluble/a_healthy_dns/records/test_a_healthy_ip.py b/tests/indisoluble/a_healthy_dns/records/test_a_healthy_ip.py index 77c5a0d..fee4228 100644 --- a/tests/indisoluble/a_healthy_dns/records/test_a_healthy_ip.py +++ b/tests/indisoluble/a_healthy_dns/records/test_a_healthy_ip.py @@ -24,6 +24,11 @@ def test_ip_normalization(non_normalized_ip, expected_ip): @pytest.mark.parametrize( "invalid_ip", [ + None, + 123, + 1.5, + [], + {}, "256.0.0.1", # octet > 255 "192.168.1", # not enough octets "192.168.1.256", # octet > 255 @@ -32,23 +37,26 @@ def test_ip_normalization(non_normalized_ip, expected_ip): ], ) def test_invalid_ip(invalid_ip): - with pytest.raises(ValueError) as exc_info: + with pytest.raises(ValueError): AHealthyIp(invalid_ip, 8080, True) - assert "Invalid IP address" in str(exc_info.value) @pytest.mark.parametrize( "invalid_port", [ + "8080", + None, + 1.5, + [], + {}, 0, # below range 65536, # above range -1, # negative ], ) def test_invalid_port(invalid_port): - with pytest.raises(ValueError) as exc_info: + with pytest.raises(ValueError): AHealthyIp("192.168.1.1", invalid_port, True) - assert "Invalid port" in str(exc_info.value) def test_equality(): diff --git a/tests/indisoluble/a_healthy_dns/records/test_zone_origins.py b/tests/indisoluble/a_healthy_dns/records/test_zone_origins.py new file mode 100644 index 0000000..61403ad --- /dev/null +++ b/tests/indisoluble/a_healthy_dns/records/test_zone_origins.py @@ -0,0 +1,163 @@ +#!/usr/bin/env python3 + +import dns.name +import pytest + +from indisoluble.a_healthy_dns.records.zone_origins import ZoneOrigins + + +@pytest.mark.parametrize( + "invalid_primary", [None, 123, 1.5, [], {}, "", "bad!primary", "example..com"] +) +def test_init_raises_for_invalid_primary(invalid_primary): + with pytest.raises(ValueError): + ZoneOrigins(invalid_primary, []) + + +@pytest.mark.parametrize( + "invalid_alias", + [ + None, + 123, + 1.5, + [], + {}, + "", + "bad!alias", + "alias..com", + ], +) +def test_init_raises_for_invalid_alias(invalid_alias): + with pytest.raises(ValueError): + ZoneOrigins("example.com", [invalid_alias]) + + +def test_primary_is_parsed_as_absolute_name(): + origins = ZoneOrigins("example.com", []) + + assert origins.primary == dns.name.from_text("example.com", origin=dns.name.root) + + +def test_relativize_keeps_relative_name_as_is(): + origins = ZoneOrigins("example.com", []) + relative_name = dns.name.from_text("www", origin=None) + + result = origins.relativize(relative_name) + + assert result == relative_name + + +def test_relativize_absolute_name_under_primary(): + origins = ZoneOrigins("example.com", []) + absolute_name = dns.name.from_text("www", origin=dns.name.from_text("example.com")) + + result = origins.relativize(absolute_name) + + assert result == dns.name.from_text("www", origin=None) + + +def test_relativize_absolute_name_under_alias(): + origins = ZoneOrigins("example.com", ["alias.com"]) + absolute_name = dns.name.from_text("www", origin=dns.name.from_text("alias.com")) + + result = origins.relativize(absolute_name) + + assert result == dns.name.from_text("www", origin=None) + + +def test_relativize_returns_none_for_unmatched_absolute_name(): + origins = ZoneOrigins("example.com", ["alias.com"]) + absolute_name = dns.name.from_text("www", origin=dns.name.from_text("other.com")) + + result = origins.relativize(absolute_name) + + assert result is None + + +def test_relativize_prefers_most_specific_matching_origin(): + origins = ZoneOrigins("example.com", ["dev.example.com"]) + absolute_name = dns.name.from_text( + "api.dev", origin=dns.name.from_text("example.com") + ) + + result = origins.relativize(absolute_name) + + assert result == dns.name.from_text("api", origin=None) + + +def test_eq_returns_true_for_identical_zone_origins(): + origins1 = ZoneOrigins("example.com", ["alias1.com", "alias2.com"]) + origins2 = ZoneOrigins("example.com", ["alias1.com", "alias2.com"]) + + assert origins1 == origins2 + + +def test_eq_returns_true_for_same_zones_different_order(): + origins1 = ZoneOrigins("example.com", ["alias1.com", "alias2.com"]) + origins2 = ZoneOrigins("example.com", ["alias2.com", "alias1.com"]) + + assert origins1 == origins2 + + +def test_eq_returns_true_for_same_zones_with_repited_aliases(): + origins1 = ZoneOrigins("example.com", ["alias1.com", "alias2.com", "alias1.com"]) + origins2 = ZoneOrigins("example.com", ["alias2.com", "alias1.com"]) + + assert origins1 == origins2 + + +def test_eq_returns_false_for_different_primary(): + origins1 = ZoneOrigins("example.com", []) + origins2 = ZoneOrigins("other.com", []) + + assert origins1 != origins2 + + +def test_eq_returns_false_for_different_aliases(): + origins1 = ZoneOrigins("example.com", ["alias1.com"]) + origins2 = ZoneOrigins("example.com", ["alias2.com"]) + + assert origins1 != origins2 + + +def test_eq_returns_false_for_non_zone_origins(): + origins = ZoneOrigins("example.com", []) + + assert origins.__eq__("not a ZoneOrigins") is False + + +def test_hash_is_consistent(): + origins1 = ZoneOrigins("example.com", ["alias1.com", "alias2.com", "alias1.com"]) + origins2 = ZoneOrigins("example.com", ["alias2.com", "alias1.com"]) + + assert hash(origins1) == hash(origins2) + + +def test_hash_allows_use_in_set(): + origins1 = ZoneOrigins("example.com", ["alias1.com"]) + origins2 = ZoneOrigins("example.com", ["alias1.com"]) + origins3 = ZoneOrigins("other.com", []) + + zone_set = {origins1, origins2, origins3} + + assert len(zone_set) == 2 + + +def test_repr_shows_primary_and_aliases(): + origins = ZoneOrigins("example.com", ["alias1.com", "alias2.com"]) + + result = repr(origins) + + assert "example.com." in result + assert "alias1.com." in result + assert "alias2.com." in result + assert result.startswith("ZoneOrigins(") + + +def test_repr_shows_no_aliases_when_empty(): + origins = ZoneOrigins("example.com", []) + + result = repr(origins) + + assert "example.com." in result + assert result == "ZoneOrigins(primary='example.com.', aliases=[])" diff --git a/tests/indisoluble/a_healthy_dns/test_dns_server_config_factory.py b/tests/indisoluble/a_healthy_dns/test_dns_server_config_factory.py index 6ce4ca6..69773d9 100644 --- a/tests/indisoluble/a_healthy_dns/test_dns_server_config_factory.py +++ b/tests/indisoluble/a_healthy_dns/test_dns_server_config_factory.py @@ -13,12 +13,14 @@ from dns.dnssecalgs.rsa import PrivateRSASHA256 from indisoluble.a_healthy_dns.records.a_healthy_ip import AHealthyIp +from indisoluble.a_healthy_dns.records.zone_origins import ZoneOrigins @pytest.fixture def args(): return { dscf.ARG_HOSTED_ZONE: "dev.example.com", + dscf.ARG_ALIAS_ZONES: json.dumps(["dev.alias-one.com", "dev.alias-two.com"]), dscf.ARG_NAME_SERVERS: json.dumps(["ns1.example.com", "ns2.example.com"]), dscf.ARG_ZONE_RESOLUTIONS: json.dumps( { @@ -59,9 +61,9 @@ def test_make_config_success(args): # Check private key assert config.ext_private_key is None - # Check origin name - assert config.origin_name == dns.name.from_text( - "dev.example.com", origin=dns.name.root + # Check zone origins + assert config.zone_origins == ZoneOrigins( + "dev.example.com", ["dev.alias-one.com", "dev.alias-two.com"] ) # Check name servers @@ -75,28 +77,28 @@ def test_make_config_success(args): healthy_ips_by_subdomain[record.subdomain] = record.healthy_ips # Check www subdomain - www_name = dns.name.from_text("www", origin=config.origin_name) + www_name = dns.name.from_text("www", origin=config.zone_origins.primary) assert www_name in healthy_ips_by_subdomain assert healthy_ips_by_subdomain[www_name] == frozenset( [AHealthyIp("192.168.1.1", 8080, False), AHealthyIp("192.168.1.2", 8080, False)] ) # Check api subdomain - api_name = dns.name.from_text("api", origin=config.origin_name) + api_name = dns.name.from_text("api", origin=config.zone_origins.primary) assert api_name in healthy_ips_by_subdomain assert healthy_ips_by_subdomain[api_name] == frozenset( [AHealthyIp("192.168.2.1", 8081, False)] ) # Check repeated subdomain - repeated_name = dns.name.from_text("repeated", origin=config.origin_name) + repeated_name = dns.name.from_text("repeated", origin=config.zone_origins.primary) assert repeated_name in healthy_ips_by_subdomain assert healthy_ips_by_subdomain[repeated_name] == frozenset( [AHealthyIp("10.16.2.1", 8082, False)] ) # Check zeros subdomain - zeros_name = dns.name.from_text("zeros", origin=config.origin_name) + zeros_name = dns.name.from_text("zeros", origin=config.zone_origins.primary) assert zeros_name in healthy_ips_by_subdomain assert healthy_ips_by_subdomain[zeros_name] == frozenset( [AHealthyIp("102.18.1.1", 8083, False), AHealthyIp("192.168.0.20", 8083, False)] @@ -120,7 +122,7 @@ def test_make_zone_success_with_dnssec(mock_load_key, args_with_dnssec): ) # Check others - assert config.origin_name is not None + assert config.zone_origins is not None assert config.name_servers is not None assert config.a_records is not None @@ -131,12 +133,28 @@ def test_make_zone_invalid_hosted_zone(invalid_zone, args): assert dscf.make_config(args) is None +@pytest.mark.parametrize( + "invalid_aliases", + [ + "invalid json", + json.dumps({"alias": "dev.alias-one.com"}), + json.dumps([""]), + json.dumps(["dev.alias-one.com", "dev.alias-@.com"]), + ], +) +def test_make_zone_invalid_alias_zones(invalid_aliases, args): + args[dscf.ARG_ALIAS_ZONES] = invalid_aliases + assert dscf.make_config(args) is None + + @pytest.mark.parametrize( "invalid_ns", [ "invalid json", - json.dumps([]), json.dumps({"ns": "ns1.example.com"}), + json.dumps([]), + json.dumps([123]), + json.dumps([""]), json.dumps(["ns1.example@.com"]), ], ) @@ -149,8 +167,16 @@ def test_make_zone_invalid_json_name_servers(invalid_ns, args): "invalid_resolution", [ "invalid json", - json.dumps({}), json.dumps(["192.168.1.1", 8080]), + json.dumps({}), + json.dumps( + { + "": { + dscf.ARG_SUBDOMAIN_IP_LIST: ["192.168.1.1"], + dscf.ARG_SUBDOMAIN_HEALTH_PORT: 8080, + } + } + ), json.dumps( { "www@": { @@ -160,6 +186,22 @@ def test_make_zone_invalid_json_name_servers(invalid_ns, args): } ), json.dumps({"www": ["192.168.1.1", 8080]}), + json.dumps({"www": {}}), + json.dumps( + { + "www": { + dscf.ARG_SUBDOMAIN_HEALTH_PORT: 8080, + } + } + ), + json.dumps( + { + "www": { + dscf.ARG_SUBDOMAIN_IP_LIST: None, + dscf.ARG_SUBDOMAIN_HEALTH_PORT: 8080, + } + } + ), json.dumps( { "www": { @@ -176,6 +218,29 @@ def test_make_zone_invalid_json_name_servers(invalid_ns, args): } } ), + json.dumps( + { + "www": { + dscf.ARG_SUBDOMAIN_IP_LIST: ["192.168.1.1"], + } + } + ), + json.dumps( + { + "www": { + dscf.ARG_SUBDOMAIN_IP_LIST: ["192.168.1.1"], + dscf.ARG_SUBDOMAIN_HEALTH_PORT: None, + } + } + ), + json.dumps( + { + "www": { + dscf.ARG_SUBDOMAIN_IP_LIST: [123], + dscf.ARG_SUBDOMAIN_HEALTH_PORT: 8080, + } + } + ), json.dumps( { "www": { 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 34abf07..43ac6e3 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 @@ -17,6 +17,7 @@ _update_response, DnsServerUdpHandler, ) +from indisoluble.a_healthy_dns.records.zone_origins import ZoneOrigins @pytest.fixture @@ -26,9 +27,14 @@ def mock_reader(): @pytest.fixture -def mock_zone(mock_reader): +def mock_zone_origins(): + return ZoneOrigins("example.com", []) + + +@pytest.fixture +def mock_zone(mock_reader, mock_zone_origins): zone = MagicMock() - zone.origin = dns.name.from_text("example.com.") + zone.origin = mock_zone_origins.primary zone.rdclass = dns.rdataclass.IN zone.reader.return_value = mock_reader return zone @@ -52,9 +58,10 @@ def mock_rdataset(): @pytest.fixture -def mock_server(mock_zone): +def mock_server(mock_zone, mock_zone_origins): server = MagicMock() server.zone = mock_zone + server.zone_origins = mock_zone_origins return server @@ -75,7 +82,7 @@ def dns_client_address(): def test_update_response_with_relative_name_found( - mock_zone, mock_reader, mock_rdata, mock_rdataset, dns_response + mock_zone, mock_reader, mock_rdata, mock_rdataset, dns_response, mock_zone_origins ): # Setup query_name = dns.name.from_text("test", origin=None) @@ -92,7 +99,7 @@ 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) + _update_response(dns_response, query_name, query_type, mock_zone, mock_zone_origins) # Assertions mock_zone.reader.assert_called_once() @@ -108,10 +115,10 @@ def test_update_response_with_relative_name_found( def test_update_response_with_absolute_name_found( - mock_zone, mock_reader, mock_rdata, mock_rdataset, dns_response + mock_zone, mock_reader, mock_rdata, mock_rdataset, dns_response, mock_zone_origins ): # Setup - query_name = dns.name.from_text("test", mock_zone.origin) + query_name = dns.name.from_text("test", origin=mock_zone_origins.primary) query_type = dns.rdatatype.A # Mock zone.reader.get_node for relative name @@ -125,12 +132,12 @@ 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) + _update_response(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.origin) + 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 @@ -142,9 +149,27 @@ def test_update_response_with_absolute_name_found( assert list(dns_response.answer[0]) == [mock_rdata] -def test_update_response_domain_not_found(mock_zone, mock_reader, dns_response): +def test_update_response_with_absolute_name_outside_zone_origins( + mock_zone, 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) + + # Assertions + mock_zone.reader.assert_not_called() + assert dns_response.rcode() == dns.rcode.NXDOMAIN + assert len(dns_response.answer) == 0 + + +def test_update_response_domain_not_found( + mock_zone, mock_reader, dns_response, mock_zone_origins +): # Setup - query_name = dns.name.from_text("nonexistent", mock_zone.origin) + query_name = dns.name.from_text("nonexistent", origin=mock_zone_origins.primary) query_type = dns.rdatatype.A # Mock zone.reader.get_node to return None @@ -153,20 +178,22 @@ def test_update_response_domain_not_found(mock_zone, mock_reader, dns_response): mock_zone.reader.return_value.__enter__.return_value = mock_reader # Call function - _update_response(dns_response, query_name, query_type, mock_zone) + _update_response(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.origin) + query_name.relativize(mock_zone_origins.primary) ) assert dns_response.rcode() == dns.rcode.NXDOMAIN assert len(dns_response.answer) == 0 -def test_update_response_record_type_not_found(mock_zone, mock_reader, dns_response): +def test_update_response_record_type_not_found( + mock_zone, mock_reader, dns_response, mock_zone_origins +): # Setup - query_name = dns.name.from_text("test", mock_zone.origin) + query_name = dns.name.from_text("test", origin=mock_zone_origins.primary) query_type = dns.rdatatype.A # Mock zone.get_node to return a node but get_rdataset returns None @@ -178,15 +205,15 @@ def test_update_response_record_type_not_found(mock_zone, mock_reader, dns_respo mock_zone.reader.return_value.__enter__.return_value = mock_reader # Call function - _update_response(dns_response, query_name, query_type, mock_zone) + _update_response(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.origin) + 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.NXDOMAIN + assert dns_response.rcode() == dns.rcode.NOERROR assert len(dns_response.answer) == 0 @@ -207,6 +234,7 @@ def test_handle_valid_query( assert mock_update_response.call_args[0][1] == question.name assert mock_update_response.call_args[0][2] == question.rdtype assert mock_update_response.call_args[0][3] == mock_server.zone + assert mock_update_response.call_args[0][4] == mock_server.zone_origins # Check response was sent mock_sock = dns_request[1] diff --git a/tests/indisoluble/a_healthy_dns/test_dns_server_zone_updater.py b/tests/indisoluble/a_healthy_dns/test_dns_server_zone_updater.py index 6b2fb16..8c4efab 100644 --- a/tests/indisoluble/a_healthy_dns/test_dns_server_zone_updater.py +++ b/tests/indisoluble/a_healthy_dns/test_dns_server_zone_updater.py @@ -19,6 +19,7 @@ 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 def _get_rrsig_rdatasets( @@ -33,13 +34,13 @@ def _get_rrsig_rdatasets( @pytest.fixture -def origin_name(): - return dns.name.from_text("example.com", origin=dns.name.root) +def zone_origins(): + return ZoneOrigins("example.com", []) @pytest.fixture -def a_record_all_ips_healthy(origin_name): - subdomain = dns.name.from_text("www", origin=origin_name) +def a_record_all_ips_healthy(zone_origins): + subdomain = dns.name.from_text("www", origin=zone_origins.primary) ip1 = AHealthyIp(ip="192.168.1.1", health_port=8080, is_healthy=True) ip2 = AHealthyIp(ip="192.168.1.2", health_port=8080, is_healthy=True) @@ -47,8 +48,8 @@ def a_record_all_ips_healthy(origin_name): @pytest.fixture -def a_record_ip_unhealthy(origin_name): - subdomain = dns.name.from_text("api", origin=origin_name) +def a_record_ip_unhealthy(zone_origins): + subdomain = dns.name.from_text("api", origin=zone_origins.primary) ip = AHealthyIp(ip="192.168.1.3", health_port=8080, is_healthy=False) return AHealthyRecord(subdomain=subdomain, healthy_ips=[ip]) @@ -69,10 +70,10 @@ def ext_private_key(): @pytest.fixture def basic_config( - origin_name, name_servers, a_record_all_ips_healthy, a_record_ip_unhealthy + zone_origins, name_servers, a_record_all_ips_healthy, a_record_ip_unhealthy ): return DnsServerConfig( - origin_name=origin_name, + zone_origins=zone_origins, name_servers=frozenset(name_servers), a_records=frozenset((a_record_all_ips_healthy, a_record_ip_unhealthy)), ext_private_key=None, @@ -81,14 +82,14 @@ def basic_config( @pytest.fixture def config_with_dnssec( - origin_name, + zone_origins, name_servers, a_record_all_ips_healthy, a_record_ip_unhealthy, ext_private_key, ): return DnsServerConfig( - origin_name=origin_name, + zone_origins=zone_origins, name_servers=frozenset(name_servers), a_records=frozenset((a_record_all_ips_healthy, a_record_ip_unhealthy)), ext_private_key=ext_private_key, @@ -97,7 +98,7 @@ def config_with_dnssec( @pytest.fixture def config_with_mock_dnssec( - origin_name, name_servers, a_record_all_ips_healthy, a_record_ip_unhealthy + zone_origins, name_servers, a_record_all_ips_healthy, a_record_ip_unhealthy ): mock_private_key = Mock(spec=dns.dnssec.PrivateKey) mock_dnskey = Mock(spec=dns.dnssec.DNSKEY) @@ -106,7 +107,7 @@ def config_with_mock_dnssec( ) return DnsServerConfig( - origin_name=origin_name, + zone_origins=zone_origins, name_servers=frozenset(name_servers), a_records=frozenset((a_record_all_ips_healthy, a_record_ip_unhealthy)), ext_private_key=ext_private_key, @@ -164,10 +165,10 @@ def test_init_success_without_dnssec( assert updater is not None assert updater.zone is not None - assert updater.zone.origin == basic_config.origin_name + assert updater.zone.origin == basic_config.zone_origins.primary assert len(list(updater.zone.keys())) == 0 mock_make_ns_record.assert_called_once_with(ANY, basic_config.name_servers) - mock_iter_soa_record.assert_called_once_with(ANY, basic_config.origin_name, ANY) + mock_iter_soa_record.assert_called_once_with(ANY, basic_config.zone_origins.primary, ANY) mock_iter_rrsig_key.assert_not_called() @@ -194,14 +195,14 @@ def test_init_success_with_dnssec( assert updater is not None assert updater.zone is not None - assert updater.zone.origin == config_with_mock_dnssec.origin_name + assert updater.zone.origin == config_with_mock_dnssec.zone_origins.primary assert len(list(updater.zone.keys())) == 0 mock_make_ns_record.assert_called_once_with( ANY, config_with_mock_dnssec.name_servers ) mock_iter_soa_record.assert_called_once_with( - ANY, config_with_mock_dnssec.origin_name, ANY + ANY, config_with_mock_dnssec.zone_origins.primary, ANY ) mock_iter_rrsig_key.assert_called_once_with( ANY, config_with_mock_dnssec.ext_private_key @@ -312,10 +313,10 @@ def test_initialize_zone_creates_zone_with_basic_and_rrsig_records( def test_initialize_zone_with_no_healthy_ips( - origin_name, name_servers, a_record_ip_unhealthy + zone_origins, name_servers, a_record_ip_unhealthy ): config = DnsServerConfig( - origin_name=origin_name, + zone_origins=zone_origins, name_servers=frozenset(name_servers), a_records=frozenset([a_record_ip_unhealthy]), ext_private_key=None, 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_threated.py index 660e7ac..059442e 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_threated.py @@ -19,21 +19,22 @@ ) 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 @pytest.fixture -def mock_origin_name(): - return dns.name.from_text("example.com", origin=dns.name.root) +def mock_zone_origins(): + return ZoneOrigins("example.com", []) @pytest.fixture -def mock_config(mock_origin_name): - subdomain = dns.name.from_text("www", origin=mock_origin_name) +def mock_config(mock_zone_origins): + subdomain = dns.name.from_text("www", origin=mock_zone_origins.primary) ip = AHealthyIp(ip="192.168.1.1", health_port=8080, is_healthy=True) a_record = AHealthyRecord(subdomain=subdomain, healthy_ips=[ip]) return DnsServerConfig( - origin_name=mock_origin_name, + zone_origins=mock_zone_origins, name_servers=frozenset(["ns1.example.com", "ns2.example.com"]), a_records=frozenset([a_record]), ext_private_key=None, @@ -41,9 +42,9 @@ def mock_config(mock_origin_name): @pytest.fixture -def mock_zone(mock_origin_name): +def mock_zone(mock_zone_origins): zone = Mock(spec=dns.versioned.Zone) - zone.origin = mock_origin_name + zone.origin = mock_zone_origins.primary return zone diff --git a/tests/indisoluble/a_healthy_dns/test_main.py b/tests/indisoluble/a_healthy_dns/test_main.py index 8a21177..4cc98da 100644 --- a/tests/indisoluble/a_healthy_dns/test_main.py +++ b/tests/indisoluble/a_healthy_dns/test_main.py @@ -33,6 +33,7 @@ def default_args() -> Dict[str, Any]: @pytest.fixture def mock_config(): mock = MagicMock() + mock.zone_origins = MagicMock() return mock @@ -70,6 +71,8 @@ def test_main_success( mock_udp_server.assert_called_once_with(("", default_args[_ARG_PORT]), ANY) + assert mock_server_instance.zone == mock_zone_updater_instance.zone + assert mock_server_instance.zone_origins == mock_config.zone_origins mock_server_instance.serve_forever.assert_called_once() mock_zone_updater_instance.stop.assert_called_once() @@ -80,12 +83,7 @@ def test_main_success( @patch("indisoluble.a_healthy_dns.main.DnsServerZoneUpdaterThreated") @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, - mock_config, + mock_udp_server, mock_zone_updater, mock_make_config, mock_logging, default_args ): # Setup mocks mock_make_config.return_value = None diff --git a/tests/indisoluble/a_healthy_dns/tools/test_is_valid_ip.py b/tests/indisoluble/a_healthy_dns/tools/test_is_valid_ip.py index af3aab5..235b272 100644 --- a/tests/indisoluble/a_healthy_dns/tools/test_is_valid_ip.py +++ b/tests/indisoluble/a_healthy_dns/tools/test_is_valid_ip.py @@ -25,6 +25,11 @@ def test_valid_ip_addresses(valid_ip): @pytest.mark.parametrize( "invalid_ip,expected_message", [ + (None, "It must be a string"), + (123, "It must be a string"), + (1.5, "It must be a string"), + ([], "It must be a string"), + ({}, "It must be a string"), ("256.0.0.1", "Each octet must be a number between 0 and 255"), ("192.168.1", "IP address must have 4 octets"), ("192.168.1.256", "Each octet must be a number between 0 and 255"), diff --git a/tests/indisoluble/a_healthy_dns/tools/test_is_valid_port.py b/tests/indisoluble/a_healthy_dns/tools/test_is_valid_port.py index 405f07f..e3bc8be 100644 --- a/tests/indisoluble/a_healthy_dns/tools/test_is_valid_port.py +++ b/tests/indisoluble/a_healthy_dns/tools/test_is_valid_port.py @@ -15,6 +15,11 @@ def test_valid_ports(valid_port): @pytest.mark.parametrize( "invalid_port,expected_message", [ + ("80", "Port must be an integer"), + (None, "Port must be an integer"), + (8080.5, "Port must be an integer"), + ([], "Port must be an integer"), + ({}, "Port must be an integer"), (0, "Port must be between 1 and 65535"), (-1, "Port must be between 1 and 65535"), (65536, "Port must be between 1 and 65535"), diff --git a/tests/indisoluble/a_healthy_dns/tools/test_is_valid_subdomain.py b/tests/indisoluble/a_healthy_dns/tools/test_is_valid_subdomain.py index a0c1dda..d90a166 100644 --- a/tests/indisoluble/a_healthy_dns/tools/test_is_valid_subdomain.py +++ b/tests/indisoluble/a_healthy_dns/tools/test_is_valid_subdomain.py @@ -31,6 +31,11 @@ def test_valid_subdomains(valid_subdomain): @pytest.mark.parametrize( "invalid_subdomain,expected_message", [ + (None, "It must be a string"), + (123, "It must be a string"), + (1.5, "It must be a string"), + ([], "It must be a string"), + ({}, "It must be a string"), ("", "It cannot be empty"), ( "domain@example",