Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
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
139 changes: 139 additions & 0 deletions api/desecapi/management/commands/notify-parent.py
Original file line number Diff line number Diff line change
@@ -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]
8 changes: 1 addition & 7 deletions api/desecapi/pdns.py
Original file line number Diff line number Diff line change
@@ -1,7 +1,5 @@
import json
import re
import socket
from functools import cache
from hashlib import sha1

import requests
Expand All @@ -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
Expand Down Expand Up @@ -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
):
Expand Down
7 changes: 7 additions & 0 deletions api/desecapi/utils.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
import socket
from functools import cache


@cache
def gethostbyname_cached(host):
return socket.gethostbyname(host)
2 changes: 1 addition & 1 deletion api/requirements.txt
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
26 changes: 26 additions & 0 deletions docker-compose.yml
Original file line number Diff line number Diff line change
Expand Up @@ -131,6 +131,7 @@ services:
- nsmaster
- celery-email
- memcached
- resolver
tmpfs:
- /var/local/django_metrics:size=500m
environment:
Expand Down Expand Up @@ -163,6 +164,7 @@ services:
rearapi_dbapi:
rearapi_ns:
ipv4_address: ${DESECSTACK_IPV4_REAR_PREFIX16}.1.10
rearapi_resolver:
rearwww:
rearmonitoring_api:
logging:
Expand Down Expand Up @@ -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:
Expand Down Expand Up @@ -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:
Expand Down
11 changes: 11 additions & 0 deletions resolver/Dockerfile
Original file line number Diff line number Diff line change
@@ -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/
19 changes: 19 additions & 0 deletions resolver/conf/resolver.conf.var
Original file line number Diff line number Diff line change
@@ -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
3 changes: 3 additions & 0 deletions resolver/entrypoint.sh
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
#!/bin/bash
envsubst < /etc/unbound/unbound.conf.d/resolver.conf.var > /etc/unbound/unbound.conf.d/resolver.conf
exec unbound