From 6ff0f83ec98881fd13319353dbaad20682665442 Mon Sep 17 00:00:00 2001 From: Robert Alexander Date: Sat, 23 Nov 2024 14:53:38 -0500 Subject: [PATCH] Revert "First pass" This reverts commit ee16aadeac717c0cf1c8add0cd32567c3946dce4. --- docker-compose.yml | 20 +++ localcert/domains/pdns.py | 201 +++++++++++++++++---------- localcert/domains/subdomain_utils.py | 59 +++++++- localcert/domains/views.py | 54 ++++++- 4 files changed, 254 insertions(+), 80 deletions(-) diff --git a/docker-compose.yml b/docker-compose.yml index f6543db..bd4a7e4 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -12,6 +12,25 @@ services: - POSTGRES_PASSWORD - POSTGRES_USER + pdns: + build: pdns + restart: unless-stopped + ports: + - "53:53/udp" + environment: + - LOCALCERT_PDNS_DB_NAME + - LOCALCERT_PDNS_DEFAULT_SOA_CONTENT + - LOCALCERT_PDNS_HOST + - LOCALCERT_PDNS_WEBSERVER_ALLOW_FROM + - LOCALCERT_SHARED_PDNS_API_KEY + - POSTGRES_PASSWORD + - POSTGRES_USER + networks: + localcert-net: + ipv4_address: 10.33.44.2 + depends_on: + - db + web: build: localcert restart: unless-stopped @@ -36,6 +55,7 @@ services: ipv4_address: 10.33.44.3 depends_on: - db + - pdns labels: - "traefik.enable=true" - "traefik.http.routers.localcert-web.rule=(Host(`console.getlocalcert.net`)) || (Host(`api.getlocalcert.net`) && PathPrefix(`/api/`))" diff --git a/localcert/domains/pdns.py b/localcert/domains/pdns.py index 64530ff..4463aea 100644 --- a/localcert/domains/pdns.py +++ b/localcert/domains/pdns.py @@ -1,104 +1,155 @@ +import requests import logging from .utils import CustomExceptionServerError from datetime import datetime from django.conf import settings from typing import List -from cloudflare import Cloudflare -ZONE_IDS = { - "localcert.net.": "ab2d04b0ccf31906dd87900f0db11f73", - "localhostcert.net.": "ac1335db9f052915b076c0de09e06443", +PDNS_API_BASE_URL = f"http://{settings.LOCALCERT_PDNS_SERVER_IP}:{settings.LOCALCERT_PDNS_API_PORT}/api/v1" +PDNS_HEADERS = { + "X-API-Key": settings.LOCALCERT_PDNS_API_KEY, + "accept": "application/json", } -client = Cloudflare(api_token=os.environ.get("CLOUDFLARE_TOKEN")) +def pdns_create_zone(zone: str): + assert zone.endswith(".") + logging.debug(f"[PDNS] Create {zone}") -# 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}") + # Create zone in pdns + resp = requests.post( + PDNS_API_BASE_URL + "/servers/localhost/zones", + headers=PDNS_HEADERS, + json={ + "name": zone, + "kind": "Native", + }, + ) + json_resp = resp.json() + + if "error" in json_resp.keys(): + raise CustomExceptionServerError(json_resp["error"]) # pragma: no cover + + # success + return + + +# TODO use the targeted name/type +def pdns_describe_domain(zone_name: str) -> dict: + assert zone_name.endswith(".") + + logging.debug(f"[PDNS] Describe {zone_name}") + + # TODO: newer pdns versions can filter by name/type + resp = requests.get( + f"{PDNS_API_BASE_URL}/servers/localhost/zones/{zone_name}", + headers=PDNS_HEADERS, + ) + if resp.status_code != requests.codes.ok: + raise CustomExceptionServerError( + f"Unable to describe domain, PDNS error code: {resp.status_code}" + ) # pragma: no cover + + return resp.json() - for k, v in ZONE_IDS.items(): - if domain.endswith(f".{k}") - zone_id = v - break - else: - # Ooops - return {} - # CF doesn't use trailing dot - domain = domain[:-1] - - # Two lookups: - # . (exact) - # *.. (endswith) - results = client.dns.records.list( - zone_id=zone_id, - name={"endswith": f".{domain}"}, - type="TXT", - ).result - r2 = client.dns.records.list( - zone_id=zone_id, - name={"exact": domain}, - type="TXT", - ).result - results.extend(r2) - - rrsets = [] - for result in results: - rrset.append({ - "type": "TXT", - "name": result.name, - "content": result.content, - "ttl": result.ttl, - }) - return { "rrsets": rrsets } +def pdns_delete_rrset(zone_name: str, rr_name: str, rrtype: str): + assert zone_name.endswith(".") + assert rr_name.endswith(zone_name) + assert rrtype == "TXT" + + logging.debug(f"[PDNS] Delete {zone_name} {rr_name} {rrtype}") + + resp = requests.patch( + f"{PDNS_API_BASE_URL}/servers/localhost/zones/{zone_name}", + headers=PDNS_HEADERS, + json={ + "rrsets": [ + { + "name": rr_name, + "type": "TXT", + "changetype": "DELETE", + }, + ], + }, + ) + + if resp.status_code != requests.codes.no_content: + raise CustomExceptionServerError(f"{resp.status_code}") # pragma: no cover + + # success + return def pdns_replace_rrset( zone_name: str, rr_name: str, rr_type: str, ttl: int, record_contents: List[str] ): """ + record_contents - Records from least recently added """ assert rr_name.endswith(".") assert rr_name.endswith(zone_name) - assert rr_type == "TXT" - - # 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, - type=rr_type, - ).result - - for record in results: - if record.content not in record_contents: - # Delete records that are no longer needed - client.dns.records.delete( - zone_id=zone_id, - dns_record_id=record.id, - ) - else: - # Don't alter records that already exist - record_contents.remove(record.content) - - for content in record_contents: - # Create anything that's new - client.dns.records.create( - zone_id=zone_id, - name=rr_name, - type=rr_type, - content=content, - ) + assert rr_type in ["TXT", "A", "MX", "NS", "SOA"] + + logging.debug( + f"[PDNS] Replace {zone_name} {rr_name} {rr_type} {ttl} {record_contents}" + ) + + records = [ + { + "content": content, + "disabled": False, + } + for content in record_contents + ] + comments = [ + { + "content": f"{record_contents[idx]} : {idx}", + "account": "", + "modified_at": int(datetime.now().timestamp()), + } + for idx in range(len(record_contents)) + ] + + resp = requests.patch( + f"{PDNS_API_BASE_URL}/servers/localhost/zones/{zone_name}", + headers=PDNS_HEADERS, + json={ + "rrsets": [ + { + "name": rr_name, + "type": rr_type, + "changetype": "REPLACE", + "ttl": ttl, + "records": records, + "comments": comments, + }, + ], + }, + ) + + if resp.status_code != requests.codes.no_content: + raise CustomExceptionServerError( + f"{resp.status_code}: {resp.content.decode('utf-8')}" + ) # pragma: no cover # success return + +def pdns_get_stats(): + resp = requests.get( + f"{PDNS_API_BASE_URL}/servers/localhost/statistics", + headers=PDNS_HEADERS, + ) + + if resp.status_code != 200: # pragma: no cover + logging.error(f"{resp.status_code}: {resp.content.decode('utf-8')}") + return {} + + # success + return resp.json() diff --git a/localcert/domains/subdomain_utils.py b/localcert/domains/subdomain_utils.py index 27467e8..1d61eb8 100644 --- a/localcert/domains/subdomain_utils.py +++ b/localcert/domains/subdomain_utils.py @@ -13,7 +13,7 @@ DEFAULT_SPF_POLICY, ) from .models import Zone, ZoneApiKey -from .pdns import pdns_replace_rrset +from .pdns import pdns_create_zone, pdns_replace_rrset from .utils import remove_trailing_dot @@ -71,6 +71,8 @@ def create_instant_subdomain(is_delegate: bool) -> InstantSubdomainCreatedInfo: new_fqdn = f"{subdomain_name}.{parent_name}" logging.info(f"Creating instant domain {new_fqdn} for anonymous user") + set_up_pdns_for_zone(new_fqdn, parent_name) + new_zone = Zone.objects.create( name=new_fqdn, owner=None, @@ -84,3 +86,58 @@ def create_instant_subdomain(is_delegate: bool) -> InstantSubdomainCreatedInfo: password=secret, ) + +def set_up_pdns_for_zone(zone_name: str, parent_zone: str): + assert zone_name.endswith("." + parent_zone) + + pdns_create_zone(zone_name) + + # localhostcert.net has predefined A records locked to localhost + if parent_zone == "localhostcert.net.": + pdns_replace_rrset(zone_name, zone_name, "A", 86400, ["127.0.0.1"]) + else: + # Others don't have default A records + assert parent_zone == "localcert.net." + + pdns_replace_rrset(zone_name, zone_name, "TXT", 1, [DEFAULT_SPF_POLICY]) + pdns_replace_rrset( + zone_name, f"_dmarc.{zone_name}", "TXT", 86400, [DEFAULT_DMARC_POLICY] + ) + pdns_replace_rrset( + zone_name, f"*._domainkey.{zone_name}", "TXT", 86400, [DEFAULT_DKIM_POLICY] + ) + pdns_replace_rrset(zone_name, zone_name, "MX", 86400, [DEFAULT_MX_RECORD]) + + pdns_replace_rrset( + zone_name, + zone_name, + "NS", + 60, + [ + settings.LOCALCERT_PDNS_NS1, + settings.LOCALCERT_PDNS_NS2, + ], + ) + + pdns_replace_rrset( + zone_name, + zone_name, + "SOA", + 60, + [ + settings.LOCALCERT_PDNS_NS1 + + " soa-admin.robalexdev.com. 0 10800 3600 604800 3600", + ], + ) + + # Delegation from parent zone + pdns_replace_rrset( + parent_zone, + zone_name, + "NS", + 60, + [ + settings.LOCALCERT_PDNS_NS1, + settings.LOCALCERT_PDNS_NS2, + ], + ) diff --git a/localcert/domains/views.py b/localcert/domains/views.py index ffd1934..d1abd79 100644 --- a/localcert/domains/views.py +++ b/localcert/domains/views.py @@ -30,6 +30,7 @@ ZoneApiKey, ) from .pdns import ( + pdns_delete_rrset, pdns_describe_domain, pdns_replace_rrset, ) @@ -1013,7 +1014,12 @@ def update_txt_record_helper( is_web_request: str, ): new_content = f'"{rr_content}"' # Normalize - existing_user_defined = get_existing_txt_records(zone_name, rr_name) + existing_content = get_existing_txt_records(zone_name, rr_name) + + # Pull out SPF record(s) so we don't change those + existing_spf_records = [_ for _ in existing_content if _ == DEFAULT_SPF_POLICY] + assert len(existing_spf_records) <= 1 + existing_user_defined = [_ for _ in existing_content if _ != DEFAULT_SPF_POLICY] if edit_action == EditActionEnum.ADD: if any([new_content == existing for existing in existing_user_defined]): @@ -1043,9 +1049,17 @@ def update_txt_record_helper( 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) + # Always keep the SPF if it previously existed + new_content_set.extend(existing_spf_records) + + if new_content_set: + 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) + else: + logging.info(f"Deleting RRSET {rr_name} TXT") + # Nothing remaining, delete the rr_set + pdns_delete_rrset(zone_name, rr_name, "TXT") if is_web_request: if edit_action == EditActionEnum.ADD: messages.success(request, "Record added") @@ -1053,3 +1067,35 @@ 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(zone_name) + existing_records = [] + existing_comments = [] + if details["rrsets"]: + for rrset in details["rrsets"]: + if rrset["name"] == rr_name and rrset["type"] == "TXT": + existing_records = rrset["records"] + existing_comments = rrset["comments"] + break + + # Check invariants + for record in existing_records: + assert all([comment["content"] for comment in existing_comments]) + assert any( + [ + comment["content"].startswith(f"{record['content']} : ") + for comment in existing_comments + ] + ) + assert len(existing_comments) == len(existing_records) + + # Each comment will contain " : " where matches the TXT record + # content and tracks the order these were added (oldest at index 0) + # Sort these so we can trim old content if needed + ordered_comments = sorted( + existing_comments, key=lambda x: int(x["content"].split(" : ")[1]) + ) + ordered_content = [ + comment["content"].split(" : ")[0] for comment in ordered_comments + ] + return ordered_content