Skip to content
Merged
Show file tree
Hide file tree
Changes from 14 commits
Commits
Show all changes
35 commits
Select commit Hold shift + click to select a range
eb83e8b
Initial plan
Copilot Feb 12, 2026
298dc92
Implement multi-domain support with alias zones
Copilot Feb 12, 2026
78a5234
Improve variable naming clarity in DNS handler
Copilot Feb 12, 2026
62a8651
Update documentation and Docker support for multi-domain feature
Copilot Feb 12, 2026
033d2d0
Simplify dns_server_udp_handler._update_response()
indisoluble Feb 12, 2026
87dfc11
dns_server_config_factory.make_config(): ensure
indisoluble Feb 12, 2026
527e6d4
DnsServerUdpHandler: return NOERROR for missing
indisoluble Feb 15, 2026
ee751cc
dns_server_config_factory._make_alias_zones(): simplify validation logic
indisoluble Feb 15, 2026
d8748cd
dns_server_config_factory.make_config(): simplify validation logic
indisoluble Feb 15, 2026
60a2be9
dns_server_config_factory.make_config(): simplify validation logic
indisoluble Feb 15, 2026
cc1024e
Set default value for alias zones to an empty list
indisoluble Feb 15, 2026
0f8b791
Set default value for alias zones to an empty list
indisoluble Feb 15, 2026
b6b4569
Define class ZoneOrigins
indisoluble Feb 15, 2026
d8f52d0
Integrate ZoneOrigins class into DNS config and handler
Copilot Feb 15, 2026
1216b0f
Clean up config factory: remove unused function and redundant validation
Copilot Feb 16, 2026
7883209
Simplify _make_zone_origins: remove unnecessary logging and restructu…
Copilot Feb 16, 2026
e45432b
Clean up config factory: remove unused function
indisoluble Feb 16, 2026
ab18993
Clean up config factory: remove unused function
indisoluble Feb 16, 2026
7d4a2dd
Remove unnecessary comments/texts
indisoluble Feb 16, 2026
30677c1
Dockerfile: small adjustments to the entrypoint
indisoluble Feb 16, 2026
b0762e5
README: small reformat and reduce length
indisoluble Feb 16, 2026
6a42800
Add __eq__, __hash__, __repr__ to ZoneOrigins and refactor test fixtures
Copilot Feb 17, 2026
ac6aa93
Reformat ZoneOrigins
indisoluble Feb 17, 2026
3bc0824
Simplify tests
indisoluble Feb 17, 2026
5235d2c
Simplify tests for main
indisoluble Feb 18, 2026
2d88566
Simplify tests for main
indisoluble Feb 18, 2026
c96b11f
Extend test for DNS server config factory
indisoluble Feb 20, 2026
817699f
Extend coverage for DnsServerUdpHandler
indisoluble Feb 21, 2026
1f8633f
Extend test cases for ZoneOrigins
indisoluble Feb 21, 2026
5992d77
Validate more consistently the configured provided
indisoluble Feb 21, 2026
e724bf9
Increase version to 0.1.26 and update cryptography
indisoluble Feb 21, 2026
baa4e2b
Update docker-compose.example.yml with new params
indisoluble Feb 21, 2026
d992207
Extend test-docker to cover multi-domain support
indisoluble Feb 21, 2026
6de9c08
Extend test-docker to cover multi-domain support
indisoluble Feb 21, 2026
0b9d4fc
Extend test-docker to cover multi-domain support
indisoluble Feb 21, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions Dockerfile
Original file line number Diff line number Diff line change
Expand Up @@ -70,6 +70,7 @@ ENV DNS_HOSTED_ZONE="" \
DNS_LOG_LEVEL="info" \
DNS_TEST_MIN_INTERVAL="30" \
DNS_TEST_TIMEOUT="2" \
DNS_ALIAS_ZONES="" \
DNS_PRIV_KEY_PATH="" \
DNS_PRIV_KEY_ALG="RSASHA256"

Expand Down Expand Up @@ -101,6 +102,9 @@ ENTRYPOINT ["tini", "--", "sh", "-c", "\
ARGS=\"$ARGS --log-level $DNS_LOG_LEVEL\"; \
ARGS=\"$ARGS --test-min-interval $DNS_TEST_MIN_INTERVAL\"; \
ARGS=\"$ARGS --test-timeout $DNS_TEST_TIMEOUT\"; \
if [ -n \"$DNS_ALIAS_ZONES\" ]; then \
ARGS=\"$ARGS --alias-zones $DNS_ALIAS_ZONES\"; \
fi; \
if [ -n \"$DNS_PRIV_KEY_PATH\" ]; then \
ARGS=\"$ARGS --priv-key-path $DNS_PRIV_KEY_PATH\"; \
fi; \
Expand Down
26 changes: 26 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -120,12 +121,36 @@ 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, optional)

#### DNSSEC Parameters

- `--priv-key-path`: Path to the DNSSEC private key file for zone signing
- `--priv-key-alg`: Algorithm used for DNSSEC signing (default: RSASHA256)

### 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)

**Key Benefits:**
- All domains share the same A records and health checks
- No duplication of health check workload
- DNS responses preserve the original query name (clients see their requested domain)
- Unknown domains are correctly rejected with NXDOMAIN

### Zone Resolution Configuration

The `--zone-resolutions` parameter accepts a JSON object with the following structure:
Expand Down Expand Up @@ -232,6 +257,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)
- `DNS_PRIV_KEY_PATH`: Path to DNSSEC private key PEM file
- `DNS_PRIV_KEY_ALG`: DNSSEC private key algorithm (default: RSASHA256)

Expand Down
114 changes: 104 additions & 10 deletions indisoluble/a_healthy_dns/dns_server_config_factory.py
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@

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


Expand All @@ -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"
Expand Down Expand Up @@ -129,7 +131,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

Expand Down Expand Up @@ -167,6 +169,98 @@ def _make_name_servers(args: Dict[str, Any]) -> Optional[FrozenSet[str]]:
return frozenset(abs_name_servers)


def _make_zone_origins(args: Dict[str, Any]) -> Optional[ZoneOrigins]:
"""Create ZoneOrigins from command-line arguments."""
hosted_zone = args[ARG_HOSTED_ZONE]

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

# Check for subdomain conflicts between alias zones
validated_aliases = []
for zone in alias_zones:
success, error = is_valid_subdomain(zone)
if not success:
logging.error("Alias zone '%s' is not a valid FQDN: %s", zone, error)
return None

alias_zone_name = dns.name.from_text(zone, origin=dns.name.root)
has_subdomain_conflict = any(
alias_zone_name != existing_zone_name
and (
alias_zone_name.is_subdomain(existing_zone_name)
or existing_zone_name.is_subdomain(alias_zone_name)
)
for existing_zone_name in validated_aliases
)
if has_subdomain_conflict:
logging.error(
"Alias zone '%s' has a subdomain relationship with another alias zone",
zone,
)
return None

validated_aliases.append(alias_zone_name)

try:
zone_origins = ZoneOrigins(hosted_zone, alias_zones)
if alias_zones:
logging.info("Configured %d alias zone(s)", len(alias_zones))
return zone_origins
except ValueError as ex:
logging.error("Failed to create zone origins: %s", ex)
return None


def _make_alias_zones(args: Dict[str, Any]) -> Optional[FrozenSet[dns.name.Name]]:
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

valid_alias_zones = []
for zone in alias_zones:
success, error = is_valid_subdomain(zone)
if not success:
logging.error("Alias zone '%s' is not a valid FQDN: %s", zone, error)
return None

alias_zone_name = dns.name.from_text(zone, origin=dns.name.root)
has_subdomain_conflict = any(
alias_zone_name != existing_zone_name
and (
alias_zone_name.is_subdomain(existing_zone_name)
or existing_zone_name.is_subdomain(alias_zone_name)
)
for existing_zone_name in valid_alias_zones
)
if has_subdomain_conflict:
logging.error(
"Alias zone '%s' has a subdomain relationship with another alias zone",
zone,
)
return None

valid_alias_zones.append(alias_zone_name)

if valid_alias_zones:
logging.info("Configured %d alias zone(s)", len(valid_alias_zones))

return frozenset(valid_alias_zones)


def _load_dnssec_private_key(key_path: str) -> Optional[bytes]:
try:
with open(key_path, "rb") as key_file:
Expand All @@ -180,7 +274,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:
Expand All @@ -197,26 +291,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,
Expand Down
33 changes: 23 additions & 10 deletions indisoluble/a_healthy_dns/dns_server_udp_handler.py
Original file line number Diff line number Diff line change
Expand Up @@ -18,35 +18,42 @@
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

# Use the original query name in the response, not the normalized one
rrset = dns.rrset.RRset(query_name, rdataset.rdclass, rdataset.rdtype)
rrset.ttl = rdataset.ttl
for rdata in rdataset:
Expand Down Expand Up @@ -74,7 +81,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)
Expand Down
4 changes: 2 additions & 2 deletions indisoluble/a_healthy_dns/dns_server_zone_updater.py
Original file line number Diff line number Diff line change
Expand Up @@ -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(
Expand All @@ -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):
Expand Down
17 changes: 17 additions & 0 deletions indisoluble/a_healthy_dns/main.py
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
#!/usr/bin/env python3

import argparse
import json
import logging
import signal
import socketserver
Expand All @@ -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,
Expand All @@ -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"
Expand All @@ -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
Expand All @@ -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 without duplicating health checks.
--{_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}
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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
Expand Down
Loading