Files
anchorhost-backend/scripts/migrate_delegated_dns.py
T
wfalkowski 2768f5f97d
Build and publish Docker image / build-and-publish (push) Successful in 8s
feat(domains): migrate user namespaces to wildcard CNAMEs
2026-10-20 18:10:23 +00:00

298 lines
12 KiB
Python

#!/usr/bin/env python3
"""Move legacy per-user deSEC zones into the parent zone's CNAME wildcard model.
Run from the backend environment. The default is a read-only dry run; pass
``--apply`` to copy records and cut over delegations. Child zones are retained
so their records remain available for rollback.
"""
import argparse
import os
import re
import sys
import time
from typing import Dict, Iterable, List, Tuple
from urllib.parse import quote
import requests
def _load_usernames() -> List[str]:
sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
from database_service import database_connection
with database_connection() as connection:
cursor = connection.cursor()
cursor.execute(
"""
SELECT DISTINCT u.username
FROM users u
JOIN user_domains d ON d.user_id = u.id
WHERE COALESCE(d.is_custom, FALSE) = FALSE
AND d.full_domain IS NOT NULL
"""
)
return [row['username'] for row in cursor.fetchall()]
def _valid_namespace(username: str, base_domain: str) -> str:
namespace = f"u-{username.strip().lower()}"
labels = namespace.split('.')
pattern = re.compile(r"[a-z0-9](?:[a-z0-9-]*[a-z0-9])?\Z")
if not all(len(label) <= 63 and pattern.fullmatch(label) for label in labels):
raise ValueError(f"Username cannot form a DNS namespace: {username!r}")
if len(f"{namespace}.{base_domain}") > 253:
raise ValueError(f"Username namespace is too long: {username!r}")
return namespace
class DeSec:
def __init__(self, token: str, base_domain: str):
self.base_url = 'https://desec.io/api/v1'
self.base_domain = base_domain.rstrip('.').lower()
self.headers = {'Authorization': f'Token {token}', 'Content-Type': 'application/json'}
self._last_request = 0.0
def request(self, method: str, url: str, **kwargs) -> requests.Response:
for attempt in range(6):
delay = 0.2 - (time.monotonic() - self._last_request)
if delay > 0:
time.sleep(delay)
response = requests.request(method, url, headers=self.headers, timeout=30, **kwargs)
self._last_request = time.monotonic()
if response.status_code != 429:
return response
retry_after = response.headers.get('Retry-After', '2')
try:
wait = min(max(float(retry_after), 1), 30)
except ValueError:
wait = 2
if attempt == 5:
return response
time.sleep(wait)
raise RuntimeError('DNS API retry loop ended unexpectedly')
def list_rrsets(self, zone: str) -> List[Dict]:
url = f'{self.base_url}/domains/{zone}/rrsets/'
rrsets: List[Dict] = []
while url:
response = self.request('GET', url)
if response.status_code != 200:
raise RuntimeError(f'Could not read RRsets for {zone} ({response.status_code})')
result = response.json()
if not isinstance(result, list):
raise RuntimeError(f'Unexpected RRset response for {zone}')
rrsets.extend(result)
url = response.links.get('next', {}).get('url')
return rrsets
def rrset_url(self, subname: str, record_type: str) -> str:
return (
f'{self.base_url}/domains/{self.base_domain}/rrsets/'
f'{quote(subname, safe=".-_*.")}/{record_type}/'
)
def _rrset_records(rrset: Dict) -> Tuple[str, ...]:
return tuple(sorted(str(record).rstrip('.').lower() if rrset.get('type') == 'CNAME' else str(record)
for record in rrset.get('records', [])))
def _relative_owner(zone: str, base_domain: str) -> str:
suffix = f'.{base_domain}'
if not zone.endswith(suffix):
raise ValueError(f'Zone is outside configured parent domain: {zone}')
owner = zone[:-len(suffix)]
_validate_owner(owner)
return owner
def _validate_owner(owner: str) -> None:
labels = owner.split('.')
pattern = re.compile(r"(?:\*|[a-z0-9](?:[a-z0-9-]*[a-z0-9])?)\Z")
if (
not all(len(label) <= 63 and pattern.fullmatch(label) for label in labels)
or ('*' in labels and (labels[0] != '*' or labels.count('*') != 1))
):
raise ValueError(f'Unsupported legacy namespace owner: {owner!r}')
def build_plan(api: DeSec) -> Tuple[List[str], List[Dict], Dict[Tuple[str, str], Dict]]:
zones_response = api.request('GET', f'{api.base_url}/domains/')
if zones_response.status_code != 200:
raise RuntimeError(f'Could not list deSEC zones ({zones_response.status_code})')
zones = zones_response.json()
legacy_zones = sorted(
zone['name'] for zone in zones
if zone.get('name', '').endswith(f'.{api.base_domain}')
and zone.get('name', '').split('.', 1)[0].startswith('u-')
)
parent_rrsets = api.list_rrsets(api.base_domain)
parent_map = {(item.get('subname') or '', item['type']): item for item in parent_rrsets}
namespaces = {_relative_owner(zone, api.base_domain) for zone in legacy_zones}
namespaces.update(
item.get('subname', '') for item in parent_rrsets
if item.get('type') in {'NS', 'DS'} and (item.get('subname') or '').startswith('u-')
)
for username in _load_usernames():
namespaces.add(_valid_namespace(username, api.base_domain))
copies: List[Dict] = []
for zone in legacy_zones:
namespace = _relative_owner(zone, api.base_domain)
for rrset in api.list_rrsets(zone):
child_name = rrset.get('subname') or ''
record_type = rrset['type']
if child_name in {'', '@'} and record_type == 'NS':
continue
if child_name in {'', '@'}:
raise RuntimeError(
f'Cannot preserve apex {record_type} at {zone}; manual DNS migration is required'
)
target_name = f'{child_name}.{namespace}'
_validate_owner(target_name)
key = (target_name, record_type)
existing = parent_map.get(key)
other_types = {
item['type'] for item in parent_rrsets
if item.get('subname', '') == target_name and item['type'] != record_type
}
if record_type == 'CNAME' and other_types:
raise RuntimeError(f'Conflicting parent RRset at {target_name}: {sorted(other_types)}')
if 'CNAME' in other_types:
raise RuntimeError(f'Parent CNAME conflicts with child RRset at {target_name} {record_type}')
if existing and _rrset_records(existing) != _rrset_records(rrset):
raise RuntimeError(f'Conflicting parent RRset at {target_name} {record_type}')
if not existing:
copies.append({
'subname': target_name,
'type': record_type,
'ttl': rrset['ttl'],
'records': rrset.get('records', []),
})
for namespace in sorted(namespaces):
for owner in (namespace, f'*.{namespace}'):
_validate_owner(owner)
current_at_owner = [item for item in parent_rrsets if item.get('subname', '') == owner]
for item in current_at_owner:
if item['type'] in {'NS', 'DS'} and owner == namespace:
continue
if item['type'] == 'CNAME' and _rrset_records(item) == (api.base_domain,):
continue
raise RuntimeError(f'Conflicting parent RRset at {owner} {item["type"]}')
return sorted(namespaces), copies, parent_map
def _delete_rrset(api: DeSec, subname: str, record_type: str) -> None:
response = api.request('DELETE', api.rrset_url(subname, record_type))
if response.status_code not in {200, 204, 404}:
raise RuntimeError(f'Could not remove parent {record_type} at {subname} ({response.status_code})')
def _create_rrset(api: DeSec, payload: Dict) -> None:
response = api.request('POST', f'{api.base_url}/domains/{api.base_domain}/rrsets/', json=payload)
if response.status_code not in {200, 201}:
raise RuntimeError(
f'Could not create parent {payload["type"]} at {payload["subname"]} ({response.status_code})'
)
def apply_plan(api: DeSec, namespaces: Iterable[str], copies: List[Dict],
parent_map: Dict[Tuple[str, str], Dict]) -> None:
# Copy child records and create wildcard records first; old delegations stay
# authoritative until all non-destructive preparation has completed.
for payload in copies:
_create_rrset(api, payload)
print(f'Copied {payload["type"]} {payload["subname"]}')
for namespace in namespaces:
wildcard = f'*.{namespace}'
if (wildcard, 'CNAME') not in parent_map:
_create_rrset(api, {
'subname': wildcard, 'type': 'CNAME', 'ttl': 3600,
'records': [f'{api.base_domain}.'],
})
print(f'Created wildcard CNAME {wildcard}')
for namespace in namespaces:
# Remove DS before NS so a resolver never sees a signed delegation with
# no matching child zone. Preserve exact RRsets for a one-step rollback.
saved = {record_type: parent_map.get((namespace, record_type))
for record_type in ('DS', 'NS')}
try:
for record_type in ('DS', 'NS'):
if saved[record_type]:
_delete_rrset(api, namespace, record_type)
if (namespace, 'CNAME') not in parent_map:
_create_rrset(api, {
'subname': namespace, 'type': 'CNAME', 'ttl': 3600,
'records': [f'{api.base_domain}.'],
})
print(f'Cut over {namespace}')
except Exception:
# The old child zones remain intact. Restore delegation metadata if
# the final CNAME write fails partway through a cutover.
for record_type in ('NS', 'DS'):
rrset = saved[record_type]
if rrset:
_create_rrset(api, {
'subname': namespace,
'type': record_type,
'ttl': rrset['ttl'],
'records': rrset['records'],
})
raise
from database_service import database_connection
with database_connection() as connection:
cursor = connection.cursor()
for namespace in namespaces:
pattern = f'%.{namespace}.{api.base_domain}'
pattern = pattern.replace('!', '!!').replace('%', '!%').replace('_', '!_')
cursor.execute(
"""
UPDATE user_domains
SET record_type = ?, ip_address = ?, ttl = 3600
WHERE COALESCE(is_custom, FALSE) = FALSE
AND lower(full_domain) LIKE ? ESCAPE '!'
""",
('CNAME', api.base_domain, pattern),
)
connection.commit()
def main() -> int:
parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument('--apply', action='store_true', help='Apply the reviewed DNS migration')
args = parser.parse_args()
token = os.environ.get('DESEC_API_TOKEN')
base_domain = os.environ.get('BASE_DOMAIN')
if not token or not base_domain:
parser.error('DESEC_API_TOKEN and BASE_DOMAIN must be set')
api = DeSec(token, base_domain)
try:
namespaces, copies, parent_map = build_plan(api)
print(f'Namespaces to migrate: {len(namespaces)}')
print(f'Legacy RRsets to copy: {len(copies)}')
print('Namespaces:', ', '.join(namespaces))
print('Record copies:', ', '.join(f'{item["type"]}:{item["subname"]}' for item in copies) or 'none')
print('Mode:', 'APPLY' if args.apply else 'DRY RUN')
if args.apply:
apply_plan(api, namespaces, copies, parent_map)
print('DNS migration completed. Legacy child zones were retained for rollback.')
else:
print('No changes made. Run with --apply to perform the migration.')
except Exception as error:
print(f'Migration stopped: {error}', file=sys.stderr)
return 1
return 0
if __name__ == '__main__':
raise SystemExit(main())