diff --git a/domain_service.py b/domain_service.py index d2890c5..55a91b0 100644 --- a/domain_service.py +++ b/domain_service.py @@ -7,6 +7,7 @@ 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, @@ -325,7 +326,7 @@ def cleanup_orphaned_ingressroutes(): for domain, username in db_domains.items(): if not domain or not username: continue - namespace = f"user-{username}".lower() + namespace = user_namespace(username) subdomain = domain.split('.')[0] ingressroute_name = f"{subdomain}-ingress" @@ -590,7 +591,7 @@ def link_domain_to_server(domain_id: str, server_id: str) -> bool: return False username = user['username'] - namespace = (server.get('namespace') or f"user-{username}").lower() + 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) @@ -675,7 +676,7 @@ def unlink_domain_from_server(domain_id: str) -> bool: if user: username = user['username'] - namespace = ((server or {}).get('namespace') or f"user-{username}").lower() + namespace = (server or {}).get('namespace') or user_namespace(username) # Delete IngressRoute in Kubernetes if not delete_traefik_ingressroute(domain=domain_host, namespace=namespace): diff --git a/kubernetes_service.py b/kubernetes_service.py index 891420c..9de042e 100644 --- a/kubernetes_service.py +++ b/kubernetes_service.py @@ -3,6 +3,7 @@ from datetime import datetime from kubernetes import client, config from kubernetes.client.rest import ApiException from kubernetes.stream import stream +from namespace_utils import is_valid_namespace_name import os import random from runtime_config import ( @@ -62,8 +63,9 @@ class KubernetesManager: return False def create_namespace(self, namespace_name: str, labels: Dict = None) -> bool: - # Kubernetes namespaces MUST be lowercase - namespace_name = namespace_name.lower() + if not is_valid_namespace_name(namespace_name): + logger.error("Refusing to create invalid Kubernetes namespace name: %r", namespace_name) + return False if not self.connected: logger.info(f"⚠ Kubernetes not connected, skipping namespace creation for {namespace_name}") diff --git a/namespace_utils.py b/namespace_utils.py new file mode 100644 index 0000000..80c32e1 --- /dev/null +++ b/namespace_utils.py @@ -0,0 +1,43 @@ +"""Helpers for translating account names to Kubernetes namespace names.""" + +from __future__ import annotations + +import hashlib +import re + + +_DNS_LABEL = re.compile(r"^[a-z0-9](?:[-a-z0-9]*[a-z0-9])?$") +_INVALID_DNS_LABEL_CHARS = re.compile(r"[^a-z0-9-]+") + + +def is_valid_namespace_name(namespace_name: str) -> bool: + """Return whether a name meets Kubernetes DNS-label constraints.""" + return ( + isinstance(namespace_name, str) + and len(namespace_name) <= 63 + and bool(_DNS_LABEL.fullmatch(namespace_name)) + ) + + +def user_namespace(username: str) -> str: + """Return a stable, DNS-label-safe namespace for a username. + + Existing valid usernames retain their current namespace. Names that need + normalization receive a short digest so normalization cannot silently + merge accounts such as ``miki.dz08`` and ``miki-dz08``. + """ + normalized_username = str(username).strip().lower() + if not normalized_username: + raise ValueError("Username must not be empty") + + candidate = f"user-{normalized_username}" + if is_valid_namespace_name(candidate): + return candidate + + safe_username = _INVALID_DNS_LABEL_CHARS.sub("-", normalized_username) + safe_username = re.sub(r"-+", "-", safe_username).strip("-") or "user" + digest = hashlib.sha256(normalized_username.encode("utf-8")).hexdigest()[:8] + suffix = f"-{digest}" + max_base_length = 63 - len("user-") - len(suffix) + safe_username = safe_username[:max_base_length].rstrip("-") or "user" + return f"user-{safe_username}{suffix}" diff --git a/routes/servers.py b/routes/servers.py index cba8712..fa2124c 100644 --- a/routes/servers.py +++ b/routes/servers.py @@ -6,6 +6,7 @@ from flask import Blueprint, jsonify, request import json from urllib.parse import urlsplit from kubernetes_service import k8s_manager +from namespace_utils import user_namespace import requests from werkzeug.utils import secure_filename from runtime_config import ( @@ -379,7 +380,7 @@ def download_plugin_to_server(): plugin_response.close() # Upload to server - namespace = f"user-{user['username']}".lower() + namespace = user_namespace(user['username']) pods = k8s_manager.get_pods_for_server(namespace, server_id) if not pods: diff --git a/server_lifecycle.py b/server_lifecycle.py index cd9d648..fb8241c 100644 --- a/server_lifecycle.py +++ b/server_lifecycle.py @@ -4,6 +4,7 @@ from database_service import database_connection from kubernetes import client from kubernetes_service import apps_v1, custom_objects_api, k8s_manager, v1 +from namespace_utils import user_namespace from runtime_config import ( logger, ) @@ -59,7 +60,7 @@ def delete_server(server_id: str, user_id: str) -> bool: pass return True - namespace = f"user-{owner['username']}".lower() + namespace = server.get('namespace') or user_namespace(owner['username']) try: custom_objects_api.delete_namespaced_custom_object( @@ -165,7 +166,7 @@ def start_server(server_id: str, user_id: str) -> bool: update_server_status(server_id, 'Running') return True - namespace = f"user-{owner['username']}".lower() + namespace = server.get('namespace') or user_namespace(owner['username']) try: patch = {'spec': {'runStrategy': 'Always'}} @@ -232,7 +233,7 @@ def stop_server(server_id: str, user_id: str) -> bool: update_server_status(server_id, 'Stopped') return True - namespace = f"user-{owner['username']}".lower() + namespace = server.get('namespace') or user_namespace(owner['username']) try: patch = {'spec': {'runStrategy': 'Halted'}} @@ -317,7 +318,7 @@ def restart_server(server_id: str, user_id: str, skip_checks: bool = False) -> b update_server_status(server_id, 'Running') return True - namespace = f"user-{owner['username']}".lower() + namespace = server.get('namespace') or user_namespace(owner['username']) update_server_status(server_id, 'Restarting') try: @@ -721,7 +722,7 @@ def adjust_server_parameters(server_id: str, user_id: str, new_cpu: str, new_ram conn.commit() # Update server resources in Kubernetes - namespace = f"user-{user['username']}".lower() + namespace = server.get('namespace') or user_namespace(user['username']) if k8s_manager.connected: if not update_server_resources_in_k8s(server, new_cpu, new_ram, new_storage, namespace): logger.warning(f"Failed to update server resources in K8s, but database was updated") diff --git a/server_provisioning.py b/server_provisioning.py index 9db6688..a0af31e 100644 --- a/server_provisioning.py +++ b/server_provisioning.py @@ -6,6 +6,7 @@ from datetime import datetime, timedelta, timezone import json from kubernetes import client from kubernetes_service import apps_v1, custom_objects_api, k8s_manager, v1 +from namespace_utils import user_namespace import random import re from runtime_config import ( @@ -75,7 +76,7 @@ def change_server_version(server_id: str, user_id: str, new_version: str, java_v logger.info(f"✓ Changed version for server {server_id} to {new_version} (billing date unchanged)") return cursor.rowcount > 0 - namespace = f"user-{owner['username']}".lower() + namespace = server.get('namespace') or user_namespace(owner['username']) if not k8s_manager.connected: # If K8s not connected, just update database @@ -368,7 +369,7 @@ def reinstall_server(server_id: str, user_id: str) -> bool: if not owner: raise Exception("Server owner not found") - namespace = f"user-{owner['username']}".lower() + namespace = server.get('namespace') or user_namespace(owner['username']) if not k8s_manager.connected: update_server_status(server_id, 'Running') @@ -1949,7 +1950,7 @@ def create_server(config: Dict, user_id: str, progress_callback=None) -> Dict: 'ip': NODE_IP, 'ports': ports, 'ownerId': user_id, - 'namespace': f"user-{user['username']}".lower(), + 'namespace': user_namespace(user['username']), 'k8s_name': server_id, 'is_demo': is_demo, 'demo_expires_at': demo_expires_at, @@ -1964,7 +1965,7 @@ def create_server(config: Dict, user_id: str, progress_callback=None) -> Dict: server = create_server_record(server_data) else: - namespace = f"user-{user['username']}".lower() + namespace = user_namespace(user['username']) sanitized_name = config['name'].lower().replace(' ', '-').replace('_', '-')[:20] server_id = f"{sanitized_name}-{str(uuid.uuid4())[:8]}" diff --git a/server_records.py b/server_records.py index f973003..5cd7345 100644 --- a/server_records.py +++ b/server_records.py @@ -2,6 +2,7 @@ from database_service import database_connection, dict_from_row +from namespace_utils import user_namespace import json from runtime_config import ( logger, @@ -116,7 +117,7 @@ def get_server_access_and_namespace(server_id: str, requester_id: str) -> Tuple[ if not owner: raise Exception("Server owner not found") - namespace = f"user-{owner['username']}".lower() + namespace = server.get('namespace') or user_namespace(owner['username']) return namespace, server def get_user_servers(user_id: str) -> List[Dict]: diff --git a/websocket_service.py b/websocket_service.py index a3cbda3..9aa065a 100644 --- a/websocket_service.py +++ b/websocket_service.py @@ -13,6 +13,7 @@ from kubernetes import client from kubernetes.client.rest import ApiException from kubernetes.stream import stream from kubernetes_service import custom_objects_api, k8s_manager, v1 +from namespace_utils import user_namespace import os import paramiko import pyotp @@ -1263,7 +1264,7 @@ async def handle_register(client_id, data): if k8s_manager.connected: try: - k8s_manager.create_namespace(f"user-{username}".lower()) + k8s_manager.create_namespace(user_namespace(username)) except Exception: pass @@ -2051,7 +2052,7 @@ async def handle_upload_file(client_id, data): }) return - namespace = f"user-{user['username']}".lower() + namespace = user_namespace(user['username']) pods = k8s_manager.get_pods_for_server(namespace, server_id) if not pods: @@ -3776,7 +3777,7 @@ async def handle_delete_custom_domain(client_id, data): # If domain was linked to a server, delete IngressRoute if domain.get('server_id'): - namespace = f"user-{username}".lower() + namespace = user_namespace(username) # Extract subdomain for IngressRoute name domain_name = domain['domain'] subdomain = domain_name.split('.')[0] if '.' in domain_name else domain_name