-
-
Notifications
You must be signed in to change notification settings - Fork 67
Adds delegation checker #1174
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Open
nils-wisiol
wants to merge
7
commits into
main
Choose a base branch
from
20260110_delegation_checker
base: main
Could not load branches
Branch not found: {{ refName }}
Loading
Could not load tags
Nothing to show
Loading
Are you sure you want to change the base?
Some commits from the old base branch may be removed from the timeline,
and old review comments may become outdated.
Open
Adds delegation checker #1174
Changes from all commits
Commits
Show all changes
7 commits
Select commit
Hold shift + click to select a range
f5434b3
chore(webapp): migration to vuetify 3
nils-wisiol fd4d940
feat(api,webapp): auto check domain status, limit insecure domains
nils-wisiol 655652c
feat(api,webapp): allow triggering delegation check for domains
nils-wisiol 72d2cc3
fixup: fix table headers after migration
nils-wisiol 58599ff
fixup! feat(api,webapp): allow triggering delegation check for domains
nils-wisiol d11246d
feat(): add resolver service
peterthomassen b553f0e
Use stack resolver instead of public one
nils-wisiol File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Some comments aren't visible on the classic Files Changed page.
There are no files selected for viewing
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -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 | ||
|
Contributor
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. should stay 15 |
||
| LIMIT_USER_INSECURE_DOMAIN_COUNT_DEFAULT = None | ||
| DELEGATION_SECURE_RECHECK_INTERVAL = None | ||
|
|
||
| PCH_API = "http://api.invalid" | ||
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| 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 |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| 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 |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| 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) |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| 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
21
api/desecapi/migrations/0046_user_limit_insecure_domains.py
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| 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, | ||
| ), | ||
| ), | ||
| ] |
Oops, something went wrong.
Oops, something went wrong.
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
what's the purpose of this? In crontab, we set it to every half hour
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
This determines if a domain is skipped by the CLI command or not, not how often the command is run