Skip to content
Open
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
34 changes: 31 additions & 3 deletions api/api/settings.py
Original file line number Diff line number Diff line change
Expand Up @@ -118,6 +118,9 @@
"100/h",
"300/d",
], # DNS API requests affecting RRset(s) of a single domain
"delegation_check": [
"10/h",
], # Manual delegation check per domain
# UserRateThrottle
"user": "2000/d", # hard limit on requests by a) an authenticated user, b) an unauthenticated IP address
},
Expand Down Expand Up @@ -170,6 +173,9 @@
PSL_RESOLVER = os.environ.get("DESECSTACK_API_PSL_RESOLVER")
LOCAL_PUBLIC_SUFFIXES = {"dedyn.%s" % os.environ["DESECSTACK_DOMAIN"]}

# Delegation checker resolver
DELEGATION_RESOLVER = os.environ.get("DESECSTACK_API_DELEGATION_RESOLVER", "resolver")

# PowerDNS-related
NSLORD_PDNS_API = "http://nslord:8081/api/v1/servers/localhost"
NSLORD_PDNS_API_TOKEN = os.environ["DESECSTACK_NSLORD_APIKEY"]
Expand Down Expand Up @@ -210,14 +216,35 @@
MINIMUM_TTL_DEFAULT = int(os.environ["DESECSTACK_MINIMUM_TTL_DEFAULT"])
MAXIMUM_TTL = 86400
AUTH_USER_MODEL = "desecapi.User"
LIMIT_USER_DOMAIN_COUNT_DEFAULT = int(
os.environ.get("DESECSTACK_API_LIMIT_USER_DOMAIN_COUNT_DEFAULT", "1")
_limit_domains_raw = os.environ.get(
"DESECSTACK_API_LIMIT_USER_DOMAIN_COUNT_DEFAULT", "none"
).lower()
LIMIT_USER_DOMAIN_COUNT_DEFAULT = (
None
if _limit_domains_raw in {"none", "null", "unlimited", "inf"}
else int(_limit_domains_raw)
)
_limit_insecure_raw = os.environ.get(
"DESECSTACK_API_LIMIT_USER_INSECURE_DOMAIN_COUNT_DEFAULT", "none"
).lower()
LIMIT_USER_INSECURE_DOMAIN_COUNT_DEFAULT = (
None
if _limit_insecure_raw in {"none", "null", "unlimited", "inf"}
else int(_limit_insecure_raw)
)
USER_ACTIVATION_REQUIRED = True
VALIDITY_PERIOD_VERIFICATION_SIGNATURE = timedelta(
hours=int(os.environ.get("DESECSTACK_API_AUTHACTION_VALIDITY", "0"))
)
REGISTER_LPS = bool(int(os.environ.get("DESECSTACK_API_REGISTER_LPS", "1")))
_delegation_recheck_raw = os.environ.get(
"DESECSTACK_API_DELEGATION_SECURE_RECHECK_HOURS", "24"
).lower()
DELEGATION_SECURE_RECHECK_INTERVAL = (
None
if _delegation_recheck_raw in {"none", "null", "off", "disabled"}
else timedelta(hours=int(_delegation_recheck_raw))
)

# CAPTCHA
CAPTCHA_VALIDITY_PERIOD = timedelta(hours=24)
Expand Down Expand Up @@ -248,7 +275,8 @@

if os.environ.get("DESECSTACK_E2E_TEST", "").upper() == "TRUE":
DEBUG = True
LIMIT_USER_DOMAIN_COUNT_DEFAULT = 5000
LIMIT_USER_DOMAIN_COUNT_DEFAULT = None
LIMIT_USER_INSECURE_DOMAIN_COUNT_DEFAULT = None
USER_ACTIVATION_REQUIRED = False
EMAIL_BACKEND = "django.core.mail.backends.dummy.EmailBackend"
REST_FRAMEWORK["DEFAULT_THROTTLE_CLASSES"] = []
4 changes: 3 additions & 1 deletion api/api/settings_quick_test.py
Original file line number Diff line number Diff line change
Expand Up @@ -36,6 +36,8 @@
# Carry email backend connection over to test mail outbox
CELERY_EMAIL_MESSAGE_EXTRA_ATTRIBUTES = ["connection"]

LIMIT_USER_DOMAIN_COUNT_DEFAULT = 15
LIMIT_USER_DOMAIN_COUNT_DEFAULT = None
LIMIT_USER_INSECURE_DOMAIN_COUNT_DEFAULT = None
DELEGATION_SECURE_RECHECK_INTERVAL = None

PCH_API = "http://api.invalid"
1 change: 1 addition & 0 deletions api/cronhook/crontab
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
*/5 * * * * /usr/local/bin/python3 -u /usr/src/app/manage.py chores >> /var/log/cron.log 2>&1
*/30 * * * * /usr/local/bin/python3 -u /usr/src/app/manage.py check-delegation >> /var/log/cron.log 2>&1
*/15 * * * * /usr/local/bin/python3 -u /usr/src/app/manage.py check-secondaries >> /var/log/cron.log 2>&1
7 11 * * * /usr/local/bin/python3 -u /usr/src/app/manage.py scavenge-unused >> /var/log/cron.log 2>&1
122 changes: 122 additions & 0 deletions api/desecapi/delegation.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,122 @@
from functools import cache
from socket import getaddrinfo

from django.conf import settings
from django.utils import timezone
import dns.exception, dns.flags, dns.message, dns.name, dns.query, dns.resolver


SERVER = settings.DELEGATION_RESOLVER
DNS_TIMEOUT = 5


@cache
def lookup(target):
try:
addrinfo = getaddrinfo(str(target), None)
except OSError:
addrinfo = []
return {v[-1][0] for v in addrinfo}


class DelegationChecker:
def __init__(self, udp_retries=2, server=SERVER):
self.udp_retries = udp_retries
self.server = server
self.our_ns_set = {dns.name.from_text(ns) for ns in settings.DEFAULT_NS}
self.our_ip_set = set.union(*(lookup(ns) for ns in self.our_ns_set))

def query_with_fallback(self, query):
if self.udp_retries <= 0:
return dns.query.tcp(query, self.server, timeout=DNS_TIMEOUT)
last_error = None
for _ in range(self.udp_retries):
try:
return dns.query.udp(query, self.server, timeout=DNS_TIMEOUT)
except Exception as ex:
last_error = ex
return dns.query.tcp(query, self.server, timeout=DNS_TIMEOUT)

def resolve_with_fallback(self, resolver, name, rdtype):
if self.udp_retries <= 0:
return resolver.resolve(name, rdtype, tcp=True)
last_error = None
for _ in range(self.udp_retries):
try:
return resolver.resolve(name, rdtype, tcp=False)
except Exception as ex:
last_error = ex
return resolver.resolve(name, rdtype, tcp=True)

def check_domain(self, domain):
# Identify parent
now = timezone.now()
domain_name = dns.name.from_text(domain.name)
parent = domain_name.parent()
resolver = dns.resolver.Resolver()
while len(parent):
query = dns.message.make_query(parent, dns.rdatatype.NS)
res = self.query_with_fallback(query)
if res.answer:
break
parent = parent.parent()

# Find delegation NS hostnames and IP addresses
try:
ns = res.find_rrset(res.answer, parent, dns.rdataclass.IN, dns.rdatatype.NS)
except KeyError:
raise dns.resolver.NoNameservers
ipv4 = set()
ipv6 = set()
for rr in ns:
ipv4 |= {ip for ip in lookup(rr.target) if "." in ip}
ipv6 |= {ip for ip in lookup(rr.target) if "." not in ip}

resolver.nameserver = list(ipv4) + list(ipv6)
try:
answer = self.resolve_with_fallback(resolver, domain_name, dns.rdatatype.NS)
except (dns.resolver.NoAnswer, dns.resolver.NXDOMAIN):
return {
"id": domain.id,
"delegation_checked": now,
"is_registered": False,
"has_all_nameservers": None,
"is_delegated": None,
"is_secured": None,
}
update = {
"id": domain.id,
"delegation_checked": now,
"is_registered": True,
}

# Compute overlap of delegation NS hostnames and IP addresses with ours
ns_intersection = self.our_ns_set & {name.target for name in answer}
update["has_all_nameservers"] = ns_intersection == self.our_ns_set

ns_ip_intersection = self.our_ip_set & set.union(
*(lookup(rr.target) for rr in answer)
)
# .is_delegated: None means "not delegated to deSEC", False means "partial", True means "fully"
if not ns_ip_intersection:
update["is_delegated"] = None
else:
update["is_delegated"] = ns_ip_intersection == self.our_ip_set

# Find delegation DS records and check validator-authenticated result
if ns_ip_intersection:
query = dns.message.make_query(domain_name, dns.rdatatype.DS)
res = self.query_with_fallback(query)
try:
res.find_rrset(
res.answer, domain_name, dns.rdataclass.IN, dns.rdatatype.DS
)
has_ds = True
except KeyError:
has_ds = False
# AD bit indicates the resolver validated the DS answer.
authenticated = bool(res.flags & dns.flags.AD)
update["is_secured"] = bool(has_ds and authenticated)
else:
update["is_secured"] = None
return update
129 changes: 129 additions & 0 deletions api/desecapi/management/commands/check-delegation.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,129 @@
from concurrent.futures import ThreadPoolExecutor, as_completed
import time

from django.conf import settings
from django.core.cache import cache as django_cache
from django.core.management import BaseCommand, CommandError
from django.db.models import Q
import dns.exception, dns.resolver

from desecapi.delegation import DelegationChecker
from desecapi.models import Domain


LOCK_KEY = "desecapi.check-delegation.lock"
LOCK_TTL = 60 * 60
SAVE_BATCH_SIZE = 500
MAX_RUN_SECONDS = 60 * 60


class Command(BaseCommand):
help = "Check delegation status."

def __init__(self, *args, **kwargs):
self.checker = DelegationChecker()
super().__init__(*args, **kwargs)

def add_arguments(self, parser):
parser.add_argument(
"domain-name",
nargs="*",
help="Domain name to check. If omitted, will check all domains not registered under a local public suffix.",
)
parser.add_argument(
"--udp-retries",
type=int,
default=2,
help="Number of UDP retries before falling back to TCP. Set to 0 to disable UDP.",
)
parser.add_argument(
"--threads",
type=int,
default=20,
help="Number of worker threads to use.",
)

def run_check(self, options):
self.checker.udp_retries = options["udp_retries"]
threads = options["threads"]
qs = Domain.objects
if options["domain-name"]:
qs = qs.filter(
name__in=[name.rstrip(".") for name in options["domain-name"]]
)
if settings.DELEGATION_SECURE_RECHECK_INTERVAL is not None:
cutoff = timezone.now() - settings.DELEGATION_SECURE_RECHECK_INTERVAL
qs = qs.exclude(Q(is_secured=True) & Q(delegation_checked__gte=cutoff))
domains = [domain for domain in qs.all() if not domain.is_locally_registrable]

def worker(domain):
try:
update = self.checker.check_domain(domain)
except (dns.exception.Timeout, dns.resolver.LifetimeTimeout):
return ("timeout", domain, None)
except dns.resolver.NoNameservers:
return ("unresponsive", domain, None)
return ("ok", domain, update)

if threads <= 1:
results = map(worker, domains)
else:
executor = ThreadPoolExecutor(max_workers=threads)
futures = [executor.submit(worker, domain) for domain in domains]
results = (future.result() for future in as_completed(futures))

updates = []
for status, domain, update in results:
if status == "timeout":
print(f"{domain.name} Timeout")
continue
if status == "unresponsive":
print(f"{domain.name} Unresponsive")
continue
updates.append(update)
if update["is_registered"] and update["is_delegated"] is not None:
print(
f"{domain.owner.email} {domain.name} {update['has_all_nameservers']=} {update['is_secured']=}"
)
else:
print(
f"{domain.owner.email} {domain.name} {update['is_registered']=} delegated=False"
)
if not updates:
return
for i in range(0, len(updates), SAVE_BATCH_SIZE):
batch = updates[i : i + SAVE_BATCH_SIZE]
objs = []
for update in batch:
domain = Domain(id=update["id"])
domain.delegation_checked = update["delegation_checked"]
domain.is_registered = update["is_registered"]
domain.has_all_nameservers = update["has_all_nameservers"]
domain.is_delegated = update["is_delegated"]
domain.is_secured = update["is_secured"]
objs.append(domain)
Domain.objects.bulk_update(
objs,
[
"delegation_checked",
"is_registered",
"has_all_nameservers",
"is_delegated",
"is_secured",
],
)

def handle(self, *args, **options):
lock_acquired = django_cache.add(LOCK_KEY, "1", timeout=LOCK_TTL)
if not lock_acquired:
raise CommandError("check-delegation is already running.")
try:
start = time.monotonic()
self.run_check(options)
elapsed = time.monotonic() - start
self.stdout.write(f"check-delegation runtime: {elapsed:.2f}s")
if elapsed > MAX_RUN_SECONDS:
raise CommandError("check-delegation exceeded maximum runtime.")
finally:
if lock_acquired:
django_cache.delete(LOCK_KEY)
35 changes: 35 additions & 0 deletions api/desecapi/migrations/0045_domain_delegation_status.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,35 @@
from django.db import migrations, models


class Migration(migrations.Migration):
dependencies = [
("desecapi", "0044_alter_captcha_created_alter_domain_renewal_state_and_more"),
]

operations = [
migrations.AddField(
model_name="domain",
name="delegation_checked",
field=models.DateTimeField(blank=True, null=True),
),
migrations.AddField(
model_name="domain",
name="has_all_nameservers",
field=models.BooleanField(blank=True, null=True),
),
migrations.AddField(
model_name="domain",
name="is_delegated",
field=models.BooleanField(blank=True, null=True),
),
migrations.AddField(
model_name="domain",
name="is_registered",
field=models.BooleanField(blank=True, null=True),
),
migrations.AddField(
model_name="domain",
name="is_secured",
field=models.BooleanField(blank=True, null=True),
),
]
21 changes: 21 additions & 0 deletions api/desecapi/migrations/0046_user_limit_insecure_domains.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
from django.db import migrations, models

import desecapi.models.users


class Migration(migrations.Migration):
dependencies = [
("desecapi", "0045_domain_delegation_status"),
]

operations = [
migrations.AddField(
model_name="user",
name="limit_insecure_domains",
field=models.PositiveIntegerField(
blank=True,
default=desecapi.models.users.User._limit_insecure_domains_default,
null=True,
),
),
]
Loading
Loading