feat(domains): migrate user namespaces to wildcard CNAMEs
Build and publish Docker image / build-and-publish (push) Successful in 8s

This commit is contained in:
wfalkowski committed 2026-10-20 18:10:23 +00:00
1 parent 82fa50e9d5
commit 2768f5f97d
6 files changed
+503 -296

No files matched your search

+145 -152
View File
@@ -6,6 +6,7 @@ import re
import requests
import time
from typing import Dict, List, Optional
from urllib.parse import quote
@@ -453,7 +454,10 @@ class DomainManager:
def create_subdomain(self, username: str, subdomain_name: str) -> Dict:
"""
Create a subdomain for user with proper DNSSEC setup
Claim a hostname below a user's namespace.
DNS is provisioned once per user with a namespace CNAME and wildcard
CNAME in the parent zone. Individual hostnames are application records.
Returns: dict with 'success', 'message', and optionally 'domain_data'
"""
@@ -470,7 +474,10 @@ class DomainManager:
username = username.lower().strip()
subdomain_name = subdomain_name.lower().strip()
# Validate subdomain format
if not self._is_valid_username_label(username):
return {'success': False, 'message': "Username cannot be represented as a DNS name", 'domain_data': None}
# Hostnames are single labels below u-<username>.
if not self._is_valid_subdomain(subdomain_name):
return {
'success': False,
@@ -481,112 +488,24 @@ class DomainManager:
# Construct domains
user_domain = f"u-{username}.{self.base_domain}"
full_subdomain = f"{subdomain_name}.{user_domain}"
namespace = self.ensure_user_namespace(username)
if not namespace['success']:
return {'success': False, 'message': namespace['message'], 'domain_data': None}
logger.info("="*60)
logger.info(f"Creating subdomain: {full_subdomain}")
logger.info(f"User domain: {user_domain}")
logger.info(f"Subdomain: {subdomain_name}")
logger.info(f"Target IP: {self.node_ip}")
logger.info("="*60)
# Step 1: Ensure user's domain exists
logger.info(f"Step 1: Checking if user domain exists: {user_domain}")
user_domain_exists = self._check_domain_exists(user_domain)
logger.info(f"Domain exists: {user_domain_exists}")
if user_domain_exists is None:
return {
'success': False,
'message': f"Error checking domain {user_domain}",
'domain_data': None
}
if not user_domain_exists:
logger.info(f"Step 2: Creating user domain: {user_domain}")
domain_created = self._create_domain(user_domain)
if not domain_created:
return {
'success': False,
'message': f"Failed to create user domain {user_domain}",
'domain_data': None
}
logger.info(f"✓ User domain created: {user_domain}")
# Step 3: Setup DNSSEC delegation for the new domain
logger.info(f"Step 3: Setting up DNSSEC delegation...")
dnssec_result = self.setup_dnssec_delegation(username)
if not dnssec_result.get('success'):
logger.warning(f"⚠ DNSSEC setup had issues: {dnssec_result.get('message')}")
# Continue anyway, as DNSSEC is not critical for basic functionality
else:
logger.info(f"✓ User domain already exists: {user_domain}")
# Check if DNSSEC is properly configured
logger.info("Checking DNSSEC configuration...")
dnssec_result = self.setup_dnssec_delegation(username)
if not dnssec_result.get('success'):
logger.warning(f"⚠ DNSSEC check had issues: {dnssec_result.get('message')}")
# Step 4: Create the A record for the subdomain
logger.info(f"Step 4: Creating A record for subdomain: {subdomain_name}")
logger.info(f"Target: {subdomain_name}.{user_domain} → {self.node_ip}")
url = f"{self.base_url}/domains/{user_domain}/rrsets/"
data = {
"subname": subdomain_name,
"type": "A",
"ttl": 3600,
"records": [self.node_ip]
domain_info = {
'domain': full_subdomain,
'subdomain': subdomain_name,
'username': username,
'record_type': 'CNAME',
'ip_address': self.base_domain,
'ttl': 3600,
'created_at': datetime.now().isoformat()
}
return {
'success': True,
'message': f"Domain {full_subdomain} is ready",
'domain_data': domain_info
}
logger.info(f"POST URL: {url}")
response = requests.post(url, headers=self.headers, json=data, timeout=30)
logger.info(f"Response Status: {response.status_code}")
if response.status_code in [200, 201]:
domain_info = {
'domain': full_subdomain,
'subdomain': subdomain_name,
'username': username,
'record_type': 'A',
'ip_address': self.node_ip,
'ttl': 3600,
'created_at': datetime.now().isoformat()
}
logger.info("="*60)
logger.info(f"✅ SUCCESS: Subdomain created!")
logger.info(f"Domain: {full_subdomain}")
logger.info(f"Points to: {self.node_ip}")
logger.info("="*60)
# Step 5: Create www CNAME record
self._create_www_cname(username, subdomain_name)
return {
'success': True,
'message': f"Subdomain {full_subdomain} created successfully",
'domain_data': domain_info
}
else:
error_msg = f"API error: {response.status_code} - {response.text}"
logger.error("="*60)
logger.error(f"❌ FAILED to create subdomain")
logger.error(f"Status: {response.status_code}")
logger.error(f"Error: {response.text}")
logger.error("="*60)
return {
'success': False,
'message': error_msg,
'domain_data': None
}
except requests.exceptions.RequestException as e:
logger.error(f"Network error creating subdomain: {e}")
@@ -603,30 +522,69 @@ class DomainManager:
'domain_data': None
}
def _create_www_cname(self, username: str, subdomain_name: str):
"""Create www CNAME record for the subdomain"""
try:
user_domain = f"u-{username}.{self.base_domain}"
def ensure_user_namespace(self, username: str) -> Dict:
"""Ensure the base and wildcard CNAME records exist for a user."""
username = (username or '').lower().strip()
if not self._is_valid_username_label(username):
return {'success': False, 'message': "Username cannot be represented as a DNS name"}
# Create www.subdomain CNAME pointing to the subdomain
cname_data = {
"subname": f"www.{subdomain_name}",
user_domain = f"u-{username}.{self.base_domain}"
for owner in (f"u-{username}", f"*.u-{username}"):
if not self._ensure_namespace_cname(owner):
return {
'success': False,
'message': f"Could not safely provision DNS for {user_domain}; check for existing DNS records or a legacy delegation",
}
return {'success': True, 'message': f"DNS namespace is ready for {user_domain}"}
def _ensure_namespace_cname(self, owner: str) -> bool:
"""Create an idempotent CNAME in the configured parent zone."""
try:
path = f"{self.base_url}/domains/{self.base_domain}/rrsets/{quote(owner, safe='.-*')}/CNAME/"
response = requests.get(path, headers=self.headers, timeout=30)
if response.status_code == 200:
records = response.json().get('records', [])
return records == [f"{self.base_domain}."] or records == [self.base_domain]
if response.status_code != 404:
logger.error("Could not inspect DNS record %s (%s)", owner, response.status_code)
return False
# CNAME cannot coexist with other record types at the same owner.
for record_type in ('A', 'AAAA', 'TXT', 'MX', 'NS', 'DS', 'CAA', 'SRV', 'TLSA', 'HTTPS', 'SVCB'):
conflict = requests.get(
f"{self.base_url}/domains/{self.base_domain}/rrsets/{quote(owner, safe='.-*')}/{record_type}/",
headers=self.headers,
timeout=30,
)
if conflict.status_code == 200:
logger.error("Refusing to replace existing %s record at %s", record_type, owner)
return False
if conflict.status_code != 404:
logger.error("Could not inspect %s record at %s (%s)", record_type, owner, conflict.status_code)
return False
payload = {
"subname": owner,
"type": "CNAME",
"ttl": 3600,
"records": [f"{subdomain_name}.{user_domain}."] # Note the trailing dot
"records": [f"{self.base_domain}."]
}
url = f"{self.base_url}/domains/{user_domain}/rrsets/"
response = requests.post(url, headers=self.headers, json=cname_data, timeout=30)
if response.status_code in [200, 201]:
logger.info(f"✓ Created www CNAME for {subdomain_name}.{user_domain}")
else:
logger.warning(f"Failed to create www CNAME: {response.status_code}")
except Exception as e:
logger.error(f"Error creating www CNAME: {e}")
created = requests.post(
f"{self.base_url}/domains/{self.base_domain}/rrsets/",
headers=self.headers,
json=payload,
timeout=30,
)
if created.status_code in (200, 201):
return True
logger.error("Could not create CNAME %s (%s)", owner, created.status_code)
return False
except requests.exceptions.RequestException as exc:
logger.error("DNS API request failed for %s: %s", owner, exc)
return False
except (ValueError, TypeError) as exc:
logger.error("Invalid DNS API response for %s: %s", owner, exc)
return False
def _check_domain_exists(self, domain_name: str) -> Optional[bool]:
"""Check if a domain exists in deSEC"""
@@ -681,9 +639,53 @@ class DomainManager:
username = username.lower().strip()
subdomain_name = subdomain_name.lower().strip()
if not self._is_valid_username_label(username) or not self._is_valid_subdomain(subdomain_name):
return {'success': False, 'message': "Invalid username or subdomain"}
user_domain = f"u-{username}.{self.base_domain}"
full_subdomain = f"{subdomain_name}.{user_domain}"
# New namespaces use a shared wildcard in the parent zone. Do not
# remove shared DNS or stale child-zone data when releasing a claim.
parent_cname = requests.get(
f"{self.base_url}/domains/{self.base_domain}/rrsets/{quote(f'u-{username}', safe='.-')}/CNAME/",
headers=self.headers,
timeout=30,
)
if parent_cname.status_code == 200:
records = parent_cname.json().get('records', [])
if records in ([f"{self.base_domain}."], [self.base_domain]):
for owner in (f"{subdomain_name}.u-{username}", f"www.{subdomain_name}.u-{username}"):
rrsets = requests.get(
f"{self.base_url}/domains/{self.base_domain}/rrsets/",
headers=self.headers,
params={'subname': owner},
timeout=30,
)
if rrsets.status_code != 200:
return {'success': False, 'message': f"Could not inspect DNS records for {owner}"}
for rrset in rrsets.json():
delete_url = (
f"{self.base_url}/domains/{self.base_domain}/rrsets/"
f"{quote(owner, safe='.-*')}/{rrset['type']}/"
)
deleted = requests.delete(delete_url, headers=self.headers, timeout=30)
if deleted.status_code not in (200, 204, 404):
return {'success': False, 'message': f"Could not remove DNS record {owner} {rrset['type']}"}
return {'success': True, 'message': f"Domain claim {full_subdomain} deleted"}
elif parent_cname.status_code != 404:
return {'success': False, 'message': f"Could not inspect parent DNS for {user_domain}"}
# Existing delegated domains retain their old record cleanup until
# their namespace has been migrated to the parent-zone CNAME model.
zone_exists = self._check_domain_exists(user_domain)
if zone_exists is False:
return {
'success': True,
'message': f"Domain claim {full_subdomain} deleted"
}
if zone_exists is None:
return {'success': False, 'message': f"Could not inspect legacy zone {user_domain}"}
logger.info("="*60)
logger.info(f"Deleting subdomain: {full_subdomain}")
logger.info("="*60)
@@ -830,12 +832,20 @@ class DomainManager:
return True
def _is_valid_username_label(self, username: str) -> bool:
"""Validate the username's DNS path while preserving its dot separators."""
labels = f"u-{username}".split('.')
label_pattern = r"[a-z0-9](?:[a-z0-9-]*[a-z0-9])?"
return (
all(len(label) <= 63 and re.fullmatch(label_pattern, label) for label in labels)
and len('.'.join(labels) + f".{self.base_domain}") <= 253
)
def check_domain_availability(self, subdomain_name: str, username: str) -> Dict:
"""Check if a subdomain is available for the user"""
try:
username = username.lower().strip()
subdomain_name = subdomain_name.lower().strip()
user_domain = f"u-{username}.{self.base_domain}"
# First validate the subdomain name
if not self._is_valid_subdomain(subdomain_name):
@@ -844,32 +854,15 @@ class DomainManager:
'message': f"Invalid subdomain name: {subdomain_name}"
}
# Check if user domain exists first
if not self._check_domain_exists(user_domain):
return {
'available': True,
'message': f"Subdomain {subdomain_name} is available"
}
if not self._is_valid_username_label(username):
return {'available': False, 'message': "Username cannot be represented as a DNS name"}
# Check if the specific A record exists
url = f"{self.base_url}/domains/{user_domain}/rrsets/{subdomain_name}/A/"
response = requests.get(url, headers=self.headers, timeout=30)
if response.status_code == 404:
return {
'available': True,
'message': f"Subdomain {subdomain_name} is available"
}
elif response.status_code == 200:
return {
'available': False,
'message': f"Subdomain {subdomain_name} already exists"
}
else:
return {
'available': False,
'message': f"API error: {response.status_code}"
}
# Individual hostnames are claims stored in user_domains. Wildcard
# DNS means DNS existence cannot determine whether a name is free.
return {
'available': True,
'message': f"Subdomain {subdomain_name} is available"
}
except Exception as e:
return {