diff --git a/api/desecapi/management/commands/notify-parent.py b/api/desecapi/management/commands/notify-parent.py new file mode 100644 index 000000000..26618767d --- /dev/null +++ b/api/desecapi/management/commands/notify-parent.py @@ -0,0 +1,139 @@ +from django.core.management import BaseCommand, CommandError +import dns.resolver + +from desecapi.models import Domain +from desecapi.utils import gethostbyname_cached + + +class Command(BaseCommand): + debug = False + help = "Notify parent to update the DS RRset." + report_agent = dns.name.from_text( # Must be below one parent-side NS + # TODO Make a Domain property? + "notify-agent.ns.desec.cz." + ) + resolver: dns.resolver.Resolver + + def add_arguments(self, parser): + parser.add_argument( + "domain-name", + nargs="*", + help="Domain name to notify for. If omitted, notify for all domains known locally.", + ) + + def handle(self, *args, **options): + domains = Domain.objects.all() + self.debug = options.get("verbosity", 1) > 1 + + if options["domain-name"]: + domains = domains.filter(name__in=options["domain-name"]) + domain_names = domains.values_list("name", flat=True) + + for domain_name in options["domain-name"]: + if domain_name not in domain_names: + raise CommandError("{} is not a known domain".format(domain_name)) + + self.resolver = dns.resolver.Resolver(configure=False) + self.resolver.nameservers = [gethostbyname_cached("resolver")] + self.resolver.flags = dns.flags.RD | dns.flags.AD + + for domain in domains: + self.stdout.write("%s ... " % domain.name, ending="") + domain_name = dns.name.from_text(domain.name) + try: + answer = self._get_dsync(domain_name) + except dns.exception.ValidationFailure as e: + print(f"failed: {e}") + continue + except Exception as e: + print("failed") + msg = "Error while processing {}: {}".format(domain.name, e) + raise CommandError(msg) + + if answer is None: + print("unsupported") + else: + notifies = 0 + targets = 0 + for dsync in answer: + result = self._notify_domain(domain_name, dsync) + try: + result, response = result + except TypeError: # None: DSYNC was not for NOTIFY(SOA) + continue + targets += 1 + notifies += result + if not result and self.debug: + print(response) + print( + f"notified, {notifies}/{targets} NOTIFY(SOA) targets confirmed (from {len(answer)} {answer.qname}/DSYNC total)" + ) + + def _resolve_securely(self, qname, rdtype): + if self.debug: + print(f"resolving {qname}/{rdtype} ...") + try: + answer = self.resolver.resolve(qname, rdtype) + response = answer.response + except dns.resolver.NoAnswer as e: + answer = None + response = e.response() + except dns.resolver.NXDOMAIN as e: + answer = None + response = e.response(qname) + finally: + if not (response.flags & dns.flags.AD): + raise dns.exception.ValidationFailure( + f"unauthenticated response: {qname}/{rdtype}" + ) + return answer, response + + def _notify_domain(self, domain_name, dsync): + # Only process NOTIFY(CDS) + if dsync.scheme != 1 or dsync.rrtype != dns.rdatatype.CDS: + return + + notify = dns.message.make_query(domain_name, dns.rdatatype.CDS) + notify.set_opcode(dns.opcode.NOTIFY) + notify.flags += dns.flags.AA - dns.flags.RD + opt = dns.edns.ReportChannelOption(self.report_agent) + notify.use_edns(edns=True, options=[opt]) + + response = dns.query.udp( + notify, gethostbyname_cached(dsync.target.to_text()), timeout=5 + ) + + notify.flags += dns.flags.QR + # TODO why does this work despite of the EDNS0 option not being in the response? + return notify == response, response + + def _get_dsync(self, domain_name): + # This implements the discovery algorithm from RFC 9859 Section 4.1 + + # Try child-specific (or wildcard), assuming parent one level up + qname = dns.name.Name((domain_name[0], "_dsync", *domain_name[1:])) + answer, response = self._resolve_securely(qname, dns.rdatatype.DSYNC) + if answer: + return answer + + # Find parent + owner_names = [ + rr.name + for rr in response.authority + if rr.rdtype == dns.rdatatype.SOA and rr.rdclass == dns.rdataclass.IN + ] + if len(owner_names) > 1: + ValueError("Negative response has several SOA records") + parent = owner_names[0] + + # Try child-specific (or wildcard), with parent from previous negative response + infix = dns.name.from_text("_dsync").relativize(dns.name.root) + parent_qname = domain_name - parent + infix + parent + if parent_qname != qname: + answer, _ = self._resolve_securely(parent_qname, dns.rdatatype.DSYNC) + if answer: + return answer + + # Try fall-back DSYNC record at _dsync.$parent + qname = infix + parent + return self._resolve_securely(qname, dns.rdatatype.DSYNC)[0] diff --git a/api/desecapi/pdns.py b/api/desecapi/pdns.py index 7508a6a68..df61a02d2 100644 --- a/api/desecapi/pdns.py +++ b/api/desecapi/pdns.py @@ -1,7 +1,5 @@ import json import re -import socket -from functools import cache from hashlib import sha1 import requests @@ -10,6 +8,7 @@ from desecapi import metrics from desecapi.exceptions import PDNSException, RequestEntityTooLarge +from desecapi.utils import gethostbyname_cached SUPPORTED_RRSET_TYPES = { # https://doc.powerdns.com/authoritative/appendices/types.html @@ -84,11 +83,6 @@ } -@cache -def gethostbyname_cached(host): - return socket.gethostbyname(host) - - def _pdns_request( method, *, server, path, data=None, accept="application/json", **kwargs ): diff --git a/api/desecapi/utils.py b/api/desecapi/utils.py new file mode 100644 index 000000000..ff9469dde --- /dev/null +++ b/api/desecapi/utils.py @@ -0,0 +1,7 @@ +import socket +from functools import cache + + +@cache +def gethostbyname_cached(host): + return socket.gethostbyname(host) diff --git a/api/requirements.txt b/api/requirements.txt index 767b22037..09c8dc1b5 100644 --- a/api/requirements.txt +++ b/api/requirements.txt @@ -9,7 +9,7 @@ django-celery-email~=3.0.0 django-netfields~=1.3.2 django-pgtrigger~=4.15.4 django-prometheus~=2.4.1 -dnspython~=2.7.0 +dnspython~=2.8.0 pyotp~=2.9.0 psycopg[binary]~=3.2.10 psl-dns~=1.1.1 diff --git a/docker-compose.yml b/docker-compose.yml index 7b6c6c888..2a3f51902 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -131,6 +131,7 @@ services: - nsmaster - celery-email - memcached + - resolver tmpfs: - /var/local/django_metrics:size=500m environment: @@ -163,6 +164,7 @@ services: rearapi_dbapi: rearapi_ns: ipv4_address: ${DESECSTACK_IPV4_REAR_PREFIX16}.1.10 + rearapi_resolver: rearwww: rearmonitoring_api: logging: @@ -358,6 +360,23 @@ services: tag: "desec/prometheus" restart: unless-stopped + resolver: + build: resolver + image: desec/dedyn-resolver:latest + init: true + cap_add: + - NET_ADMIN + environment: + - DESECSTACK_IPV4_REAR_PREFIX16 + networks: + rearapi_resolver: + ipv4_address: ${DESECSTACK_IPV4_REAR_PREFIX16}.9.2 + logging: + driver: "syslog" + options: + tag: "desec/resolver" + restart: unless-stopped + volumes: dbapi_postgres: dblord_mysql: @@ -401,6 +420,13 @@ networks: config: - subnet: ${DESECSTACK_IPV4_REAR_PREFIX16}.1.0/24 gateway: ${DESECSTACK_IPV4_REAR_PREFIX16}.1.1 + rearapi_resolver: + driver: bridge + ipam: + driver: default + config: + - subnet: ${DESECSTACK_IPV4_REAR_PREFIX16}.9.0/24 + gateway: ${DESECSTACK_IPV4_REAR_PREFIX16}.9.1 rearwww: driver: bridge ipam: diff --git a/resolver/Dockerfile b/resolver/Dockerfile new file mode 100755 index 000000000..7738f79cc --- /dev/null +++ b/resolver/Dockerfile @@ -0,0 +1,11 @@ +FROM ubuntu:noble + +COPY ./entrypoint.sh /root/ +CMD ["/root/entrypoint.sh"] + +RUN apt-get update \ + && apt-get install -y bind9-dnsutils gettext-base unbound \ + && apt-get clean && rm -rf /var/lib/apt/lists/* + +RUN cp /usr/share/dns/root.key /var/lib/unbound/root.key +COPY conf/ /etc/unbound/unbound.conf.d/ diff --git a/resolver/conf/resolver.conf.var b/resolver/conf/resolver.conf.var new file mode 100644 index 000000000..3eedc4b5d --- /dev/null +++ b/resolver/conf/resolver.conf.var @@ -0,0 +1,19 @@ +server: + cache-max-ttl: 600 + ede: yes + interface: 0.0.0.0@53 + access-control: ${DESECSTACK_IPV4_REAR_PREFIX16}.0.0/16 allow + + log-queries: no + log-replies: no + log-servfail: yes + verbosity: 1 + + do-daemonize: no + + neg-cache-size: 4M + qname-minimisation: yes + + deny-any: yes + logfile: /tmp/log + use-syslog: no diff --git a/resolver/entrypoint.sh b/resolver/entrypoint.sh new file mode 100755 index 000000000..0eba8eec0 --- /dev/null +++ b/resolver/entrypoint.sh @@ -0,0 +1,3 @@ +#!/bin/bash +envsubst < /etc/unbound/unbound.conf.d/resolver.conf.var > /etc/unbound/unbound.conf.d/resolver.conf +exec unbound