fix(domains): provision routes when domains are linked
Build and publish Docker image / build-and-publish (push) Successful in 10s

This commit is contained in:
wfalkowski committed 2026-10-20 18:50:46 +00:00
1 parent 8cb6820bea
commit 9822e43592
3 files changed
+131 -154

No files matched your search

+68 -70
View File
@@ -12,10 +12,7 @@ from runtime_config import (
NODE_IP, NODE_IP,
logger, logger,
) )
from server_records import get_server_by_id
import time
from typing import Dict, List, Optional from typing import Dict, List, Optional
from user_service import get_user_by_id
import uuid 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'] __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]: 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: try:
with database_connection() as conn: with database_connection() as conn:
cursor = conn.cursor() cursor = conn.cursor()
@@ -53,7 +50,7 @@ def create_custom_domain_record(domain_data: Dict) -> Optional[Dict]:
domain_data['record_type'], domain_data['record_type'],
domain_data['ip_address'], domain_data['ip_address'],
domain_data.get('ttl', 3600), domain_data.get('ttl', 3600),
domain_data.get('server_id'), None,
domain_data.get('created_at', datetime.now().isoformat()) domain_data.get('created_at', datetime.now().isoformat())
)) ))
conn.commit() 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,)) cursor.execute('SELECT * FROM user_domains WHERE id = ?', (domain_id,))
domain = dict_from_row(cursor.fetchone()) domain = dict_from_row(cursor.fetchone())
# IMPORTANT: If server_id is provided, create IngressRoute
if domain and domain_data.get('server_id'): if domain and domain_data.get('server_id'):
# Get user and server information if link_domain_to_server(domain_id, domain_data['server_id']):
user = get_user_by_id(domain_data['user_id']) domain['server_id'] = domain_data['server_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
)
return domain 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]: 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: try:
with database_connection() as conn: with database_connection() as conn:
cursor = conn.cursor() cursor = conn.cursor()
@@ -278,13 +249,17 @@ def create_domain_record(domain_data: Dict) -> Optional[Dict]:
domain_data['record_type'], domain_data['record_type'],
domain_data['ip_address'], domain_data['ip_address'],
domain_data.get('ttl', 3600), domain_data.get('ttl', 3600),
domain_data.get('server_id'), None,
domain_data.get('created_at', datetime.now().isoformat()) domain_data.get('created_at', datetime.now().isoformat())
)) ))
conn.commit() conn.commit()
cursor.execute('SELECT * FROM user_domains WHERE id = ?', (domain_id,)) 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: except Exception as e:
logger.error(f"Error creating domain record: {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: try:
custom_objects_api.delete_namespaced_custom_object( existing = custom_objects_api.get_namespaced_custom_object(
group="traefik.io", group="traefik.io",
version="v1alpha1", version="v1alpha1",
namespace=namespace, namespace=namespace,
plural="ingressroutes", plural="ingressroutes",
name=ingressroute_name name=ingressroute_name
) )
logger.info(f"Deleted existing IngressRoute: {ingressroute_name}") ingressroute_body['metadata']['resourceVersion'] = existing['metadata']['resourceVersion']
time.sleep(1) 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: except ApiException as e:
if e.status != 404: 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: try:
result = custom_objects_api.create_namespaced_custom_object( result = custom_objects_api.create_namespaced_custom_object(
group="traefik.io", group="traefik.io",
@@ -570,7 +556,7 @@ def delete_traefik_ingressroute(domain: str, namespace: str) -> bool:
return False return False
def link_domain_to_server(domain_id: str, server_id: str) -> bool: 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: try:
with database_connection() as conn: with database_connection() as conn:
cursor = conn.cursor() 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}") logger.error(f"Server not found: {server_id}")
return False 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 # Get user details for namespace
cursor.execute('SELECT username FROM users WHERE id = ?', (domain['user_id'],)) cursor.execute('SELECT username FROM users WHERE id = ?', (domain['user_id'],))
user = cursor.fetchone() user = cursor.fetchone()
@@ -600,7 +590,11 @@ def link_domain_to_server(domain_id: str, server_id: str) -> bool:
return False return False
username = user['username'] 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 # Determine service name and port
service_name = f"{server_id}-service" service_name = f"{server_id}-service"
@@ -621,33 +615,30 @@ def link_domain_to_server(domain_id: str, server_id: str) -> bool:
else: else:
service_port = 80 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}") 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(''' cursor.execute('''
UPDATE user_domains UPDATE user_domains
SET server_id = ?, updated_at = CURRENT_TIMESTAMP SET server_id = ?, updated_at = CURRENT_TIMESTAMP
WHERE id = ? WHERE id = ?
''', (server_id, domain_id)) ''', (server_id, domain_id))
if cursor.rowcount != 1:
delete_traefik_ingressroute(domain_host, namespace)
conn.rollback()
return False
conn.commit() conn.commit()
# Create IngressRoute in Kubernetes logger.info(f"Successfully linked domain {domain_host} to server {server['name']}")
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']}")
return True return True
except Exception as e: 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}") logger.error(f"Domain not found: {domain_id}")
return False 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 # Get user details for namespace
cursor.execute('SELECT username FROM users WHERE id = ?', (domain['user_id'],)) cursor.execute('SELECT username FROM users WHERE id = ?', (domain['user_id'],))
user = cursor.fetchone() user = cursor.fetchone()
if user: if user:
username = user['username'] username = user['username']
namespace = f"user-{username}".lower() namespace = ((server or {}).get('namespace') or f"user-{username}").lower()
# Delete IngressRoute in Kubernetes # Delete IngressRoute in Kubernetes
if k8s_manager.connected: if not delete_traefik_ingressroute(domain=domain_host, namespace=namespace):
logger.info(f"Deleting IngressRoute for domain: {domain['full_domain']} in namespace: {namespace}") logger.error("Failed to remove IngressRoute for %s; domain remains linked", domain_host)
delete_traefik_ingressroute( return False
domain=domain['full_domain'],
namespace=namespace
)
# Update database to remove server link # Update database to remove server link
cursor.execute(''' cursor.execute('''
@@ -692,7 +690,7 @@ def unlink_domain_from_server(domain_id: str) -> bool:
''', (domain_id,)) ''', (domain_id,))
conn.commit() 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 return True
except Exception as e: except Exception as e:
+19 -3
View File
@@ -112,12 +112,22 @@ def create_domain_api():
if not domain_manager: if not domain_manager:
return jsonify({'error': 'Domain service not available'}), 503 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') 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: if subdomain is None:
return jsonify({'error': 'Subdomain name required'}), 400 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 # Check if subdomain is available
availability_result = domain_manager.check_domain_availability(subdomain, user['username']) 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) db_domain = create_domain_record(domain_record)
if db_domain: if db_domain:
routing_ready = not server_id or db_domain.get('server_id') == server_id
return jsonify({ return jsonify({
'success': True, 'success': True,
'domain': db_domain, '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 }), 201
else: else:
return jsonify({'error': 'Domain created in DNS but failed to save to database'}), 500 return jsonify({'error': 'Domain created in DNS but failed to save to database'}), 500
+44 -81
View File
@@ -822,83 +822,6 @@ async def handle_websocket(websocket):
if client_id in connected_clients: if client_id in connected_clients:
del connected_clients[client_id] 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): async def handle_delete_domain(client_id, data):
"""Handle delete domain request - FIXED VERSION with proper domain type handling""" """Handle delete domain request - FIXED VERSION with proper domain type handling"""
client_info = connected_clients.get(client_id) 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'] user_id = client_info['user_id']
username = client_info['username'] username = client_info['username']
subdomain_name = '' if data.get('useUsernameDomain') is True else data.get('subdomain') 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}") 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) db_domain = create_domain_record(domain_record)
if db_domain: if db_domain:
routing_ready = not server_id or db_domain.get('server_id') == server_id
await send_message(client_id, { await send_message(client_id, {
'type': 'domain_created', 'type': 'domain_created',
'domain': db_domain, '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: else:
logger.error("Failed to save domain to database!") 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'] user_id = client_info['user_id']
domain = data.get('domain') 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: if not domain:
await send_message(client_id, {'type': 'error', 'error': 'Domain required'}) 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) db_domain = create_custom_domain_record(domain_record)
if db_domain: if db_domain:
routing_ready = not server_id or db_domain.get('server_id') == server_id
await send_message(client_id, { await send_message(client_id, {
'type': 'custom_domain_created', 'type': 'custom_domain_created',
'domain': db_domain, '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: else:
await send_message(client_id, {'type': 'error', 'error': 'Failed to create custom domain'}) await send_message(client_id, {'type': 'error', 'error': 'Failed to create custom domain'})