diff --git a/domain_service.py b/domain_service.py index 45ca19e..d2890c5 100644 --- a/domain_service.py +++ b/domain_service.py @@ -12,10 +12,7 @@ from runtime_config import ( NODE_IP, logger, ) -from server_records import get_server_by_id -import time from typing import Dict, List, Optional -from user_service import get_user_by_id 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'] @@ -23,7 +20,7 @@ __all__ = ['cleanup_orphaned_ingressroutes', 'create_custom_domain_record', 'cre def create_custom_domain_record(domain_data: Dict) -> Optional[Dict]: - """Create custom domain record in database and create IngressRoute if server_id is provided""" + """Create a custom domain record, then link it if a server was selected.""" try: with database_connection() as conn: cursor = conn.cursor() @@ -53,7 +50,7 @@ def create_custom_domain_record(domain_data: Dict) -> Optional[Dict]: domain_data['record_type'], domain_data['ip_address'], domain_data.get('ttl', 3600), - domain_data.get('server_id'), + None, domain_data.get('created_at', datetime.now().isoformat()) )) conn.commit() @@ -61,35 +58,9 @@ def create_custom_domain_record(domain_data: Dict) -> Optional[Dict]: cursor.execute('SELECT * FROM user_domains WHERE id = ?', (domain_id,)) domain = dict_from_row(cursor.fetchone()) - # IMPORTANT: If server_id is provided, create IngressRoute if domain and domain_data.get('server_id'): - # Get user and server information - user = get_user_by_id(domain_data['user_id']) - server = get_server_by_id(domain_data['server_id']) - - if user and server: - namespace = f"user-{user['username']}".lower() - service_name = f"{domain_data['server_id']}-service" - - # Get the correct port for the service - service_port = 80 - if server['ports']: - ports_data = json.loads(server['ports']) if isinstance(server['ports'], str) else server['ports'] - for port in ports_data: - if port['internal'] == 80: - service_port = 80 - break - else: - service_port = ports_data[0]['internal'] if ports_data else 80 - - # Create IngressRoute for custom domain - logger.info(f"Creating IngressRoute for custom domain {domain_data['domain']}") - create_traefik_ingressroute( - domain=domain_data['domain'], - namespace=namespace, - service_name=service_name, - service_port=service_port - ) + if link_domain_to_server(domain_id, domain_data['server_id']): + domain['server_id'] = domain_data['server_id'] return domain @@ -249,7 +220,7 @@ def is_domain_available(domain: str, current_user_id: str) -> Dict: def create_domain_record(domain_data: Dict) -> Optional[Dict]: - """Create domain record in database""" + """Create a domain record and provision its route when a server is selected.""" try: with database_connection() as conn: cursor = conn.cursor() @@ -278,13 +249,17 @@ def create_domain_record(domain_data: Dict) -> Optional[Dict]: domain_data['record_type'], domain_data['ip_address'], domain_data.get('ttl', 3600), - domain_data.get('server_id'), + None, domain_data.get('created_at', datetime.now().isoformat()) )) conn.commit() cursor.execute('SELECT * FROM user_domains WHERE id = ?', (domain_id,)) - return dict_from_row(cursor.fetchone()) + 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}") @@ -485,22 +460,33 @@ def create_traefik_ingressroute(domain: str, namespace: str, service_name: str, } } - # Try to delete existing IngressRoute first (if updating) + # Replace an existing route atomically so a failed update cannot drop + # a domain that is already serving traffic. try: - custom_objects_api.delete_namespaced_custom_object( + existing = custom_objects_api.get_namespaced_custom_object( group="traefik.io", version="v1alpha1", namespace=namespace, plural="ingressroutes", name=ingressroute_name ) - logger.info(f"Deleted existing IngressRoute: {ingressroute_name}") - time.sleep(1) + 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.warning(f"Error deleting existing IngressRoute: {e.reason}") + logger.error(f"Failed to read existing IngressRoute: {e.status} - {e.reason}") + return False - # Create new IngressRoute + # No prior route exists; create it. try: result = custom_objects_api.create_namespaced_custom_object( group="traefik.io", @@ -570,7 +556,7 @@ def delete_traefik_ingressroute(domain: str, namespace: str) -> bool: return False def link_domain_to_server(domain_id: str, server_id: str) -> bool: - """Link a domain to a server and create IngressRoute""" + """Create the Kubernetes route, then mark the domain as linked.""" try: with database_connection() as conn: cursor = conn.cursor() @@ -591,6 +577,10 @@ def link_domain_to_server(domain_id: str, server_id: str) -> bool: 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() @@ -600,7 +590,11 @@ def link_domain_to_server(domain_id: str, server_id: str) -> bool: return False username = user['username'] - namespace = f"user-{username}".lower() + 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" @@ -621,33 +615,30 @@ def link_domain_to_server(domain_id: str, server_id: str) -> bool: else: service_port = 80 - logger.info(f"Linking domain {domain['full_domain']} to server {server['name']}") + logger.info(f"Linking domain {domain_host} to server {server['name']}") logger.info(f"Service: {service_name}, Port: {service_port}, Namespace: {namespace}") - # Update database + 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() - # Create IngressRoute in Kubernetes - if k8s_manager.connected: - success = create_traefik_ingressroute( - domain=domain['full_domain'], - namespace=namespace, - service_name=service_name, - service_port=service_port - ) - - if not success: - logger.warning(f"Failed to create IngressRoute for {domain['full_domain']}, but database updated") - # Don't rollback database - domain is still linked, just no routing yet - else: - logger.warning("Kubernetes not connected, IngressRoute not created") - - logger.info(f"✅ Successfully linked domain {domain['full_domain']} to server {server['name']}") + logger.info(f"Successfully linked domain {domain_host} to server {server['name']}") return True except Exception as e: @@ -668,21 +659,28 @@ def unlink_domain_from_server(domain_id: str) -> bool: 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 = f"user-{username}".lower() + namespace = ((server or {}).get('namespace') or f"user-{username}").lower() # Delete IngressRoute in Kubernetes - if k8s_manager.connected: - logger.info(f"Deleting IngressRoute for domain: {domain['full_domain']} in namespace: {namespace}") - delete_traefik_ingressroute( - domain=domain['full_domain'], - namespace=namespace - ) + 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(''' @@ -692,7 +690,7 @@ def unlink_domain_from_server(domain_id: str) -> bool: ''', (domain_id,)) conn.commit() - logger.info(f"✅ Successfully unlinked domain {domain['full_domain']} from server") + logger.info(f"Successfully unlinked domain {domain_host} from server") return True except Exception as e: diff --git a/routes/domains.py b/routes/domains.py index a057ca8..7062cad 100644 --- a/routes/domains.py +++ b/routes/domains.py @@ -112,12 +112,22 @@ def create_domain_api(): if not domain_manager: return jsonify({'error': 'Domain service not available'}), 503 - data = request.get_json() + data = request.get_json(silent=True) or {} subdomain = '' if data.get('useUsernameDomain') is True else data.get('subdomain') - server_id = data.get('serverId') + server_id = data.get('serverId') or None if subdomain is None: return jsonify({'error': 'Subdomain name required'}), 400 + if server_id and not isinstance(server_id, str): + return jsonify({'error': 'Server ID must be a string'}), 400 + + server = None + if server_id: + server = get_server_by_id(server_id) + if not server or server.get('owner_id') != user['id']: + return jsonify({'error': 'Server not found or access denied'}), 404 + if server.get('type') not in ('website', 'vps'): + return jsonify({'error': 'Domain can only be linked to Website or VPS servers'}), 400 # Check if subdomain is available availability_result = domain_manager.check_domain_availability(subdomain, user['username']) @@ -143,10 +153,16 @@ def create_domain_api(): db_domain = create_domain_record(domain_record) if db_domain: + routing_ready = not server_id or db_domain.get('server_id') == server_id + return jsonify({ 'success': True, 'domain': db_domain, - 'message': result['message'] + 'message': result['message'] if routing_ready else ( + 'Domain created, but routing could not be provisioned. ' + 'It remains unlinked; retry linking it after checking Kubernetes.' + ), + 'routingReady': routing_ready }), 201 else: return jsonify({'error': 'Domain created in DNS but failed to save to database'}), 500 diff --git a/websocket_service.py b/websocket_service.py index 015ab8a..c32d2e7 100644 --- a/websocket_service.py +++ b/websocket_service.py @@ -822,83 +822,6 @@ async def handle_websocket(websocket): if client_id in connected_clients: del connected_clients[client_id] -# Domain WebSocket handlers -# Replace the domain WebSocket handlers in app.py with these fixed versions: - -# Domain WebSocket handlers - """Handle create domain request""" - client_info = connected_clients.get(client_id) - if not client_info or not client_info['authenticated']: - await send_message(client_id, {'type': 'error', 'error': 'Not authenticated'}) - return - - user_id = client_info['user_id'] - username = client_info['username'] - subdomain_name = data.get('subdomain') - server_id = data.get('serverId') - - logger.info(f"DEBUG: Creating domain for user {username}, subdomain: {subdomain_name}") - - if not subdomain_name: - await send_message(client_id, {'type': 'error', 'error': 'Subdomain name required'}) - return - - if not domain_manager: - logger.error("Domain manager not initialized!") - await send_message(client_id, {'type': 'error', 'error': 'Domain service not available'}) - return - - # Add API token check - if not domain_manager.desesec_api_token: - logger.warning("Using default API token - this might not work!") - - try: - # Check if subdomain is available - availability_result = domain_manager.check_domain_availability(subdomain_name, username) - logger.info(f"DEBUG: Availability check: {availability_result}") - - if not availability_result.get('available', False): - await send_message(client_id, {'type': 'error', 'error': availability_result.get('message', 'Domain not available')}) - return - - # Create the subdomain - result = domain_manager.create_subdomain(username, subdomain_name) - logger.info(f"DEBUG: Create subdomain result: {result}") - - if result.get('success') and result.get('domain_data'): - # Save to database - domain_record = { - 'user_id': user_id, - 'subdomain': result['domain_data']['subdomain'], - 'full_domain': result['domain_data']['domain'], - 'record_type': result['domain_data']['record_type'], - 'ip_address': result['domain_data']['ip_address'], - 'ttl': result['domain_data'].get('ttl', 3600), - 'server_id': server_id, - 'created_at': result['domain_data'].get('created_at', datetime.now().isoformat()) - } - - db_domain = create_domain_record(domain_record) - - if db_domain: - await send_message(client_id, { - 'type': 'domain_created', - 'domain': db_domain, - 'message': result.get('message', 'Domain created successfully') - }) - else: - logger.error("Failed to save domain to database!") - await send_message(client_id, { - 'type': 'error', - 'error': 'Domain created in DNS but failed to save to database' - }) - else: - await send_message(client_id, {'type': 'error', 'error': result.get('message', 'Failed to create domain')}) - - except Exception as e: - logger.error(f"Error creating domain: {e}", exc_info=True) - await send_message(client_id, {'type': 'error', 'error': str(e)}) - async def handle_delete_domain(client_id, data): """Handle delete domain request - FIXED VERSION with proper domain type handling""" client_info = connected_clients.get(client_id) @@ -1384,7 +1307,21 @@ async def handle_create_domain(client_id, data): user_id = client_info['user_id'] username = client_info['username'] subdomain_name = '' if data.get('useUsernameDomain') is True else data.get('subdomain') - server_id = data.get('serverId') + server_id = data.get('serverId') or None + + if server_id: + if not isinstance(server_id, str): + await send_message(client_id, {'type': 'error', 'error': 'Server ID must be a string'}) + return + server = get_server_by_id(server_id) + requester = get_user_by_id(user_id) + owner_id = str(server.get('owner_id') or '') if server else '' + if not server or (owner_id != str(user_id) and (not requester or requester.get('role') != 'admin')): + await send_message(client_id, {'type': 'error', 'error': 'Server not found or access denied'}) + return + if server.get('type') not in ('website', 'vps'): + await send_message(client_id, {'type': 'error', 'error': 'Domain can only be linked to Website or VPS servers'}) + return logger.info(f"DEBUG: Creating domain for user {username}, subdomain: {subdomain_name}") @@ -1444,10 +1381,15 @@ async def handle_create_domain(client_id, data): db_domain = create_domain_record(domain_record) if db_domain: + routing_ready = not server_id or db_domain.get('server_id') == server_id await send_message(client_id, { 'type': 'domain_created', 'domain': db_domain, - 'message': result.get('message', 'Domain created successfully') + 'message': result.get('message', 'Domain created successfully') if routing_ready else ( + 'Domain created, but routing could not be provisioned. ' + 'It remains unlinked; retry linking it after checking Kubernetes.' + ), + 'routingReady': routing_ready }) else: logger.error("Failed to save domain to database!") @@ -3635,7 +3577,21 @@ async def handle_create_custom_domain(client_id, data): user_id = client_info['user_id'] domain = data.get('domain') - server_id = data.get('serverId') + server_id = data.get('serverId') or None + + if server_id: + if not isinstance(server_id, str): + await send_message(client_id, {'type': 'error', 'error': 'Server ID must be a string'}) + return + server = get_server_by_id(server_id) + requester = get_user_by_id(user_id) + owner_id = str(server.get('owner_id') or '') if server else '' + if not server or (owner_id != str(user_id) and (not requester or requester.get('role') != 'admin')): + await send_message(client_id, {'type': 'error', 'error': 'Server not found or access denied'}) + return + if server.get('type') not in ('website', 'vps'): + await send_message(client_id, {'type': 'error', 'error': 'Domain can only be linked to Website or VPS servers'}) + return if not domain: await send_message(client_id, {'type': 'error', 'error': 'Domain required'}) @@ -3691,10 +3647,17 @@ async def handle_create_custom_domain(client_id, data): db_domain = create_custom_domain_record(domain_record) if db_domain: + routing_ready = not server_id or db_domain.get('server_id') == server_id await send_message(client_id, { 'type': 'custom_domain_created', 'domain': db_domain, - 'message': f'Custom domain {domain} added successfully' + 'message': ( + f'Custom domain {domain} added successfully' + if routing_ready else + f'Custom domain {domain} was added but routing could not be provisioned. ' + 'It remains unlinked; retry linking it after checking Kubernetes.' + ), + 'routingReady': routing_ready }) else: await send_message(client_id, {'type': 'error', 'error': 'Failed to create custom domain'})