333 lines
14 KiB
Python
333 lines
14 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, cut over delegations, export the old zonefiles,
|
|
and remove the child zones that would otherwise override the parent records.
|
|
"""
|
|
|
|
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[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), legacy_zones, 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 _backup_and_remove_legacy_zones(api: DeSec, legacy_zones: Iterable[str], backup_dir: str) -> None:
|
|
"""Export old zones for rollback, then remove their competing authority."""
|
|
os.makedirs(backup_dir, mode=0o700, exist_ok=True)
|
|
zonefiles = {}
|
|
for zone in legacy_zones:
|
|
url = f'{api.base_url}/domains/{quote(zone, safe=".-")}/zonefile/'
|
|
response = api.request('GET', url)
|
|
if response.status_code != 200:
|
|
raise RuntimeError(f'Could not export legacy zone {zone} ({response.status_code})')
|
|
zonefiles[zone] = response.text
|
|
|
|
for zone, zonefile in zonefiles.items():
|
|
filename = os.path.join(backup_dir, f'{zone}.zone')
|
|
with open(filename, 'w', encoding='utf-8') as backup:
|
|
backup.write(zonefile)
|
|
print(f'Exported {len(zonefiles)} legacy zone backups to {backup_dir}')
|
|
|
|
for zone in legacy_zones:
|
|
url = f'{api.base_url}/domains/{quote(zone, safe=".-")}/'
|
|
response = api.request('DELETE', url)
|
|
if response.status_code not in {204, 404}:
|
|
raise RuntimeError(f'Could not remove legacy zone {zone} ({response.status_code})')
|
|
print(f'Removed legacy child zone {zone}')
|
|
|
|
|
|
def apply_plan(api: DeSec, namespaces: Iterable[str], copies: List[Dict],
|
|
legacy_zones: Iterable[str], parent_map: Dict[Tuple[str, str], Dict],
|
|
backup_dir: str) -> 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
|
|
|
|
# Hosted child zones continue to answer authoritatively even after their
|
|
# parent NS/DS records are removed. Back them up and remove them so the
|
|
# new parent-zone CNAMEs and wildcards are actually served.
|
|
_backup_and_remove_legacy_zones(api, legacy_zones, backup_dir)
|
|
|
|
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) = ? OR lower(full_domain) LIKE ? ESCAPE '!')
|
|
""",
|
|
('CNAME', api.base_domain, f'{namespace}.{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')
|
|
parser.add_argument(
|
|
'--backup-dir', default='/tmp/anchorhost-delegated-dns-backup',
|
|
help='Directory for exported legacy zonefiles before their zones are deleted',
|
|
)
|
|
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, legacy_zones, 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, legacy_zones, parent_map, args.backup_dir)
|
|
print('DNS migration completed. Legacy zonefiles are backed up; old child zones are removed.')
|
|
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())
|