Skip to content
This repository was archived by the owner on May 15, 2025. It is now read-only.
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions docker-compose.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down
47 changes: 33 additions & 14 deletions localcert/domains/pdns.py
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import logging
import os

from .utils import CustomExceptionServerError
from datetime import datetime
Expand All @@ -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]
Expand All @@ -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
Expand All @@ -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,
Expand All @@ -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:
Expand Down
1 change: 0 additions & 1 deletion localcert/domains/subdomain_utils.py
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,6 @@
DEFAULT_SPF_POLICY,
)
from .models import Zone, ZoneApiKey
from .pdns import pdns_replace_rrset
from .utils import remove_trailing_dot


Expand Down
2 changes: 1 addition & 1 deletion localcert/domains/templates/domain_detail.html
Original file line number Diff line number Diff line change
Expand Up @@ -45,7 +45,7 @@ <h2 class="title is-4">Records</h2>
{{ rrset.name | strip_domain_name }}
</td>
<td>
{{ rrset.ttl | namedDuration }}
Auto
</td>
<td>
{{ record.content }}
Expand Down
11 changes: 11 additions & 0 deletions localcert/domains/utils.py
Original file line number Diff line number Diff line change
Expand Up @@ -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



36 changes: 20 additions & 16 deletions localcert/domains/views.py
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down Expand Up @@ -42,6 +42,7 @@
domain_limit_for_user,
sort_records_key,
build_url,
domains_equal,
)
from uuid import uuid4
from django.contrib import messages
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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]):
Expand All @@ -1039,17 +1032,28 @@ 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")
else:
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
2 changes: 1 addition & 1 deletion localcert/requirements-dev.txt
Original file line number Diff line number Diff line change
@@ -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
22 changes: 17 additions & 5 deletions localcert/requirements.txt
Original file line number Diff line number Diff line change
@@ -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
Loading