feat(domains): migrate user namespaces to wildcard CNAMEs
Build and publish Docker image / build-and-publish (push) Successful in 8s
Build and publish Docker image / build-and-publish (push) Successful in 8s
This commit is contained in:
1 parent
82fa50e9d5
commit
2768f5f97d
6 files changed
+503
-296
No files matched your search
+145
-152
@@ -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 {
|
||||
|
||||
Reference in new issue
Block a user