fix(domains): provision routes when domains are linked
Build and publish Docker image / build-and-publish (push) Successful in 10s
Build and publish Docker image / build-and-publish (push) Successful in 10s
This commit is contained in:
1 parent
8cb6820bea
commit
9822e43592
3 files changed
+131
-154
No files matched your search
+68
-70
@@ -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:
|
||||
|
||||
+19
-3
@@ -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
|
||||
|
||||
+44
-81
@@ -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'})
|
||||
|
||||
Reference in new issue
Block a user