"""Domain persistence, DNS validation, and Kubernetes ingress operations.""" from database_service import database_connection, dict_from_row from datetime import datetime from domain_runtime import domain_manager import json from kubernetes.client.rest import ApiException from kubernetes_service import custom_objects_api, k8s_manager import re from runtime_config import ( NODE_IP, logger, ) from typing import Dict, List, Optional import uuid __all__ = ['cleanup_orphaned_ingressroutes', 'create_custom_domain_record', 'create_domain_record', 'create_traefik_ingressroute', 'delete_domain_record', 'delete_traefik_ingressroute', 'get_domain_by_id', 'get_user_custom_domains', 'get_user_domains', 'is_domain_available', 'link_domain_to_server', 'unlink_domain_from_server', 'verify_domain_dns'] def create_custom_domain_record(domain_data: Dict) -> Optional[Dict]: """Create a custom domain record, then link it if a server was selected.""" try: with database_connection() as conn: cursor = conn.cursor() # Check if custom domain already exists for this user cursor.execute(''' SELECT id FROM user_domains WHERE domain = ? AND user_id = ? AND is_custom = TRUE ''', (domain_data['domain'], domain_data['user_id'])) if cursor.fetchone(): return None domain_id = f"custom-{str(uuid.uuid4())[:8]}" cursor.execute(''' INSERT INTO user_domains (id, user_id, domain, is_custom, verified, record_type, ip_address, ttl, server_id, created_at) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?) ''', ( domain_id, domain_data['user_id'], domain_data['domain'], True, # is_custom True, # verified (since we checked) domain_data['record_type'], domain_data['ip_address'], domain_data.get('ttl', 3600), None, domain_data.get('created_at', datetime.now().isoformat()) )) conn.commit() cursor.execute('SELECT * FROM user_domains WHERE id = ?', (domain_id,)) domain = dict_from_row(cursor.fetchone()) if domain and domain_data.get('server_id'): if link_domain_to_server(domain_id, domain_data['server_id']): domain['server_id'] = domain_data['server_id'] return domain except Exception as e: logger.error(f"Error creating custom domain record: {e}") return None def get_user_custom_domains(user_id: str) -> List[Dict]: """Get all custom domains for a user""" try: with database_connection() as conn: cursor = conn.cursor() cursor.execute(''' SELECT ud.*, s.name as server_name, s.type as server_type FROM user_domains ud LEFT JOIN servers s ON ud.server_id = s.id WHERE ud.user_id = ? AND ud.is_custom = TRUE ORDER BY ud.created_at DESC ''', (user_id,)) domains = [dict_from_row(row) for row in cursor.fetchall()] return domains except Exception as e: logger.error(f"Error getting user custom domains: {e}") return [] def verify_domain_dns(domain: str) -> Dict: """Verify if a domain points to our server IP and check domain restrictions""" try: import socket # Check if domain is from restricted domains restricted_domains = ['kvj.ovh', 'anchorhost.eu'] # Block subdomains of restricted domains for restricted in restricted_domains: if domain.endswith(f'.{restricted}') or domain == restricted: return { 'verified': False, 'message': f'Domain from {restricted} cannot be used as custom domain', 'current_ip': None, 'blocked': True, 'restricted_domain': restricted } # Check if domain already exists in database (used by another user) try: with database_connection() as conn: cursor = conn.cursor() cursor.execute(''' SELECT ud.*, u.username FROM user_domains ud JOIN users u ON ud.user_id = u.id WHERE ud.domain = ? AND ud.is_custom = TRUE LIMIT 1 ''', (domain.lower(),)) existing_domain = dict_from_row(cursor.fetchone()) if existing_domain: return { 'verified': False, 'message': f'Domain already used by user {existing_domain["username"]}', 'current_ip': None, 'already_used': True, 'used_by': existing_domain['username'] } except Exception as db_error: logger.error(f"Error checking database for domain: {db_error}") # Continue with DNS check even if database check fails # Get IP address from domain try: ip_address = socket.gethostbyname(domain) except socket.gaierror: return { 'verified': False, 'message': 'Domain does not exist or cannot be resolved', 'current_ip': None } # Check if it points to our server if ip_address == NODE_IP: return { 'verified': True, 'message': f'Domain points to our server ({NODE_IP})', 'ip_address': ip_address } else: return { 'verified': False, 'message': f'Domain points to {ip_address}, not our server ({NODE_IP})', 'current_ip': ip_address } except Exception as e: logger.error(f"Error verifying domain DNS: {e}") return { 'verified': False, 'message': f'Error verifying domain: {str(e)}', 'current_ip': None } def is_domain_available(domain: str, current_user_id: str) -> Dict: """Check if a custom domain is available for use""" try: domain = domain.lower().strip() # Check restricted domains restricted_domains = ['kvj.ovh', 'anchorhost.eu'] for restricted in restricted_domains: if domain.endswith(f'.{restricted}') or domain == restricted: return { 'available': False, 'message': f'Domain from {restricted} cannot be used as custom domain', 'reason': 'restricted' } # Check if already used by other users with database_connection() as conn: cursor = conn.cursor() cursor.execute(''' SELECT ud.*, u.username FROM user_domains ud JOIN users u ON ud.user_id = u.id WHERE ud.domain = ? AND ud.is_custom = TRUE AND ud.user_id != ? LIMIT 1 ''', (domain, current_user_id)) existing_domain = dict_from_row(cursor.fetchone()) if existing_domain: return { 'available': False, 'message': f'Domain already used by user {existing_domain["username"]}', 'reason': 'already_used' } return { 'available': True, 'message': 'Domain is available' } except Exception as e: logger.error(f"Error checking domain availability: {e}") return { 'available': False, 'message': f'Error checking domain availability: {str(e)}', 'reason': 'error' } def create_domain_record(domain_data: Dict) -> Optional[Dict]: """Create a domain record and provision its route when a server is selected.""" try: with database_connection() as conn: cursor = conn.cursor() # Check if domain already exists cursor.execute(''' SELECT id FROM user_domains WHERE full_domain = ? AND user_id = ? ''', (domain_data['full_domain'], domain_data['user_id'])) if cursor.fetchone(): return None domain_id = f"domain-{str(uuid.uuid4())[:8]}" cursor.execute(''' INSERT INTO user_domains (id, user_id, subdomain, full_domain, record_type, ip_address, ttl, server_id, created_at) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?) ''', ( domain_id, domain_data['user_id'], domain_data['subdomain'], domain_data['full_domain'], domain_data['record_type'], domain_data['ip_address'], domain_data.get('ttl', 3600), None, domain_data.get('created_at', datetime.now().isoformat()) )) conn.commit() cursor.execute('SELECT * FROM user_domains WHERE id = ?', (domain_id,)) domain = dict_from_row(cursor.fetchone()) server_id = domain_data.get('server_id') if domain and server_id and link_domain_to_server(domain_id, server_id): domain['server_id'] = server_id return domain except Exception as e: logger.error(f"Error creating domain record: {e}") return None def get_user_domains(user_id: str, include_serverless: bool = True) -> List[Dict]: """Get all domains for a user""" try: with database_connection() as conn: cursor = conn.cursor() query = ''' SELECT ud.*, s.name as server_name, s.type as server_type FROM user_domains ud LEFT JOIN servers s ON ud.server_id = s.id WHERE ud.user_id = ? ''' if not include_serverless: query += ' AND ud.server_id IS NOT NULL' query += ' ORDER BY ud.created_at DESC' cursor.execute(query, (user_id,)) domains = [dict_from_row(row) for row in cursor.fetchall()] return domains except Exception as e: logger.error(f"Error getting user domains: {e}") return [] def get_domain_by_id(domain_id: str) -> Optional[Dict]: """Get domain by ID""" try: domain_id = str(domain_id) with database_connection() as conn: cursor = conn.cursor() cursor.execute(''' SELECT ud.*, s.name as server_name, s.type as server_type, s.k8s_name FROM user_domains ud LEFT JOIN servers s ON ud.server_id = s.id WHERE ud.id = ? ''', (domain_id,)) return dict_from_row(cursor.fetchone()) except Exception as e: logger.error(f"Error getting domain: {e}") return None def cleanup_orphaned_ingressroutes(): """Clean up orphaned IngressRoutes (domains that no longer exist)""" if not k8s_manager.connected: return try: with database_connection() as conn: cursor = conn.cursor() # Get all user domains from database cursor.execute('SELECT ud.full_domain, u.username FROM user_domains ud JOIN users u ON ud.user_id = u.id') db_domains = {row['full_domain']: row['username'] for row in cursor.fetchall()} # For each user namespace, check IngressRoutes for domain, username in db_domains.items(): if not domain or not username: continue namespace = f"user-{username}".lower() subdomain = domain.split('.')[0] ingressroute_name = f"{subdomain}-ingress" try: # Check if IngressRoute exists ingress = custom_objects_api.get_namespaced_custom_object( group="traefik.io", version="v1alpha1", namespace=namespace, plural="ingressroutes", name=ingressroute_name ) # If domain exists in DB but is not linked to a server, delete the IngressRoute cursor.execute('SELECT server_id FROM user_domains WHERE full_domain = ?', (domain,)) domain_row = cursor.fetchone() if domain_row and not domain_row['server_id']: # Domain exists but not linked to any server, delete orphaned IngressRoute logger.info(f"Deleting orphaned IngressRoute for unlinked domain: {domain}") delete_traefik_ingressroute(domain, namespace) except ApiException as e: if e.status == 404: # IngressRoute doesn't exist, which is fine pass else: logger.error(f"Error checking IngressRoute for {domain}: {e}") except Exception as e: logger.error(f"Error cleaning up orphaned IngressRoutes: {e}") def delete_domain_record(domain_id: str) -> bool: """Delete domain record from database""" try: with database_connection() as conn: cursor = conn.cursor() # Get domain info before deleting (for logging/cleanup) cursor.execute('SELECT * FROM user_domains WHERE id = ?', (domain_id,)) domain = dict_from_row(cursor.fetchone()) if domain: logger.info(f"Deleting domain record: {domain.get('full_domain')} with server_id: {domain.get('server_id')}") # If domain was linked to a server, we should have already unlinked it # But just in case, log if there's still a server_id if domain.get('server_id'): logger.warning(f"Domain {domain['full_domain']} still has server_id {domain['server_id']} during deletion") # Delete the domain record cursor.execute('DELETE FROM user_domains WHERE id = ?', (domain_id,)) conn.commit() deleted = cursor.rowcount > 0 if deleted and domain: logger.info(f"✓ Deleted domain record: {domain.get('full_domain')}") return deleted except Exception as e: logger.error(f"Error deleting domain record: {e}") return False def create_traefik_ingressroute(domain: str, namespace: str, service_name: str, service_port: int) -> bool: """ Create a Traefik IngressRoute for the domain Supports both delegated domains (subdomain.u-username.anchorhost.eu) and custom domains (anchor.opime.online) """ try: if not k8s_manager.connected: logger.warning("Kubernetes not connected, skipping IngressRoute creation") return False # FIXED: Better validation if not domain or domain == 'None': logger.error(f"Invalid domain name: {domain}") return False if not namespace or not service_name or not service_port: logger.error(f"Missing required parameters: namespace={namespace}, service={service_name}, port={service_port}") return False # For IngressRoute name, sanitize the domain to be K8s-friendly # Replace dots with hyphens and remove any invalid characters ingressroute_name = domain.lower().replace('.', '-').replace('*', 'wildcard')[:63] # Ensure it starts and ends with alphanumeric ingressroute_name = re.sub(r'^[^a-z0-9]+|[^a-z0-9]+$', '', ingressroute_name) if not ingressroute_name: ingressroute_name = f"ingress-{str(uuid.uuid4())[:8]}" logger.info(f"Creating IngressRoute: {ingressroute_name}") logger.info(f" Domain: {domain}") logger.info(f" Namespace: {namespace}") logger.info(f" Service: {service_name}:{service_port}") # Create IngressRoute manifest ingressroute_body = { 'apiVersion': 'traefik.io/v1alpha1', 'kind': 'IngressRoute', 'metadata': { 'name': ingressroute_name, 'namespace': namespace, 'labels': { 'app': service_name, 'managed-by': 'anchorhost', 'domain': domain, 'domain-type': 'custom' if '.' in domain and not domain.endswith(f'.{domain_manager.base_domain}') else 'delegated' }, 'annotations': { 'anchorhost.eu/created-at': datetime.now().isoformat(), 'anchorhost.eu/domain': domain } }, 'spec': { 'entryPoints': ['web', 'websecure'], 'routes': [ { 'match': f"Host(`{domain}`)", 'kind': 'Rule', 'services': [ { 'name': service_name, 'port': service_port } ] } ], 'tls': { 'certResolver': 'letsencrypt' } } } # Replace an existing route atomically so a failed update cannot drop # a domain that is already serving traffic. try: existing = custom_objects_api.get_namespaced_custom_object( group="traefik.io", version="v1alpha1", namespace=namespace, plural="ingressroutes", name=ingressroute_name ) ingressroute_body['metadata']['resourceVersion'] = existing['metadata']['resourceVersion'] custom_objects_api.replace_namespaced_custom_object( group="traefik.io", version="v1alpha1", namespace=namespace, plural="ingressroutes", name=ingressroute_name, body=ingressroute_body ) logger.info(f"Successfully updated IngressRoute: {ingressroute_name}") return True except ApiException as e: if e.status != 404: logger.error(f"Failed to read existing IngressRoute: {e.status} - {e.reason}") return False # No prior route exists; create it. try: result = custom_objects_api.create_namespaced_custom_object( group="traefik.io", version="v1alpha1", namespace=namespace, plural="ingressroutes", body=ingressroute_body ) logger.info(f"✅ Successfully created IngressRoute: {ingressroute_name}") return True except ApiException as e: logger.error(f"Failed to create IngressRoute: {e.status} - {e.reason}") if hasattr(e, 'body'): logger.error(f"Error details: {e.body}") return False except Exception as e: logger.error(f"Unexpected error creating IngressRoute: {e}", exc_info=True) return False def delete_traefik_ingressroute(domain: str, namespace: str) -> bool: """ Delete a Traefik IngressRoute for the domain """ try: if not k8s_manager.connected: logger.warning("Kubernetes not connected, skipping IngressRoute deletion") return False # FIXED: Better validation if not domain or domain == 'None': logger.error(f"Invalid domain name: {domain}") return False ingressroute_name = domain.lower().replace('.', '-').replace('*', 'wildcard')[:63] ingressroute_name = re.sub(r'^[^a-z0-9]+|[^a-z0-9]+$', '', ingressroute_name) if not ingressroute_name: logger.error("Invalid domain format: %s", domain) return False logger.info(f"Deleting IngressRoute for domain: {domain} in namespace: {namespace}") try: custom_objects_api.delete_namespaced_custom_object( group="traefik.io", version="v1alpha1", namespace=namespace, plural="ingressroutes", name=ingressroute_name ) logger.info(f"✅ Successfully deleted IngressRoute: {ingressroute_name}") return True except ApiException as e: if e.status == 404: logger.info(f"IngressRoute {ingressroute_name} not found (may have been already deleted)") return True else: logger.error(f"Failed to delete IngressRoute: {e.status} - {e.reason}") return False except Exception as e: logger.error(f"Unexpected error deleting IngressRoute for domain {domain}: {e}", exc_info=True) return False def link_domain_to_server(domain_id: str, server_id: str) -> bool: """Create the Kubernetes route, then mark the domain as linked.""" try: with database_connection() as conn: cursor = conn.cursor() # Get domain details cursor.execute('SELECT * FROM user_domains WHERE id = ?', (domain_id,)) domain = dict_from_row(cursor.fetchone()) if not domain: logger.error(f"Domain not found: {domain_id}") return False # Get server details cursor.execute('SELECT * FROM servers WHERE id = ?', (server_id,)) server = dict_from_row(cursor.fetchone()) if not server: logger.error(f"Server not found: {server_id}") return False if server.get('type') not in ('website', 'vps'): logger.error("Domains can only be linked to website and VPS servers") return False # Get user details for namespace cursor.execute('SELECT username FROM users WHERE id = ?', (domain['user_id'],)) user = cursor.fetchone() if not user: logger.error(f"User not found: {domain['user_id']}") return False username = user['username'] namespace = (server.get('namespace') or f"user-{username}").lower() domain_host = domain.get('full_domain') or domain.get('domain') if not domain_host: logger.error("Domain record has no hostname: %s", domain_id) return False # Determine service name and port service_name = f"{server_id}-service" # Get the correct port for the service if server['ports']: ports_data = json.loads(server['ports']) if isinstance(server['ports'], str) else server['ports'] # For website/VPS, use port 80 if available, otherwise use first port service_port = 80 for port in ports_data: if port['internal'] == 80: service_port = 80 break else: # Use first available port if 80 not found service_port = ports_data[0]['internal'] if ports_data else 80 else: service_port = 80 logger.info(f"Linking domain {domain_host} to server {server['name']}") logger.info(f"Service: {service_name}, Port: {service_port}, Namespace: {namespace}") if not create_traefik_ingressroute( domain=domain_host, namespace=namespace, service_name=service_name, service_port=service_port ): logger.error(f"Failed to create IngressRoute for {domain_host}; domain remains unlinked") return False cursor.execute(''' UPDATE user_domains SET server_id = ?, updated_at = CURRENT_TIMESTAMP WHERE id = ? ''', (server_id, domain_id)) if cursor.rowcount != 1: delete_traefik_ingressroute(domain_host, namespace) conn.rollback() return False conn.commit() logger.info(f"Successfully linked domain {domain_host} to server {server['name']}") return True except Exception as e: logger.error(f"Error linking domain to server: {e}", exc_info=True) return False def unlink_domain_from_server(domain_id: str) -> bool: """Unlink a domain from any server and delete IngressRoute""" try: with database_connection() as conn: cursor = conn.cursor() # Get domain details before unlinking cursor.execute('SELECT * FROM user_domains WHERE id = ?', (domain_id,)) domain = dict_from_row(cursor.fetchone()) if not domain: logger.error(f"Domain not found: {domain_id}") return False domain_host = domain.get('full_domain') or domain.get('domain') if not domain_host: logger.error("Domain record has no hostname: %s", domain_id) return False server = None if domain.get('server_id'): cursor.execute('SELECT namespace FROM servers WHERE id = ?', (domain['server_id'],)) server = dict_from_row(cursor.fetchone()) # Get user details for namespace cursor.execute('SELECT username FROM users WHERE id = ?', (domain['user_id'],)) user = cursor.fetchone() if user: username = user['username'] namespace = ((server or {}).get('namespace') or f"user-{username}").lower() # Delete IngressRoute in Kubernetes if not delete_traefik_ingressroute(domain=domain_host, namespace=namespace): logger.error("Failed to remove IngressRoute for %s; domain remains linked", domain_host) return False # Update database to remove server link cursor.execute(''' UPDATE user_domains SET server_id = NULL, updated_at = CURRENT_TIMESTAMP WHERE id = ? ''', (domain_id,)) conn.commit() logger.info(f"Successfully unlinked domain {domain_host} from server") return True except Exception as e: logger.error(f"Error unlinking domain: {e}", exc_info=True) return False