diff --git a/api/api/settings.py b/api/api/settings.py index d69ddb606..e517b1a39 100644 --- a/api/api/settings.py +++ b/api/api/settings.py @@ -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 }, @@ -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"] @@ -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) @@ -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"] = [] diff --git a/api/api/settings_quick_test.py b/api/api/settings_quick_test.py index 513073b30..5f8436632 100644 --- a/api/api/settings_quick_test.py +++ b/api/api/settings_quick_test.py @@ -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" diff --git a/api/cronhook/crontab b/api/cronhook/crontab index acdd86679..013a14f9d 100644 --- a/api/cronhook/crontab +++ b/api/cronhook/crontab @@ -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 diff --git a/api/desecapi/delegation.py b/api/desecapi/delegation.py new file mode 100644 index 000000000..78434fa27 --- /dev/null +++ b/api/desecapi/delegation.py @@ -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 diff --git a/api/desecapi/management/commands/check-delegation.py b/api/desecapi/management/commands/check-delegation.py new file mode 100644 index 000000000..e48e4eecf --- /dev/null +++ b/api/desecapi/management/commands/check-delegation.py @@ -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) diff --git a/api/desecapi/migrations/0045_domain_delegation_status.py b/api/desecapi/migrations/0045_domain_delegation_status.py new file mode 100644 index 000000000..4ac12c593 --- /dev/null +++ b/api/desecapi/migrations/0045_domain_delegation_status.py @@ -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), + ), + ] diff --git a/api/desecapi/migrations/0046_user_limit_insecure_domains.py b/api/desecapi/migrations/0046_user_limit_insecure_domains.py new file mode 100644 index 000000000..a538b3687 --- /dev/null +++ b/api/desecapi/migrations/0046_user_limit_insecure_domains.py @@ -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, + ), + ), + ] diff --git a/api/desecapi/models/domains.py b/api/desecapi/models/domains.py index cd4f5c488..cb0447a8f 100644 --- a/api/desecapi/models/domains.py +++ b/api/desecapi/models/domains.py @@ -63,6 +63,11 @@ class RenewalState(models.IntegerChoices): choices=RenewalState.choices, db_index=True, default=RenewalState.IMMORTAL ) renewal_changed = models.DateTimeField(auto_now_add=True) + delegation_checked = models.DateTimeField(null=True, blank=True) + is_registered = models.BooleanField(null=True, blank=True) + has_all_nameservers = models.BooleanField(null=True, blank=True) + is_delegated = models.BooleanField(null=True, blank=True) + is_secured = models.BooleanField(null=True, blank=True) _keys = None objects = DomainManager() diff --git a/api/desecapi/models/users.py b/api/desecapi/models/users.py index 025d4dac0..b37bc5e0b 100644 --- a/api/desecapi/models/users.py +++ b/api/desecapi/models/users.py @@ -33,6 +33,10 @@ class User(ExportModelOperationsMixin("User"), AbstractBaseUser): def _limit_domains_default(): return settings.LIMIT_USER_DOMAIN_COUNT_DEFAULT + @staticmethod + def _limit_insecure_domains_default(): + return settings.LIMIT_USER_INSECURE_DOMAIN_COUNT_DEFAULT + id = models.UUIDField(primary_key=True, default=uuid.uuid4, editable=False) email = models.EmailField( verbose_name="email address", @@ -47,6 +51,9 @@ def _limit_domains_default(): limit_domains = models.PositiveIntegerField( default=_limit_domains_default.__func__, null=True, blank=True ) + limit_insecure_domains = models.PositiveIntegerField( + default=_limit_insecure_domains_default.__func__, null=True, blank=True + ) needs_captcha = models.BooleanField(default=True) outreach_preference = models.BooleanField(default=True) throttle_daily_rate = models.PositiveIntegerField(null=True) diff --git a/api/desecapi/permissions.py b/api/desecapi/permissions.py index 4d80c888b..fe58c1391 100644 --- a/api/desecapi/permissions.py +++ b/api/desecapi/permissions.py @@ -1,5 +1,6 @@ from ipaddress import IPv4Address, IPv4Network +from django.conf import settings from rest_framework import permissions from desecapi.models import RRset @@ -175,3 +176,24 @@ def has_permission(self, request, view): request.user.limit_domains is None or request.user.domains.count() < request.user.limit_domains ) + + +class WithinInsecureDelegatedDomainLimit(permissions.BasePermission): + """ + Permission that limits the number of domains delegated without DNSSEC. + """ + + message = ( + "Insecure delegation limit exceeded. Please secure an existing domain with DNSSEC before creating more domains." + ) + + def has_permission(self, request, view): + limit = request.user.limit_insecure_domains + if limit is None: + return True + if limit == 0: + return False + insecure_count = request.user.domains.filter( + is_registered=True, is_delegated=True + ).exclude(is_secured=True).count() + return insecure_count < limit diff --git a/api/desecapi/serializers/domains.py b/api/desecapi/serializers/domains.py index 49c518fc2..5638ce93f 100644 --- a/api/desecapi/serializers/domains.py +++ b/api/desecapi/serializers/domains.py @@ -1,6 +1,7 @@ import dns.name import dns.zone from django.conf import settings +from django.utils import timezone from rest_framework import serializers from desecapi.models import Domain, RR_SET_TYPES_AUTOMATIC @@ -20,6 +21,11 @@ class Meta: model = Domain fields = ( "created", + "delegation_checked", + "has_all_nameservers", + "is_delegated", + "is_registered", + "is_secured", "published", "name", "keys", @@ -28,6 +34,11 @@ class Meta: "zonefile", ) read_only_fields = ( + "delegation_checked", + "has_all_nameservers", + "is_delegated", + "is_registered", + "is_secured", "published", "minimum_ttl", ) @@ -100,6 +111,21 @@ def validate(self, attrs): def create(self, validated_data): # save domain domain: Domain = super().create(validated_data) + if domain.is_locally_registrable: + domain.delegation_checked = timezone.now() + domain.is_registered = True + domain.has_all_nameservers = True + domain.is_delegated = True + domain.is_secured = True + domain.save( + update_fields=[ + "delegation_checked", + "is_registered", + "has_all_nameservers", + "is_delegated", + "is_secured", + ] + ) # save RRsets if zonefile was given nodes = getattr(self.import_zone, "nodes", None) diff --git a/api/desecapi/serializers/users.py b/api/desecapi/serializers/users.py index 05ebb8d00..88e7ba2ad 100644 --- a/api/desecapi/serializers/users.py +++ b/api/desecapi/serializers/users.py @@ -31,6 +31,7 @@ class ResetPasswordSerializer(EmailSerializer): class UserSerializer(serializers.ModelSerializer): domains_under_management = serializers.SerializerMethodField() + insecure_delegated_domains = serializers.SerializerMethodField() class Meta: model = User @@ -40,6 +41,8 @@ class Meta: "email", "id", "limit_domains", + "limit_insecure_domains", + "insecure_delegated_domains", "outreach_preference", ) read_only_fields = ( @@ -48,6 +51,8 @@ class Meta: "email", "id", "limit_domains", + "limit_insecure_domains", + "insecure_delegated_domains", ) def get_domains_under_management(self, obj): @@ -60,6 +65,11 @@ def get_domains_under_management(self, obj): .count() ) + def get_insecure_delegated_domains(self, obj): + return obj.domains.filter(is_registered=True, is_delegated=True).exclude( + is_secured=True + ).count() + def validate_password(self, value): if value is not None: validate_password(value) diff --git a/api/desecapi/tests/test_domains.py b/api/desecapi/tests/test_domains.py index ed54d0a34..46d4e420a 100644 --- a/api/desecapi/tests/test_domains.py +++ b/api/desecapi/tests/test_domains.py @@ -1,9 +1,11 @@ from contextlib import nullcontext +from unittest.mock import Mock, patch from django.conf import settings from django.core import mail from django.core.exceptions import ValidationError from django.test import override_settings +from django.utils import timezone from rest_framework import status from desecapi.models import Domain @@ -232,6 +234,19 @@ def test_name_validity(self): Domain(owner=self.owner, name=name).save() def test_list_domains(self): + self.my_domain.is_registered = True + self.my_domain.is_delegated = True + self.my_domain.has_all_nameservers = True + self.my_domain.is_secured = False + self.my_domain.save( + update_fields=[ + "is_registered", + "is_delegated", + "has_all_nameservers", + "is_secured", + ] + ) + response = self.client.get(self.reverse("v1:domain-list")) self.assertStatus(response, status.HTTP_200_OK) self.assertEqual(len(response.data), self.NUM_OWNED_DOMAINS) @@ -240,6 +255,50 @@ def test_list_domains(self): expected_set = {domain.name for domain in self.my_domains} self.assertEqual(response_set, expected_set) self.assertFalse(any("keys" in data for data in response.data)) + for data in response.data: + for field in ( + "delegation_checked", + "is_registered", + "has_all_nameservers", + "is_delegated", + "is_secured", + ): + self.assertIn(field, data) + + my_domain_data = next( + entry for entry in response.data if entry["name"] == self.my_domain.name + ) + self.assertTrue(my_domain_data["is_registered"]) + self.assertTrue(my_domain_data["is_delegated"]) + self.assertTrue(my_domain_data["has_all_nameservers"]) + self.assertFalse(my_domain_data["is_secured"]) + + def test_delegation_check_endpoint(self): + url = ( + self.reverse("v1:domain-detail", name=self.my_domain.name) + + "delegation-check/" + ) + now = timezone.now() + update = { + "id": self.my_domain.id, + "delegation_checked": now, + "is_registered": True, + "has_all_nameservers": True, + "is_delegated": True, + "is_secured": True, + } + checker = Mock() + checker.check_domain.return_value = update + with patch("desecapi.views.domains.DelegationChecker", return_value=checker): + response = self.client.post(url) + self.assertStatus(response, status.HTTP_200_OK) + self.my_domain.refresh_from_db() + self.assertTrue(self.my_domain.is_registered) + self.assertTrue(self.my_domain.has_all_nameservers) + self.assertTrue(self.my_domain.is_delegated) + self.assertTrue(self.my_domain.is_secured) + self.assertIsNotNone(self.my_domain.delegation_checked) + self.assertEqual(response.data["is_registered"], True) def test_list_domains_owns_qname(self): # Domains outside this account or non-existent @@ -930,17 +989,38 @@ def test_create_auto_delegated_domains(self): self.assertTrue(domain.is_locally_registrable) self.assertEqual(domain.renewal_state, Domain.RenewalState.FRESH) - def test_domain_limit(self): + def test_create_many_local_public_suffix_domains(self): url = self.reverse("v1:domain-list") - user_quota = settings.LIMIT_USER_DOMAIN_COUNT_DEFAULT - self.NUM_OWNED_DOMAINS + self.owner.limit_domains = self.NUM_OWNED_DOMAINS + 5 + self.owner.limit_insecure_domains = 1 + self.owner.save(update_fields=["limit_domains", "limit_insecure_domains"]) - for i in range(user_quota): - name = self.random_domain_name(self.AUTO_DELEGATION_DOMAINS) + suffix = next(iter(self.AUTO_DELEGATION_DOMAINS)) + created = [] + for _ in range(5): + name = self.random_domain_name(suffix) with self.assertRequests( - self.requests_desec_domain_creation_auto_delegation(name) + self.requests_desec_domain_creation_auto_delegation(name=name) ): response = self.client.post(url, {"name": name}) self.assertStatus(response, status.HTTP_201_CREATED) + created.append(name) + + for name in created: + domain = Domain.objects.get(name=name) + self.assertTrue(domain.is_locally_registrable) + self.assertTrue(domain.is_secured) + self.assertIsNotNone(domain.delegation_checked) + + def test_domain_limit(self): + url = self.reverse("v1:domain-list") + self.owner.limit_domains = self.NUM_OWNED_DOMAINS + 1 + self.owner.save(update_fields=["limit_domains"]) + + name = self.random_domain_name(self.AUTO_DELEGATION_DOMAINS) + with self.assertRequests(self.requests_desec_domain_creation_auto_delegation(name)): + response = self.client.post(url, {"name": name}) + self.assertStatus(response, status.HTTP_201_CREATED) response = self.client.post( url, {"name": self.random_domain_name(self.AUTO_DELEGATION_DOMAINS)} @@ -950,6 +1030,40 @@ def test_domain_limit(self): ) self.assertFalse(mail.outbox) # do not send email + def test_insecure_delegation_limit(self): + url = self.reverse("v1:domain-list") + insecure_domain = self.my_domain + insecure_domain.is_registered = True + insecure_domain.is_delegated = True + insecure_domain.is_secured = False + insecure_domain.save( + update_fields=["is_registered", "is_delegated", "is_secured"] + ) + + self.owner.limit_insecure_domains = 1 + self.owner.save(update_fields=["limit_insecure_domains"]) + response = self.client.post( + url, {"name": self.random_domain_name(self.AUTO_DELEGATION_DOMAINS)} + ) + self.assertContains( + response, + "Insecure delegation limit", + status_code=status.HTTP_403_FORBIDDEN, + ) + + def test_insecure_delegation_limit_zero(self): + url = self.reverse("v1:domain-list") + self.owner.limit_insecure_domains = 0 + self.owner.save(update_fields=["limit_insecure_domains"]) + response = self.client.post( + url, {"name": self.random_domain_name(self.AUTO_DELEGATION_DOMAINS)} + ) + self.assertContains( + response, + "Insecure delegation limit", + status_code=status.HTTP_403_FORBIDDEN, + ) + def test_domain_minimum_ttl(self): url = self.reverse("v1:domain-list") name = self.random_domain_name(self.AUTO_DELEGATION_DOMAINS) diff --git a/api/desecapi/tests/test_user_management.py b/api/desecapi/tests/test_user_management.py index c432ee810..caaea1956 100644 --- a/api/desecapi/tests/test_user_management.py +++ b/api/desecapi/tests/test_user_management.py @@ -762,6 +762,7 @@ def test_registration_late_captcha(self): def test_registration_with_override_token(self): limit_domains = 15 + limit_insecure_domains = settings.LIMIT_USER_INSECURE_DOMAIN_COUNT_DEFAULT token = self.create_token(owner=self.create_user(), perm_manage_tokens=True) self.client.credentials(HTTP_AUTHORIZATION="Token " + token.plain) @@ -789,6 +790,7 @@ def test_registration_with_override_token(self): self.assertTrue(user.needs_captcha) self.assertFalse(user.outreach_preference) self.assertEqual(user.limit_domains, limit_domains) + self.assertEqual(user.limit_insecure_domains, limit_insecure_domains) self.assertPassword(email, None) # Check confirmation email @@ -818,6 +820,7 @@ def test_registration_with_override_token(self): user.refresh_from_db() self.assertTrue(user.is_active) self.assertEqual(user.limit_domains, limit_domains) + self.assertEqual(user.limit_insecure_domains, limit_insecure_domains) self.assertFalse(user.needs_captcha) self.assertEqual(user.outreach_preference, outreach_preference) self.assertPassword(email, None) @@ -937,6 +940,8 @@ def test_view_account(self): "email", "id", "limit_domains", + "limit_insecure_domains", + "insecure_delegated_domains", "outreach_preference", }, ) @@ -947,6 +952,11 @@ def test_view_account(self): self.assertEqual( response.data["limit_domains"], settings.LIMIT_USER_DOMAIN_COUNT_DEFAULT ) + self.assertEqual( + response.data["limit_insecure_domains"], + settings.LIMIT_USER_INSECURE_DOMAIN_COUNT_DEFAULT, + ) + self.assertEqual(response.data["insecure_delegated_domains"], 0) self.assertTrue(response.data["outreach_preference"]) def test_view_account_forbidden_methods(self): @@ -965,6 +975,7 @@ def test_view_account_update(self): "email", "id", "limit_domains", + "limit_insecure_domains", "password", ) immutable_values = [getattr(user, key) for key in immutable_fields] @@ -979,6 +990,7 @@ def test_view_account_update(self): "email": "youremailaddress@example.com", "id": "9ab16e5c-805d-4ab1-9030-af3f5a541d47", "limit_domains": 42, + "limit_insecure_domains": 0, "password": self.random_password(), "outreach_preference": outreach_preference, }, diff --git a/api/desecapi/urls/version_1.py b/api/desecapi/urls/version_1.py index 3ff1af835..38f6f9b00 100644 --- a/api/desecapi/urls/version_1.py +++ b/api/desecapi/urls/version_1.py @@ -53,6 +53,11 @@ path("", views.Root.as_view(), name="root"), # Domain and RRSet management path("domains/", include(domains_router.urls)), + path( + "domains//delegation-check/", + views.DomainViewSet.as_view({"post": "delegation_check"}), + name="domain-delegation-check", + ), path("domains//rrsets/", views.RRsetList.as_view(), name="rrsets"), path( "domains//rrsets/...//", diff --git a/api/desecapi/views/domains.py b/api/desecapi/views/domains.py index a535467ee..1fb241db1 100644 --- a/api/desecapi/views/domains.py +++ b/api/desecapi/views/domains.py @@ -12,6 +12,7 @@ from rest_framework.views import APIView from desecapi import permissions +from desecapi.delegation import DelegationChecker from desecapi.models import Domain from desecapi.pdns import get_serials from desecapi.pdns_change_tracker import PDNSChangeTracker @@ -47,14 +48,19 @@ def permission_classes(self): case "create": ret.append(permissions.HasCreateDomainPermission) ret.append(permissions.WithinDomainLimit) + ret.append(permissions.WithinInsecureDelegatedDomainLimit) case "destroy": ret.append(permissions.HasDeleteDomainPermission) + case "delegation_check": + pass case _: raise ValueError(f"Invalid action: {self.action}") return ret @property def throttle_scope(self): + if self.action == "delegation_check": + return "delegation_check" if self.action == "zonefile": self.throttle_scope_bucket = self.kwargs["name"] return "dns_api_per_domain_expensive" @@ -136,6 +142,28 @@ def zonefile(self, request, name=None): prefix = f"; Zonefile for {instance.name} exported from desec.{settings.DESECSTACK_DOMAIN} at {datetime.now(timezone.utc)}\n".encode() return Response(prefix + instance.zonefile, content_type="text/dns") + @action(detail=True, methods=["post"]) + def delegation_check(self, request, name=None): + instance = self.get_object() + checker = DelegationChecker() + update = checker.check_domain(instance) + instance.delegation_checked = update["delegation_checked"] + instance.is_registered = update["is_registered"] + instance.has_all_nameservers = update["has_all_nameservers"] + instance.is_delegated = update["is_delegated"] + instance.is_secured = update["is_secured"] + instance.save( + update_fields=[ + "delegation_checked", + "is_registered", + "has_all_nameservers", + "is_delegated", + "is_secured", + ] + ) + serializer = self.get_serializer(instance) + return Response(serializer.data) + class SerialListView(APIView): permission_classes = (permissions.IsVPNClient,) diff --git a/api/desecapi/views/tokens.py b/api/desecapi/views/tokens.py index f03589337..2fc6179f7 100644 --- a/api/desecapi/views/tokens.py +++ b/api/desecapi/views/tokens.py @@ -64,6 +64,7 @@ def create(self, request, *args, **kwargs): user_override = account_serializer.save( is_active=None if settings.USER_ACTIVATION_REQUIRED else True, limit_domains=15, + limit_insecure_domains=settings.LIMIT_USER_INSECURE_DOMAIN_COUNT_DEFAULT, outreach_preference=False, ) serializers.AuthenticatedActivateUserWithOverrideTokenActionSerializer.build_and_save( diff --git a/docker-compose.yml b/docker-compose.yml index 696142319..8c7244194 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: @@ -403,6 +422,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/docs/auth/account.rst b/docs/auth/account.rst index 718109067..2fa239d3d 100644 --- a/docs/auth/account.rst +++ b/docs/auth/account.rst @@ -239,6 +239,8 @@ A JSON object representing your user account will be returned:: "email": "youremailaddress@example.com", "id": "9ab16e5c-805d-4ab1-9030-af3f5a541d47", "limit_domains": 15, + "limit_insecure_domains": 1, + "insecure_delegated_domains": 0, "outreach_preference": true } @@ -271,6 +273,18 @@ Field details: Maximum number of domains the user can create. +``limit_insecure_domains`` + :Access mode: read-only + + Maximum number of domains that may be delegated to deSEC without DNSSEC. + ``0`` means that creating new domains is not permitted. + ``null`` means unlimited. + +``insecure_delegated_domains`` + :Access mode: read-only + + Number of domains currently delegated without DNSSEC. + ``outreach_preference`` :Access mode: read, write :Type: boolean diff --git a/docs/dns/domains.rst b/docs/dns/domains.rst index e608b144e..4b00d768d 100644 --- a/docs/dns/domains.rst +++ b/docs/dns/domains.rst @@ -20,6 +20,11 @@ A JSON object representing a domain has the following structure:: { "created": "2018-09-18T16:36:16.510368Z", + "delegation_checked": "2018-09-18T17:30:00.000000Z", + "has_all_nameservers": true, + "is_delegated": true, + "is_registered": true, + "is_secured": false, "keys": [ { "dnskey": "257 3 13 WFRl60...", @@ -48,6 +53,38 @@ Field details: Timestamp of domain creation, in ISO 8601 format (e.g. ``2013-01-29T12:34:56.000000Z``). +``delegation_checked`` + :Access mode: read-only + + Timestamp of the last delegation check. If no check has happened yet, this + field is ``null``. + +``has_all_nameservers`` + :Access mode: read-only + + ``true`` if the domain is delegated and all authoritative nameservers at the + parent match deSEC. ``false`` indicates a partial delegation. ``null`` if no + delegation information is available. + +``is_delegated`` + :Access mode: read-only + + ``true`` if the domain is delegated to deSEC, ``false`` if only partially, + ``null`` if delegation does not point to deSEC at all. + +``is_registered`` + :Access mode: read-only + + ``true`` if the domain exists in the public DNS, ``false`` if it is not + visible (yet), ``null`` if no check has been performed. + +``is_secured`` + :Access mode: read-only + + ``true`` if DNSSEC is correctly configured and matches deSEC's keys, + ``false`` if the DS records do not match, ``null`` if no DNSSEC data was + found. + ``keys`` :Access mode: read-only @@ -163,6 +200,14 @@ If you have reached the maximum number of domains for your account, the API responds with ``403 Forbidden``. If you find yourself affected by this limit although you have a legitimate use case, please contact our support. +If you have reached the per-account limit for domains delegated without DNSSEC, +the API responds with ``403 Forbidden`` when creating additional domains. +Secure an existing domain by adding the DS records shown in the UI or API, then +try again. + +If the per-account limit is set to ``0``, domain creation is disabled. +If the limit is ``null``, there is no restriction based on DNSSEC status. + Restrictions on what is a valid domain name apply. In particular, domains listed on the `Public Suffix List`_ such as ``co.uk`` cannot be registered. (If you operate a public suffix and would like to host it with deSEC, that's @@ -197,6 +242,33 @@ Up to 500 items are returned at a time. If you have a larger number of domains configured, the use of :ref:`pagination` is required. +Delegation Status Checks +~~~~~~~~~~~~~~~~~~~~~~~~ + +deSEC periodically checks how your domain is published in the public DNS. +The results are stored in the delegation status fields described above +(``delegation_checked``, ``is_registered``, ``is_delegated``, +``has_all_nameservers``, ``is_secured``). + +The checks use public resolvers to determine: + +- whether the domain exists in the public DNS (registered), +- whether the delegation points to deSEC name servers, +- whether DNSSEC is correctly configured for your domain. + +The API does not change or delete domains based on these checks. However, if +you have at least one domain delegated to deSEC without DNSSEC, you cannot +create additional domains until that domain is secured. + +If you need details about a domain's current status, request the domain via +``GET /api/v1/domains/{name}/`` or list domains via ``GET /api/v1/domains/`` and +inspect the delegation fields. + +To trigger a manual refresh for a single domain, issue a ``POST`` request to +``/api/v1/domains/{name}/delegation-check/``. The response contains the updated +domain object, including the delegation fields. This endpoint is rate-limited. + + Retrieving a Specific Domain ~~~~~~~~~~~~~~~~~~~~~~~~~~~~ diff --git a/docs/rate-limits.rst b/docs/rate-limits.rst index a345ecb21..1ded2b5c3 100644 --- a/docs/rate-limits.rst +++ b/docs/rate-limits.rst @@ -56,6 +56,8 @@ the API. When several rates are given, all are enforced at the same time. | | | | | | 300/day | | +-----------------------------------------+----------+-------------------------------------------------------------------------------------------+ +| ``delegation_check`` | 10/h | Manual delegation check for a specific domain | ++-----------------------------------------+----------+-------------------------------------------------------------------------------------------+ | ``user`` | 2000/day | Any activity of a) authenticated users, b) unauthenticated users (by IP) | +-----------------------------------------+----------+-------------------------------------------------------------------------------------------+ 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 diff --git a/www/webapp/.gitignore b/www/webapp/.gitignore index 46955aa0f..7e07b0286 100644 --- a/www/webapp/.gitignore +++ b/www/webapp/.gitignore @@ -1,5 +1,6 @@ .DS_Store node_modules +.vite /tests/e2e/videos/ /tests/e2e/screenshots/ diff --git a/www/webapp/package.json b/www/webapp/package.json index 11aa2b0d1..5585bc742 100644 --- a/www/webapp/package.json +++ b/www/webapp/package.json @@ -7,7 +7,8 @@ "serve": "vite preview", "build": "vite build", "lint": "eslint --ignore-path .gitignore --no-fix src/**/*.{vue,js,json}", - "lint:fix": "eslint --ignore-path .gitignore --fix src/**/*.{vue,js,json}" + "lint:fix": "eslint --ignore-path .gitignore --fix src/**/*.{vue,js,json}", + "postinstall": "vue-demi-switch 3" }, "type": "module", "engines": { @@ -16,26 +17,30 @@ "dependencies": { "@fontsource/roboto": "^5.0.3", "@mdi/js": "~7.4.47", + "@vuelidate/core": "^2.0.3", + "@vuelidate/validators": "^2.0.4", "axios": "^1.4.0", "date-fns": "^4.1.0", "pinia": "^2.0.30", - "vue": "~2.7.14", - "vue-router": "~3.6.5", - "vuelidate": "^0.7.7", - "vuetify": "^2.7.0" + "vue": "^3.4.19", + "vue-router": "^4.3.0", + "vuetify": "^3.7.5" }, "devDependencies": { "@vitejs/plugin-legacy": "^6.0.0", - "@vitejs/plugin-vue2": "^2.3.3", + "@vitejs/plugin-vue": "^5.2.0", "eslint": "^8.45.0", "eslint-import-resolver-alias": "^1.1.2", "eslint-plugin-import": "^2.27.5", "eslint-plugin-vue": "^9.15.1", "eslint-plugin-vue-scoped-css": "^2.6.1", - "eslint-plugin-vuetify": "^1.1.0", + "eslint-plugin-vuetify": "^2.1.1", "sass": "~1.83.4", - "unplugin-vue-components": "^28.0.0", "vite": "^6.0.11", - "vuetify-loader": "~1.9.1" + "vite-plugin-vuetify": "^2.0.4", + "vue-demi": "^0.14.10" + }, + "overrides": { + "vue-demi": "^0.14.10" } } diff --git a/www/webapp/src/App.vue b/www/webapp/src/App.vue index 4499155f6..e04680950 100644 --- a/www/webapp/src/App.vue +++ b/www/webapp/src/App.vue @@ -1,90 +1,91 @@ @@ -50,6 +49,10 @@ export default { type: Array, default: () => [], }, + modelValue: { + type: [String, Number], + required: false, + }, value: { type: [String, Number], required: false, @@ -59,9 +62,19 @@ export default { required: false, }, }, + computed: { + inputValue() { + return this.modelValue ?? this.value; + }, + }, methods: { - changed(event, e) { - this.$emit(event, e); + updateValue(value) { + this.$emit('update:modelValue', value); + this.$emit('input', value); + this.$emit('dirty'); + }, + handleKeyup(event) { + this.$emit('keyup', event); this.$emit('dirty'); }, }, diff --git a/www/webapp/src/components/Field/RRSetType.vue b/www/webapp/src/components/Field/RRSetType.vue index ed63b1a35..d6f172857 100644 --- a/www/webapp/src/components/Field/RRSetType.vue +++ b/www/webapp/src/components/Field/RRSetType.vue @@ -5,11 +5,11 @@ :error-messages="errorMessages" hint="You can also enter other types. For a full list, check the documentation." :persistent-hint="!readonly" - :value="value" + :model-value="inputValue" :items="types" :required="required" :rules="[v => !required || !!v || 'Required.']" - @input="input($event)" + @update:modelValue="input" /> @@ -37,9 +37,13 @@ export default { type: Boolean, default: false, }, + modelValue: { + type: String, + required: false, + }, value: { type: String, - required: true, + required: false, }, }, data: () => ({ @@ -60,8 +64,14 @@ export default { 'DS', ], }), + computed: { + inputValue() { + return this.modelValue ?? this.value; + }, + }, methods: { input(event) { + this.$emit('update:modelValue', event); this.$emit('input', event); }, }, diff --git a/www/webapp/src/components/Field/RecordA.vue b/www/webapp/src/components/Field/RecordA.vue index 868ccc184..20e8ce2be 100644 --- a/www/webapp/src/components/Field/RecordA.vue +++ b/www/webapp/src/components/Field/RecordA.vue @@ -1,10 +1,11 @@ diff --git a/www/webapp/src/components/ResetPasswordActionHandler.vue b/www/webapp/src/components/ResetPasswordActionHandler.vue index f5b29363a..577b27b4e 100644 --- a/www/webapp/src/components/ResetPasswordActionHandler.vue +++ b/www/webapp/src/components/ResetPasswordActionHandler.vue @@ -3,7 +3,7 @@
h(App) -}).$mount('#app') +app.use(pinia) +app.use(router) +app.use(vuetify) + +app.mount('#app') diff --git a/www/webapp/src/plugins/vuetify.js b/www/webapp/src/plugins/vuetify.js index b616aac30..7f38e5e85 100644 --- a/www/webapp/src/plugins/vuetify.js +++ b/www/webapp/src/plugins/vuetify.js @@ -1,21 +1,30 @@ -import Vue from 'vue'; -import Vuetify from 'vuetify/lib'; -import colors from 'vuetify/lib/util/colors' +import 'vuetify/styles' +import { createVuetify } from 'vuetify' +import { aliases, mdi } from 'vuetify/iconsets/mdi-svg' +import { VDataTable, VOtpInput } from 'vuetify/components' +import colors from 'vuetify/util/colors' - -Vue.use(Vuetify); - - -export default new Vuetify({ +export default createVuetify({ + components: { + VDataTable, + VOtpInput, + }, icons: { - iconfont: 'mdiSvg', // 'mdi' || 'mdiSvg' || 'md' || 'fa' || 'fa4' || 'faSvg' + defaultSet: 'mdi', + aliases, + sets: { + mdi, + }, }, theme: { + defaultTheme: 'light', themes: { light: { - primary: colors.amber, - secondary: colors.lightBlue.darken1, - accent: colors.amber.accent4, + colors: { + primary: colors.amber.base, + secondary: colors.lightBlue.darken1, + accent: colors.amber.accent4, + }, }, }, }, diff --git a/www/webapp/src/router/index.js b/www/webapp/src/router/index.js index 123fb6429..681a3d846 100644 --- a/www/webapp/src/router/index.js +++ b/www/webapp/src/router/index.js @@ -1,8 +1,20 @@ -import VueRouter from 'vue-router' +import { createRouter, createWebHistory } from 'vue-router' import HomePage from '@/views/HomePage.vue' import {HTTP} from '@/utils'; import {useUserStore} from "@/store/user"; +const lazy = (loader) => () => loader().catch((error) => { + const message = error?.message || ''; + if ( + message.includes('Failed to fetch dynamically imported module') || + message.includes('Importing a module script failed') || + message.includes('error loading dynamically imported module') + ) { + window.location.reload(); + } + throw error; +}); + const routes = [ { path: '/', @@ -12,136 +24,132 @@ const routes = [ { path: '/signup/:email?', name: 'signup', - // route level code-splitting - // this generates a separate chunk (about.[hash].js) for this route - // which is lazy-loaded when the route is visited. - component: () => import('@/views/SignUp.vue'), + component: lazy(() => import('@/views/SignUp.vue')), }, { path: '/custom-setup/:domain', name: 'customSetup', - component: () => import('@/views/DomainSetupPage.vue'), + component: lazy(() => import('@/views/DomainSetupPage.vue')), props: true, }, { path: '/dyn-setup/:domain', alias: '/dynsetup/:domain', name: 'dynSetup', - component: () => import('@/views/DynSetup.vue'), + component: lazy(() => import('@/views/DynSetup.vue')), }, { path: '/welcome/:domain?', name: 'welcome', - component: () => import('@/views/WelcomePage.vue'), + component: lazy(() => import('@/views/WelcomePage.vue')), }, { - path: 'https://desec.readthedocs.io/', + path: '/docs', name: 'docs', - beforeEnter(to) { location.href = to.path }, + beforeEnter() { location.href = 'https://desec.readthedocs.io/' }, }, { - path: 'https://talk.desec.io/', + path: '/talk', name: 'talk', - beforeEnter(to) { location.href = to.path }, + beforeEnter() { location.href = 'https://talk.desec.io/' }, }, { path: '/confirm/:action/:code', name: 'confirmation', - component: () => import('@/views/ConfirmationPage.vue') + component: lazy(() => import('@/views/ConfirmationPage.vue')) }, { path: '/reset-password/:email?', name: 'reset-password', - component: () => import('@/views/ResetPassword.vue'), + component: lazy(() => import('@/views/ResetPassword.vue')), }, { path: '/totp/', name: 'totp', - component: () => import('@/views/CrudListTOTP.vue'), + component: lazy(() => import('@/views/CrudListTOTP.vue')), meta: {guest: false}, }, { path: '/totp-verify/', name: 'TOTPVerify', - component: () => import('@/views/Console/TOTPVerifyDialog.vue'), + component: lazy(() => import('@/views/Console/TOTPVerifyDialog.vue')), props: (route) => ({...route.params}), }, { path: '/mfa/', name: 'mfa', - component: () => import('@/views/MFA.vue'), + component: lazy(() => import('@/views/MFA.vue')), meta: {guest: false}, }, { path: '/change-email/:email?', name: 'change-email', - component: () => import('@/views/ChangeEmail.vue'), + component: lazy(() => import('@/views/ChangeEmail.vue')), meta: {guest: false}, }, { path: '/delete-account/', name: 'delete-account', - component: () => import('@/views/DeleteAccount.vue'), + component: lazy(() => import('@/views/DeleteAccount.vue')), meta: {guest: false}, }, { path: '/donate/', name: 'donate', - component: () => import('@/views/DonatePage.vue'), + component: lazy(() => import('@/views/DonatePage.vue')), }, { - path: 'https://github.com/desec-io/desec-stack/milestones?direction=asc&sort=title&state=open', + path: '/roadmap', name: 'roadmap', - beforeEnter(to) { location.href = to.path }, + beforeEnter() { location.href = 'https://github.com/desec-io/desec-stack/milestones?direction=asc&sort=title&state=open' }, }, { path: '/impressum/', name: 'impressum', - component: () => import('@/views/ImpressumPage.vue'), + component: lazy(() => import('@/views/ImpressumPage.vue')), }, { path: '/privacy-policy/', name: 'privacy-policy', - component: () => import('@/views/PrivacyPolicy.vue'), + component: lazy(() => import('@/views/PrivacyPolicy.vue')), }, { path: '/terms/', name: 'terms', - component: () => import('@/views/TermsPage.vue'), + component: lazy(() => import('@/views/TermsPage.vue')), }, { path: '/about/', name: 'about', - component: () => import('@/views/AboutPage.vue'), + component: lazy(() => import('@/views/AboutPage.vue')), }, { path: '/login', name: 'login', - component: () => import('@/views/LoginPage.vue'), + component: lazy(() => import('@/views/LoginPage.vue')), }, { path: '/tokens', name: 'tokens', - component: () => import('@/views/CrudListToken.vue'), + component: lazy(() => import('@/views/CrudListToken.vue')), meta: {guest: false}, }, { path: '/domains', name: 'domains', - component: () => import('@/views/CrudListDomain.vue'), + component: lazy(() => import('@/views/CrudListDomain.vue')), meta: {guest: false}, }, { path: '/domains/:domain', name: 'domain', - component: () => import('@/views/CrudListRecord.vue'), + component: lazy(() => import('@/views/CrudListRecord.vue')), meta: {guest: false}, }, ] -const router = new VueRouter({ - mode: 'history', - base: import.meta.env.BASE_URL, +const router = createRouter({ + history: createWebHistory(import.meta.env.BASE_URL), scrollBehavior (to, from) { // Skip if destination full path has query parameters and differs in no other way from previous if (from && Object.keys(to.query).length) { diff --git a/www/webapp/src/views/AboutPage.vue b/www/webapp/src/views/AboutPage.vue index dbf24088e..62bb9fb2c 100644 --- a/www/webapp/src/views/AboutPage.vue +++ b/www/webapp/src/views/AboutPage.vue @@ -26,7 +26,7 @@ - + diff --git a/www/webapp/src/views/ChangeEmail.vue b/www/webapp/src/views/ChangeEmail.vue index b275b2dc1..d6d7f018c 100644 --- a/www/webapp/src/views/ChangeEmail.vue +++ b/www/webapp/src/views/ChangeEmail.vue @@ -20,7 +20,6 @@ Change Account Email Address @@ -38,32 +37,30 @@ {{ actionName }} Confirmation @@ -41,7 +40,7 @@ If you like our service, please consider donating.

- Donate + Donate

@@ -127,11 +126,6 @@ this.errors.splice(0, this.errors.length); } }, - filters: { - replace: function (value, a, b) { - return value.replace(a, b) - } - }, }; diff --git a/www/webapp/src/views/Console/DomainSetupDialog.vue b/www/webapp/src/views/Console/DomainSetupDialog.vue index c112777df..509b89692 100644 --- a/www/webapp/src/views/Console/DomainSetupDialog.vue +++ b/www/webapp/src/views/Console/DomainSetupDialog.vue @@ -7,70 +7,133 @@ @keydown.esc="close" > - -
+ + Setup Instructions for {{ domain }} -
+ - - {{ mdiClose }} - -
+ + + + Your domain {{ domain }} has been successfully created! - + + + + + + + + + Run check now + + Close +
+ + diff --git a/www/webapp/src/views/Console/TOTPVerifyDialog.vue b/www/webapp/src/views/Console/TOTPVerifyDialog.vue index 4290b6b76..3cb202055 100644 --- a/www/webapp/src/views/Console/TOTPVerifyDialog.vue +++ b/www/webapp/src/views/Console/TOTPVerifyDialog.vue @@ -8,43 +8,48 @@ > - -
- Verify TOTP: {{ name }} -
+ + + Verify TOTP: {{ displayName }} + - - {{ mdiClose }} - + + + - + {{ detail }} - + {{ successDetail }}

- {{ mdiCheck }} + Great! Continue to log in.

- +

- {{ mdiNumeric1Circle }} + Please scan the following QR code with an authenticator app (e.g. Google Authenticator).
This code is only displayed once.

- +

- {{ mdiNumeric2Circle }} + Enter the code displayed in the authenticator app to confirm and activate the token:

@@ -65,7 +70,7 @@ Want to know what's in the code? — It's your TOTP secret:
- {{ data.secret }}
+ {{ payload.secret }}

@@ -92,7 +97,7 @@ + + diff --git a/www/webapp/src/views/CrudListToken.vue b/www/webapp/src/views/CrudListToken.vue index ca50b712d..06f4d794c 100644 --- a/www/webapp/src/views/CrudListToken.vue +++ b/www/webapp/src/views/CrudListToken.vue @@ -42,7 +42,7 @@ export default { datatype: GenericText.name, searchable: true, fieldProps: (item) => ( - item.mfa !== null + item?.id && item.mfa != null ? { value_override: item.id == useUserStore().token.id ? 'current log-in token' : 'previous log-in token' } : {} ), diff --git a/www/webapp/src/views/DeleteAccount.vue b/www/webapp/src/views/DeleteAccount.vue index 9ef179e84..41f98bc74 100644 --- a/www/webapp/src/views/DeleteAccount.vue +++ b/www/webapp/src/views/DeleteAccount.vue @@ -20,7 +20,6 @@ Delete Account @@ -36,31 +35,29 @@ -
+

- Your domain is fully configured. + Your domain is securely delegated and DNSSEC is active.

-

- The following steps need to be completed in order to use your domain with deSEC. -

- {{ mdiNumeric0Circle }} + Configure your DNS records

Before delegating your domain, you might want to take the following steps:

@@ -22,20 +19,20 @@
-
- {{ mdiNumeric1Circle }} +
+ Delegate your domain
-

+

Forward the following information to the organization/person where you bought the domain {{ domain }} (usually your provider or technical administrator).

- + - - {{ mdiAlert }} Moving a domain that had DNSSEC enabled before? Read this! - - + + Moving a domain that had DNSSEC enabled before? Read this! + +
Be careful! Simply replacing records can cause errors, because resolvers may have old NS or DNSSEC settings cached. @@ -49,17 +46,17 @@
-
+
- - + + Nameservers - - {{ mdiContentCopy }} + + Copy @@ -71,13 +68,13 @@ Nameservers could not be retrieved. -

Once your provider processes this information, the Internet will start directing DNS queries to deSEC.

+

Once your provider processes this information, the Internet will start directing DNS queries to deSEC.

-
- {{ mdiNumeric2Circle }} +
+ Enable DNSSEC
-
+

You also need to forward the following DNSSEC information to your domain provider. The exact steps depend on your provider: @@ -86,18 +83,18 @@

Notes: When using block format, some providers require you to add the domain name in the beginning. (Also, - depending on + depending on your domain's suffix, we will perform this step automatically.)

- + DS Format - - {{ mdiContentCopy }} + + Copy @@ -111,13 +108,13 @@ - + DNSKEY Format - - {{ mdiContentCopy }} + + Copy @@ -130,32 +127,32 @@ Parameters could not be retrieved. (Are you logged in?) -
- {{ mdiNumeric3Circle }} +
+ Check Setup
-

+

All set up correctly? Take a look at DNSSEC Analyzer to check the status of your domain.

-
+

To enable DNSSEC, you will also need to forward some information to your domain provider. - You can retrieve this information by logging in, and then clicking on the {{ mdiInformation }} button next to your domain name. + You can retrieve this information by logging in, and then clicking on the button next to your domain name.

- {{ snackbar_icon }} + {{ snackbar_text }} -