Files
anchorhost-backend/domain_service.py
T
wfalkowski 6732ee9d47
Build and publish Docker image / build-and-publish (push) Successful in 8s
Fix Kubernetes namespaces for dotted usernames
2026-10-20 19:38:31 +00:00

700 lines
27 KiB
Python

"""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
from namespace_utils import user_namespace
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 = user_namespace(username)
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 user_namespace(username)
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 user_namespace(username)
# 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