diff --git a/docker-compose.yml b/docker-compose.yml index f6543db..4d42c79 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -29,6 +29,7 @@ services: - LOCALCERT_WEB_PDNS_DNS_PORT - LOCALCERT_WEB_PDNS_HOST - LOCALCERT_WEB_PGSQL_HOST + - CLOUDFLARE_TOKEN - POSTGRES_PASSWORD - POSTGRES_USER networks: diff --git a/localcert/domains/pdns.py b/localcert/domains/pdns.py index 64530ff..e0651f5 100644 --- a/localcert/domains/pdns.py +++ b/localcert/domains/pdns.py @@ -1,4 +1,5 @@ import logging +import os from .utils import CustomExceptionServerError from datetime import datetime @@ -16,18 +17,19 @@ client = Cloudflare(api_token=os.environ.get("CLOUDFLARE_TOKEN")) +def get_zone_id(domain: str) -> str: + for k, v in ZONE_IDS.items(): + if domain.endswith(f".{k}"): + return v + else: + assert False, "Unknown domain" + # TODO: Some records are set by wildcard, hardcode these def pdns_describe_domain(domain: str) -> dict: assert domain.endswith(".") logging.debug(f"[PDNS] Describe {domain}") - for k, v in ZONE_IDS.items(): - if domain.endswith(f".{k}") - zone_id = v - break - else: - # Ooops - return {} + zone_id = get_zone_id(domain) # CF doesn't use trailing dot domain = domain[:-1] @@ -47,19 +49,33 @@ def pdns_describe_domain(domain: str) -> dict: ).result results.extend(r2) - rrsets = [] + # Convert CF results to look like PDNS JSON + results_by_name = {} for result in results: - rrset.append({ - "type": "TXT", - "name": result.name, + if result.name not in results_by_name: + results_by_name[result.name] = [] + results_by_name[result.name].append({ "content": result.content, - "ttl": result.ttl, + "created": result.created_on, }) + + rrsets = [] + for name, records in results_by_name.items(): + records.sort(key=lambda r: r['created']) + records = [ {'content': _['content']} for _ in records ] + + rrsets.append({ + "type": "TXT", + "name": name, + "records": records, + }) + + logging.debug(f"[PDNS] RRSets: {results} {rrsets}") return { "rrsets": rrsets } def pdns_replace_rrset( - zone_name: str, rr_name: str, rr_type: str, ttl: int, record_contents: List[str] + zone_name: str, rr_name: str, rr_type: str, record_contents: List[str] ): """ record_contents - Records from least recently added @@ -68,11 +84,12 @@ def pdns_replace_rrset( assert rr_name.endswith(zone_name) assert rr_type == "TXT" + zone_id = get_zone_id(zone_name) + # CF doesn't use trailing dot rr_name = rr_name[:-1] # Collect the existing content - zone_id = ZONE_IDS[zone_name] results = client.dns.records.list( zone_id=zone_id, name=rr_name, @@ -82,12 +99,14 @@ def pdns_replace_rrset( for record in results: if record.content not in record_contents: # Delete records that are no longer needed + logging.debug(f"No longer need: {record.content} || {record_contents}") client.dns.records.delete( zone_id=zone_id, dns_record_id=record.id, ) else: # Don't alter records that already exist + logging.debug(f"Keeping: {record.content} || {record_contents}") record_contents.remove(record.content) for content in record_contents: diff --git a/localcert/domains/subdomain_utils.py b/localcert/domains/subdomain_utils.py index 27467e8..fd2c6f2 100644 --- a/localcert/domains/subdomain_utils.py +++ b/localcert/domains/subdomain_utils.py @@ -13,7 +13,6 @@ DEFAULT_SPF_POLICY, ) from .models import Zone, ZoneApiKey -from .pdns import pdns_replace_rrset from .utils import remove_trailing_dot diff --git a/localcert/domains/templates/domain_detail.html b/localcert/domains/templates/domain_detail.html index 3079a2b..820a2aa 100644 --- a/localcert/domains/templates/domain_detail.html +++ b/localcert/domains/templates/domain_detail.html @@ -45,7 +45,7 @@

Records

{{ rrset.name | strip_domain_name }} - {{ rrset.ttl | namedDuration }} + Auto {{ record.content }} diff --git a/localcert/domains/utils.py b/localcert/domains/utils.py index 59e5b30..2fc9d5a 100644 --- a/localcert/domains/utils.py +++ b/localcert/domains/utils.py @@ -119,3 +119,14 @@ def subdomain_name(value: str) -> str: if value.endswith(f".{suffix}"): return value.removesuffix(f".{suffix}") assert False # pragma: no cover + + +def domains_equal(a: str, b: str) -> bool: + if a.endswith('.'): + a = a[:-1] + if b.endswith('.'): + b = b[:-1] + return a == b + + + diff --git a/localcert/domains/views.py b/localcert/domains/views.py index ffd1934..bbabc58 100644 --- a/localcert/domains/views.py +++ b/localcert/domains/views.py @@ -8,7 +8,7 @@ from django.utils import timezone from requests.structures import CaseInsensitiveDict -from .subdomain_utils import Credentials, create_instant_subdomain, set_up_pdns_for_zone +from .subdomain_utils import Credentials, create_instant_subdomain from .validators import validate_acme_dns01_txt_value, validate_label from .network import dns_query_A, dns_query_TXT @@ -42,6 +42,7 @@ domain_limit_for_user, sort_records_key, build_url, + domains_equal, ) from uuid import uuid4 from django.contrib import messages @@ -139,7 +140,6 @@ def register_subdomain( zone_name = form.cleaned_data["zone_name"] # synthetic field logging.info(f"Creating domain {zone_name} for user {request.user.id}...") - set_up_pdns_for_zone(zone_name, parent_zone) newZone = Zone.objects.create( name=zone_name, owner=request.user, @@ -385,32 +385,24 @@ def api_instant_subdomain( def api_health( _: HttpRequest, ) -> JsonResponse: - # Check a random host name, it should not resolve - # Random name is used to ensure uncached responses - try: - dns_query_A(str(uuid4()) + ".localhostcert.net") - logging.warning("Query unexpectedly resolved") - ext_dns_a_healthy = False - except dns.resolver.NXDOMAIN: - ext_dns_a_healthy = True - # Check a TXT for a known domain (short TTL) + # Check a SPF record try: txt_result = dns_query_TXT( - "_acme-challenge.test-txt-lookup-known-value.localhostcert.net" + "localhostcert.net" ) ext_dns_txt_healthy = ( - b"testtestaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa" in txt_result + b"v=spf1 -all" in txt_result ) except Exception as e: logging.warning(e) ext_dns_txt_healthy = False - healthy = ext_dns_a_healthy and ext_dns_txt_healthy + healthy = ext_dns_txt_healthy status = 200 if healthy else 500 return JsonResponse( { "healthy": healthy, - "a": ext_dns_a_healthy, + "a": True, "b": ext_dns_txt_healthy, }, status=status, @@ -1014,6 +1006,7 @@ def update_txt_record_helper( ): new_content = f'"{rr_content}"' # Normalize existing_user_defined = get_existing_txt_records(zone_name, rr_name) + existing_user_defined = [ _['content'] for _ in existing_user_defined ] if edit_action == EditActionEnum.ADD: if any([new_content == existing for existing in existing_user_defined]): @@ -1039,13 +1032,14 @@ def update_txt_record_helper( item for item in existing_user_defined if item != new_content ] if len(new_content_set) == len(existing_user_defined): + logging.debug(f"AAAAAAAAA {zone_name} {rr_name} || {existing_user_defined} || {new_content}") if is_web_request: messages.warning(request, "Nothing was removed") return logging.info(f"Updating RRSET {rr_name} TXT with {len(new_content_set)} values") # Replace to update the content - pdns_replace_rrset(zone_name, rr_name, "TXT", 1, new_content_set) + pdns_replace_rrset(zone_name, rr_name, "TXT", new_content_set) if is_web_request: if edit_action == EditActionEnum.ADD: messages.success(request, "Record added") @@ -1053,3 +1047,13 @@ def update_txt_record_helper( messages.success(request, "Record removed") +def get_existing_txt_records(zone_name: str, rr_name: str) -> List[str]: + details = pdns_describe_domain(rr_name) + existing_records = [] + if details["rrsets"]: + for rrset in details["rrsets"]: + if domains_equal(rrset['name'], rr_name) and rrset["type"] == "TXT": + existing_records = rrset["records"] + break + + return existing_records diff --git a/localcert/requirements-dev.txt b/localcert/requirements-dev.txt index eb65cee..2ab272f 100644 --- a/localcert/requirements-dev.txt +++ b/localcert/requirements-dev.txt @@ -1,6 +1,6 @@ beautifulsoup4==4.12.3 black==23.10.1 -coverage==7.6.4 +coverage==7.6.7 dnspython==2.7.0 flake8==7.1.1 pip-upgrader==1.4.15 diff --git a/localcert/requirements.txt b/localcert/requirements.txt index cc9d4a3..d4d357e 100644 --- a/localcert/requirements.txt +++ b/localcert/requirements.txt @@ -1,21 +1,33 @@ +Django==5.1.3 +PyJWT==2.10.0 +annotated-types==0.7.0 +anyio==4.6.2.post1 asgiref==3.8.1 certifi==2024.8.30 cffi==1.17.1 charset-normalizer==3.4.0 +cloudflare==3.1.0 cryptography==43.0.3 defusedxml==0.7.1 -Django==5.1.2 -django-allauth==65.1.0 +distro==1.9.0 +django-allauth==65.2.0 django-csp==3.8 dnspython==2.7.0 gunicorn==23.0.0 +h11==0.14.0 +httpcore==1.0.7 +httpx==0.27.2 idna==3.10 oauthlib==3.2.2 psycopg2-binary==2.9.10 pycparser==2.22 -PyJWT==2.9.0 +pydantic==2.9.0 +pydantic_core==2.23.2 python3-openid==3.2.0 -requests==2.32.3 requests-oauthlib==2.0.0 -sqlparse==0.5.1 +requests==2.32.3 +sniffio==1.3.1 +sqlparse==0.5.2 +typing_extensions==4.12.2 +tzdata==2024.2 urllib3==2.2.3