diff --git a/.env.example b/.env.example index 4042059c..3c74bcbe 100644 --- a/.env.example +++ b/.env.example @@ -320,9 +320,13 @@ VOLUME_STORAGE_PATH=/tmp/nukelab-volumes # Optional: XFS project quotas for kernel-enforced real-time volume limits. # Requires host filesystem to be XFS mounted with 'prjquota' and xfsprogs installed. # When enabled, volume size limits are enforced by the kernel (not just periodic checks). +# The backend container also needs read-only access to the underlying XFS block device +# so xfs_quota can operate on the bind-mounted volume directory. +# Find the device with: sudo df -P "$VOLUME_STORAGE_PATH" | tail -1 | awk '{print $1}' XFS_QUOTA_ENABLED=false XFS_PROJECT_ID_START=10000 XFS_PROJECTS_FILE=/data/xfs/projects.nukelab +# XFS_QUOTA_DEVICE_PATH=/dev/mapper/your-vg-data # Upload storage path inside the container for user-generated files (avatars, attachments). # Mounted via a named Docker/Podman volume for persistence. Host path is managed by the container engine. diff --git a/backend/Dockerfile b/backend/Dockerfile index cc12e913..7ca7f870 100644 --- a/backend/Dockerfile +++ b/backend/Dockerfile @@ -6,6 +6,7 @@ WORKDIR /app RUN apt-get update && apt-get install -y \ gcc \ libpq-dev \ + xfsprogs \ && rm -rf /var/lib/apt/lists/* # Install production Python dependencies diff --git a/backend/app/api/admin.py b/backend/app/api/admin.py index 48486ba1..a9dba774 100644 --- a/backend/app/api/admin.py +++ b/backend/app/api/admin.py @@ -535,6 +535,56 @@ async def update_system_max_balance( return {"message": f"System max balance updated to {request.amount}"} +# ========== Default Resource Quotas (Admin) ========== +class DefaultQuotaLimitsRequest(BaseModel): + max_cpu_total: float = Field(..., ge=0, description="Default CPU cores per user") + max_memory_total: str = Field(..., min_length=1, description="Default memory limit (e.g. 16g)") + max_disk_total: str = Field(..., min_length=1, description="Default disk limit (e.g. 100g)") + max_gpu_total: int = Field(..., ge=0, description="Default GPUs per user") + max_servers_total: int = Field(..., ge=0, description="Default servers per user") + + +@router.get("/quotas/default-limits") +async def get_default_quota_limits( + current_user: User = Depends(require_permissions(Permission.ADMIN_ACCESS)), + _jwt=Depends(require_jwt_auth()), + db: AsyncSession = Depends(get_db), +): + """Get the system-wide default resource quota limits applied to new users.""" + from app.services.setting_service import SettingService + + service = SettingService(db) + return {"default_limits": await service.get_quota_defaults()} + + +@router.put("/quotas/default-limits") +async def update_default_quota_limits( + request: DefaultQuotaLimitsRequest, + current_user: User = Depends(require_permissions(Permission.ADMIN_ACCESS)), + _jwt=Depends(require_jwt_auth()), + db: AsyncSession = Depends(get_db), +): + """Update the system-wide default resource quota limits applied to new users.""" + from app.services.activity_service import ActivityService + from app.services.setting_service import SettingService + + service = SettingService(db) + await service.set_quota_defaults(request.model_dump()) + + activity_service = ActivityService(db) + await activity_service.log( + action="quotas.update_default_limits", + target_type="system", + actor_id=str(current_user.id), + details=request.model_dump(), + ) + + return { + "message": "System default quota limits updated", + "default_limits": await service.get_quota_defaults(), + } + + class BulkSetAllowanceRequest(BaseModel): user_ids: list[str] = Field(..., min_length=1, description="Users to update") amount: int = Field(..., ge=0, description="New daily allowance (NUKE / day)") diff --git a/backend/app/api/auth.py b/backend/app/api/auth.py index 005fd68f..795f16e2 100644 --- a/backend/app/api/auth.py +++ b/backend/app/api/auth.py @@ -3,11 +3,13 @@ import asyncio import hashlib +import ipaddress import logging import secrets import uuid from dataclasses import dataclass from datetime import UTC, datetime, timedelta +from urllib.parse import urlparse import jwt from fastapi import APIRouter, Depends, HTTPException, Request, status @@ -860,6 +862,24 @@ async def verify_admin_auth(request: Request, db: AsyncSession = Depends(get_db) ) +def _cookie_domain_from_url(url: str) -> str | None: + """Return a usable cookie domain from a URL, or None for IPs/empty hosts. + + Browsers reject cookies whose ``Domain`` attribute is an IP address, so + this helper falls back to host-only cookies (no Domain attribute) for IPs. + For proper hostnames it returns the hostname verbatim, which lets the + cookie be shared across sub-paths such as ``/api`` and ``/grafana``. + """ + hostname = urlparse(url).hostname + if not hostname: + return None + try: + ipaddress.ip_address(hostname) + except ValueError: + return hostname + return None + + @router.get("/monitoring") async def monitoring_auth_redirect( request: Request, @@ -872,7 +892,7 @@ async def monitoring_auth_redirect( scoped to the site where they were set. Logging in on localhost:5173 and then navigating to localhost:8080 can fail because the cookie is not sent. This endpoint validates the token and explicitly sets the cookie on the - backend domain, then redirects to Prometheus/Grafana. + configured public domain, then redirects to Prometheus/Grafana. """ token = _extract_token_from_request(request) user = await _resolve_user_from_token(token, db) @@ -891,7 +911,7 @@ async def monitoring_auth_redirect( response.set_cookie( key="nukelab_token", value=token, - domain="localhost", + domain=_cookie_domain_from_url(settings.public_url), path="/", max_age=settings.session_max_age, httponly=settings.session_httponly, @@ -1154,7 +1174,11 @@ async def oauth_callback( profile.update(user_data["extra_profile"]) user.profile = profile else: - # Create new user + # Create new user with system default daily allowance + from app.services.setting_service import SettingService + + setting_service = SettingService(db) + default_allowance = await setting_service.get_daily_allowance() user = User( username=user_data["username"], email=user_data["email"], @@ -1165,6 +1189,8 @@ async def oauth_callback( role="user", is_active=True, is_verified=True, + nuke_balance=default_allowance, + daily_allowance=default_allowance, profile=user_data.get("extra_profile") or {}, ) db.add(user) diff --git a/backend/app/api/servers.py b/backend/app/api/servers.py index 7fcb98db..58348ff4 100644 --- a/backend/app/api/servers.py +++ b/backend/app/api/servers.py @@ -396,11 +396,15 @@ async def create_server( try: from app.models.server_volume import ServerVolume from app.services.volume_access_service import VolumeAccessService - from app.services.volume_service import VolumeService + from app.services.volume_service import VolumeService, make_docker_resource_name volume_service = VolumeService(db) volume_access = VolumeAccessService(db) + # Track auto-created volumes so we can clean them up if server creation fails. + auto_created_volume = None + auto_created_volume_names: list[str] = [] + # Build volume_mounts list from new or legacy format volume_mounts = [] @@ -414,9 +418,14 @@ async def create_server( } # Auto-create volume for empty volume_id mounts if not vm.volume_id: - safe_name = re.sub(r"[^a-zA-Z0-9_.-]", "-", body.name).lower() suffix = "data" if idx == 0 else f"data-{idx}" - volume_name = f"nukelab-server-{current_user.username}-{safe_name}-{suffix}" + volume_name = make_docker_resource_name( + prefix="nukelab-server", + user_identifier=str(current_user.id), + server_name=body.name, + suffix=suffix, + ) + auto_created_volume_names.append(volume_name) new_vol = await volume_service.create_volume( name=volume_name, display_name=f"{body.name} {suffix.title()}", @@ -437,13 +446,14 @@ async def create_server( ) # Auto-create primary volume if none provided - auto_created_volume = None - auto_created_volume_name = None if not volume_mounts: - # Sanitize volume name to ensure Docker compatibility - safe_name = re.sub(r"[^a-zA-Z0-9_.-]", "-", body.name).lower() - volume_name = f"nukelab-server-{current_user.username}-{safe_name}-data" - auto_created_volume_name = volume_name + volume_name = make_docker_resource_name( + prefix="nukelab-server", + user_identifier=str(current_user.id), + server_name=body.name, + suffix="data", + ) + auto_created_volume_names.append(volume_name) auto_created_volume = await volume_service.create_volume( name=volume_name, display_name=f"{body.name} Data", @@ -577,48 +587,52 @@ async def create_server( except Exception: await db.rollback() - # Clean up auto-created Docker volume on failure to allow retries. - # DB record is rolled back automatically by db.rollback() above. - if auto_created_volume_name: + # Clean up auto-created Docker volumes on failure to allow retries. + # DB records are rolled back automatically by db.rollback() above, + # but some drivers may commit before the rollback, so clean up defensively. + for volume_name in auto_created_volume_names: try: from app.container.client import get_container_client container_client = await get_container_client() try: - vol = await container_client.client.volumes.get(auto_created_volume_name) + vol = await container_client.client.volumes.get(volume_name) await vol.delete() - logger.info(f"Cleaned up Docker volume: {auto_created_volume_name}") + logger.info(f"Cleaned up Docker volume: {volume_name}") except Exception as e: - logger.warning( - f"Failed to delete Docker volume {auto_created_volume_name}: {e}" - ) + logger.warning(f"Failed to delete Docker volume {volume_name}: {e}") except Exception as e: - logger.warning(f"Failed to clean up auto-created volume: {e}") + logger.warning(f"Failed to clean up auto-created volume {volume_name}: {e}") - # Delete orphaned DB volume record using a fresh session to avoid greenlet issues - if auto_created_volume_name: + # Delete orphaned DB volume record using a fresh session to avoid greenlet issues try: from app.db.session import async_session from app.models.volume import Volume async with async_session() as cleanup_db: result = await cleanup_db.execute( - select(Volume).where(Volume.name == auto_created_volume_name) + select(Volume).where(Volume.name == volume_name) ) vol = result.scalar_one_or_none() if vol: await cleanup_db.delete(vol) await cleanup_db.commit() - logger.info(f"Cleaned up DB volume record: {auto_created_volume_name}") + logger.info(f"Cleaned up DB volume record: {volume_name}") except Exception as e: - logger.warning(f"Failed to clean up DB volume record: {e}") + logger.warning(f"Failed to clean up DB volume record for {volume_name}: {e}") # Also clean up any container that may have been created try: from app.container.client import get_container_client container_client = await get_container_client() - container_name = f"nukelab-server-{current_user.username}-{body.name}" + container_name = make_docker_resource_name( + prefix="nukelab-server", + user_identifier=str(current_user.id), + server_name=body.name, + suffix=None, + max_len=240, + ) try: container = await container_client.client.containers.get(container_name) await container.delete(force=True) @@ -1112,7 +1126,7 @@ async def _perform_server_stop( await db.commit() await broadcast_server_status_change(server.user_id, server_id, "stopped") return { - "message": "Server stopped", + "message": "Server already stopped", "server_id": server_id, "status": "stopped", } @@ -1509,7 +1523,7 @@ async def update_server( from app.services.plan_service import PlanService from app.services.quota_service import QuotaService from app.services.volume_access_service import VolumeAccessService - from app.services.volume_service import VolumeService + from app.services.volume_service import VolumeService, make_docker_resource_name volume_service = VolumeService(db) volume_access = VolumeAccessService(db) @@ -1578,9 +1592,13 @@ async def update_server( # Auto-create volume for empty volume_id mounts if not vm.volume_id: - safe_name = re.sub(r"[^a-zA-Z0-9_.-]", "-", server.name).lower() suffix = "data" if idx == 0 else f"data-{idx}" - volume_name = f"nukelab-server-{current_user.username}-{safe_name}-{suffix}" + volume_name = make_docker_resource_name( + prefix="nukelab-server", + user_identifier=str(current_user.id), + server_name=server.name, + suffix=suffix, + ) new_vol = await volume_service.create_volume( name=volume_name, display_name=f"{server.name} {suffix.title()}", @@ -2010,7 +2028,9 @@ async def create_server_access_token( ) try: - client_ip = request.client.host if request.client else None + from app.middleware.ip_restriction import _get_client_ip + + client_ip = _get_client_ip(request) user_agent = request.headers.get("user-agent") # Accessing a server is a strong activity signal; refresh idle timeout. diff --git a/backend/app/api/users.py b/backend/app/api/users.py index 4763ae15..61e7e8ea 100644 --- a/backend/app/api/users.py +++ b/backend/app/api/users.py @@ -35,6 +35,7 @@ class UserCreateRequest(BaseModel): last_name: str | None = Field(default=None, max_length=255) avatar_url: str | None = Field(default=None, max_length=500) credits: int = Field(default=500, ge=0) + daily_allowance: int | None = Field(default=None, ge=0) class UserUpdateRequest(BaseModel): @@ -440,6 +441,7 @@ async def create_user( last_name=request.last_name, avatar_url=request.avatar_url, credits=request.credits, + daily_allowance=request.daily_allowance, created_by=current_user, ) diff --git a/backend/app/container/spawner.py b/backend/app/container/spawner.py index 69d5cc63..63f31d1c 100644 --- a/backend/app/container/spawner.py +++ b/backend/app/container/spawner.py @@ -11,6 +11,7 @@ from app.container.client import ContainerClient, get_container_client from app.core.logging import get_logger from app.models.server import Server +from app.services.volume_service import make_docker_resource_name logger = get_logger(__name__) @@ -64,7 +65,13 @@ async def spawn( # Use existing server ID or generate new one server_id = server_id or str(uuid.uuid4()) - container_name = f"nukelab-server-{username}-{server_name}" + container_name = make_docker_resource_name( + prefix="nukelab-server", + user_identifier=user_id, + server_name=server_name, + suffix=None, + max_len=240, + ) # If a container with this name already exists (e.g., an orphaned container # from a previous failed stop/start/restart), remove it before attempting to @@ -118,7 +125,12 @@ async def spawn( # Fallback: generate name from id volume_name = f"nukelab-vol-{vol_id[:8]}" else: - volume_name = f"nukelab-server-{username}-{server_name}-data" + volume_name = make_docker_resource_name( + prefix="nukelab-server", + user_identifier=user_id, + server_name=server_name, + suffix="data", + ) await self._ensure_volume(volume_name) @@ -126,7 +138,12 @@ async def spawn( volumes[volume_name] = {"bind": mount_path, "mode": mount_mode} else: # Default single volume mounted at the user's home directory - volume_name = f"nukelab-server-{username}-{server_name}-data" + volume_name = make_docker_resource_name( + prefix="nukelab-server", + user_identifier=user_id, + server_name=server_name, + suffix="data", + ) await self._ensure_volume(volume_name) volumes[volume_name] = {"bind": home_mount_path, "mode": "rw"} @@ -148,7 +165,7 @@ async def spawn( # when Traefik sits behind an external SSL-terminating reverse proxy. f"traefik.http.routers.server-{server_id}.middlewares": f"server-{server_id}-slash@docker,server-{server_id}-strip@docker", f"traefik.http.middlewares.server-{server_id}-slash.redirectregex.regex": f"^https?://[^/]+({re.escape(route_prefix)})($|\\?.*$)", - f"traefik.http.middlewares.server-{server_id}-slash.redirectregex.replacement": f"$1/$2", + f"traefik.http.middlewares.server-{server_id}-slash.redirectregex.replacement": "$1/$2", f"traefik.http.middlewares.server-{server_id}-slash.redirectregex.permanent": "true", f"traefik.http.middlewares.server-{server_id}-strip.stripprefix.prefixes": route_prefix, "nukelab.server.id": server_id, @@ -179,8 +196,8 @@ async def spawn( "NUKELAB_AUTH_SERVER_ID": server_id, # nss-wrapper: every process, including Theia terminals, should see # the human username in whoami/id/ls instead of the fixed nukelab - # account. start.sh writes the actual passwd/group files at runtime. - "LD_PRELOAD": "/usr/lib/x86_64-linux-gnu/libnss_wrapper.so", + # account. start.sh creates the passwd/group files and then sets + # LD_PRELOAD itself, so we only pass the target paths here. "NSS_WRAPPER_PASSWD": "/tmp/nukelab-passwd", "NSS_WRAPPER_GROUP": "/tmp/nukelab-group", **(env_vars or {}), @@ -271,7 +288,10 @@ async def spawn( for mount_path in mount_paths_to_fix: try: - exec_instance = await container.exec(["chmod", "777", mount_path]) + # Run as root so this works in hardened mode, where the + # container user is 65532 and cannot chmod a root-owned + # named volume that was initialized by an earlier run. + exec_instance = await container.exec(["chmod", "777", mount_path], user="root") await exec_instance.start(detach=True) await asyncio.sleep(0.2) except Exception as e: diff --git a/backend/app/services/quota_service.py b/backend/app/services/quota_service.py index 0e0584c4..e017d163 100644 --- a/backend/app/services/quota_service.py +++ b/backend/app/services/quota_service.py @@ -24,6 +24,13 @@ class QuotaService: def __init__(self, db: AsyncSession): self.db = db + async def get_default_limits(self) -> dict[str, float | int | str]: + """Return system-wide default quota limits.""" + from app.services.setting_service import SettingService + + service = SettingService(self.db) + return await service.get_quota_defaults() + async def get_user_quota(self, user_id: str) -> ResourceQuota | None: """Get quota for a user""" result = await self.db.execute( @@ -35,7 +42,15 @@ async def get_or_create_user_quota(self, user_id: str) -> ResourceQuota: """Get or create quota for a user""" quota = await self.get_user_quota(user_id) if not quota: - quota = ResourceQuota(user_id=uuid.UUID(user_id)) + defaults = await self.get_default_limits() + quota = ResourceQuota( + user_id=uuid.UUID(user_id), + max_cpu_total=defaults["max_cpu_total"], + max_memory_total=defaults["max_memory_total"], + max_disk_total=defaults["max_disk_total"], + max_gpu_total=defaults["max_gpu_total"], + max_servers_total=defaults["max_servers_total"], + ) self.db.add(quota) await self.db.commit() await self.db.refresh(quota) @@ -84,13 +99,7 @@ async def list_quotas( rows = result.all() items = [] - default_limits = { - "max_cpu_total": 8.0, - "max_memory_total": "16g", - "max_disk_total": "100g", - "max_gpu_total": 0, - "max_servers_total": 5, - } + default_limits = await self.get_default_limits() for user, quota in rows: limits = quota.to_dict()["limits"] if quota else default_limits diff --git a/backend/app/services/setting_service.py b/backend/app/services/setting_service.py index 45a41b80..c6b8d91c 100644 --- a/backend/app/services/setting_service.py +++ b/backend/app/services/setting_service.py @@ -118,6 +118,51 @@ async def set_max_balance(self, amount: int) -> None: await self.set("credits_max_balance", str(amount)) settings.credits_max_balance = amount + async def get_quota_defaults(self) -> dict[str, float | int | str]: + """Return system-wide default resource quota limits. + + Reads from the database and falls back to the in-process config + defaults so values written by other workers are observed. + """ + defaults = { + "max_cpu_total": 8.0, + "max_memory_total": "16g", + "max_disk_total": "100g", + "max_gpu_total": 0, + "max_servers_total": 5, + } + for key in defaults: + value = await self.get(f"quota_default_{key}") + if value is None: + continue + if key in ("max_cpu_total",): + try: + defaults[key] = float(value) + except ValueError: + logger.warning(f"Invalid {key} value: {value}") + elif key in ("max_gpu_total", "max_servers_total"): + try: + defaults[key] = int(value) + except ValueError: + logger.warning(f"Invalid {key} value: {value}") + else: + defaults[key] = value + return defaults + + async def set_quota_defaults(self, defaults: dict[str, float | int | str]) -> None: + """Persist system-wide default resource quota limits.""" + valid_keys = { + "max_cpu_total", + "max_memory_total", + "max_disk_total", + "max_gpu_total", + "max_servers_total", + } + for key, value in defaults.items(): + if key not in valid_keys: + continue + await self.set(f"quota_default_{key}", str(value)) + async def get_maintenance(self) -> dict: """Get current maintenance mode settings (from DB or fallback to config).""" mode_str = await self.get("maintenance_mode") diff --git a/backend/app/services/user_service.py b/backend/app/services/user_service.py index 5d7de6b8..d3190ac7 100644 --- a/backend/app/services/user_service.py +++ b/backend/app/services/user_service.py @@ -116,9 +116,14 @@ async def create_user( avatar_url: str | None = None, use_gravatar: bool = True, credits: int = 500, + daily_allowance: int | None = None, created_by: User | None = None, ) -> User: - """Create a new user""" + """Create a new user. + + ``daily_allowance`` defaults to the persisted system default + (``credits_daily_allowance``). Pass an explicit value to override it. + """ # Validate role if not is_valid_role(role): @@ -139,6 +144,13 @@ async def create_user( if existing: raise HTTPException(status_code=status.HTTP_409_CONFLICT, detail="Email already exists") + # Resolve daily allowance default from system settings when not provided + if daily_allowance is None: + from app.services.setting_service import SettingService + + setting_service = SettingService(self.db) + daily_allowance = await setting_service.get_daily_allowance() + # Create user user = User( username=username, @@ -149,7 +161,7 @@ async def create_user( last_name=last_name, avatar_url=avatar_url, nuke_balance=credits, - daily_allowance=credits, + daily_allowance=daily_allowance, is_active=True, is_verified=True, preferences={"use_gravatar": use_gravatar}, diff --git a/backend/app/services/volume_service.py b/backend/app/services/volume_service.py index 1a3450a0..c9a0b4c5 100644 --- a/backend/app/services/volume_service.py +++ b/backend/app/services/volume_service.py @@ -5,9 +5,12 @@ Volume management service with quota enforcement. """ +import re from datetime import UTC, datetime from typing import Any +import aiodocker +from fastapi import HTTPException, status from sqlalchemy import func, or_, select from sqlalchemy.ext.asyncio import AsyncSession from sqlalchemy.orm import selectinload @@ -22,6 +25,65 @@ logger = get_logger(__name__) +# Docker volume names must start with an alphanumeric character and contain +# only letters, numbers, underscores, dots, and hyphens. +_DOCKER_VOLUME_NAME_PATTERN = re.compile(r"^[a-zA-Z0-9][a-zA-Z0-9_.-]*$") +_MAX_VOLUME_NAME_LEN = 255 + + +def _sanitize_docker_name_component(value: str) -> str: + """Sanitize a string for use as a Docker volume name component. + + Lowercases the value, replaces invalid characters with hyphens, and + strips leading/trailing separators so joined names remain valid. + """ + value = value.lower() + value = re.sub(r"[^a-z0-9_.-]+", "-", value) + value = re.sub(r"^[_.-]+|[_.-]+$", "", value) + return value + + +def make_docker_resource_name( + prefix: str, + user_identifier: str, + server_name: str, + suffix: str | None = "data", + max_len: int = 240, +) -> str: + """Build a Docker-compatible resource name from components. + + Each component is sanitized and lowercased. The resulting name always + starts with an alphanumeric character and only contains characters + allowed in Docker names. It is truncated if it exceeds `max_len`. + + ``user_identifier`` should be the user's UUID so that two users whose + sanitized usernames collide still get distinct Docker resource names. + + Pass ``suffix=None`` to omit the suffix segment (useful for container + names that follow the ``nukelab-server-{user_id}-{server_name}`` pattern). + """ + safe_prefix = _sanitize_docker_name_component(prefix) or "nukelab" + safe_user = _sanitize_docker_name_component(user_identifier) or "user" + safe_server_name = _sanitize_docker_name_component(server_name) or "server" + safe_suffix = _sanitize_docker_name_component(suffix) if suffix else None + + if safe_suffix: + base = f"{safe_prefix}-{safe_user}-{safe_server_name}-{safe_suffix}" + reserved_len = len(f"{safe_prefix}-{safe_user}--{safe_suffix}") + else: + base = f"{safe_prefix}-{safe_user}-{safe_server_name}" + reserved_len = len(f"{safe_prefix}-{safe_user}-") + + if len(base) > max_len: + # Truncate the server-name component while preserving prefix and suffix. + allowed_server_len = max_len - reserved_len + safe_server_name = safe_server_name[: max(1, allowed_server_len)] + if safe_suffix: + base = f"{safe_prefix}-{safe_user}-{safe_server_name}-{safe_suffix}" + else: + base = f"{safe_prefix}-{safe_user}-{safe_server_name}" + return base + class VolumeService: """Docker volume management with database tracking""" @@ -59,18 +121,38 @@ async def create_volume( visibility: str = "private", ) -> Volume: """Create a new volume record and Docker volume""" + if ( + not name + or len(name) > _MAX_VOLUME_NAME_LEN + or not _DOCKER_VOLUME_NAME_PATTERN.match(name) + ): + raise HTTPException( + status_code=status.HTTP_400_BAD_REQUEST, + detail=f"Invalid Docker volume name: {name!r}", + ) + container_client = await get_container_client() # Create Docker volume - await container_client.client.volumes.create( - { - "Name": name, - "Labels": { - "nukelab.managed": "true", - "nukelab.user.id": owner_id, - }, - } - ) + try: + await container_client.client.volumes.create( + { + "Name": name, + "Labels": { + "nukelab.managed": "true", + "nukelab.user.id": owner_id, + }, + } + ) + except aiodocker.DockerError as e: + logger.warning( + "Docker volume creation failed", + extra={"volume_name": name, "error": e.message}, + ) + raise HTTPException( + status_code=status.HTTP_400_BAD_REQUEST, + detail=f"Failed to create Docker volume: {e.message}", + ) from e # Create database record volume = Volume( diff --git a/backend/app/services/xfs_quota_service.py b/backend/app/services/xfs_quota_service.py index 06415551..c00f1340 100644 --- a/backend/app/services/xfs_quota_service.py +++ b/backend/app/services/xfs_quota_service.py @@ -8,14 +8,14 @@ du-based enforcement task (which serves as fallback on non-XFS filesystems). Requirements: - - Host filesystem must be XFS mounted with prjquota - - xfsprogs installed on host (xfs_quota, xfs_io) - - Container must have CAP_SYS_ADMIN or run privileged to run xfs_quota + - Host filesystem must be XFS mounted with prjquota/pquota + - xfsprogs installed inside the backend container (xfs_quota, xfs_io) + - Container must have CAP_SYS_ADMIN to run xfs_quota -Setup on host: - mount -o remount,prjquota /var/lib/docker/volumes +Setup on host (replace /data/docker with your Docker data-root): + mount -o remount,prjquota /data/docker # or in fstab: - /dev/sdXn /var/lib/docker/volumes xfs defaults,prjquota 0 0 + /dev/sdXn /data/docker xfs defaults,prjquota 0 0 """ import hashlib diff --git a/backend/tests/api/auth/test_auth_edge_cases.py b/backend/tests/api/auth/test_auth_edge_cases.py index 5a3e10e1..34c24635 100644 --- a/backend/tests/api/auth/test_auth_edge_cases.py +++ b/backend/tests/api/auth/test_auth_edge_cases.py @@ -333,3 +333,73 @@ async def test_cleanup_expired_refresh_tokens(self, db_session, test_user): count = await cleanup_expired_refresh_tokens(db_session) assert count >= 1 + + +class TestMonitoringRedirect: + """Tests for /api/auth/monitoring redirect shim.""" + + @pytest.mark.asyncio + async def test_monitoring_redirect_requires_admin(self, client, admin_token, user_token): + """Admin tokens get a redirect cookie; regular user tokens get 403.""" + response = await client.get( + "/api/auth/monitoring?redirect=/grafana", + headers={"Authorization": f"Bearer {admin_token}"}, + follow_redirects=False, + ) + assert response.status_code == 307 + assert response.headers["location"] == "/grafana" + assert "nukelab_token=" in response.headers.get("set-cookie", "") + + response = await client.get( + "/api/auth/monitoring?redirect=/grafana", + headers={"Authorization": f"Bearer {user_token}"}, + follow_redirects=False, + ) + assert response.status_code == 403 + + @pytest.mark.asyncio + async def test_monitoring_redirect_invalid_token(self, client): + """Invalid or missing tokens are rejected before redirect.""" + response = await client.get( + "/api/auth/monitoring?redirect=/grafana", + headers={"Authorization": "Bearer invalid-token"}, + follow_redirects=False, + ) + assert response.status_code == 401 + assert response.json()["detail"] == "Invalid token" + + @pytest.mark.asyncio + async def test_monitoring_redirect_sanitizes_open_redirect(self, client, admin_token): + """Only known monitoring paths are allowed as redirect targets.""" + response = await client.get( + "/api/auth/monitoring?redirect=https://evil.example.com", + headers={"Authorization": f"Bearer {admin_token}"}, + follow_redirects=False, + ) + assert response.status_code == 307 + assert response.headers["location"] == "/grafana" + + @pytest.mark.asyncio + async def test_monitoring_cookie_domain_follows_public_url(self, client, admin_token): + """Cookie domain is derived from settings.public_url, not hardcoded localhost.""" + from app.api.auth import _cookie_domain_from_url + + original_public_url = settings.public_url + try: + settings.public_url = "https://lab.nukehub.org" + response = await client.get( + "/api/auth/monitoring?redirect=/grafana", + headers={"Authorization": f"Bearer {admin_token}"}, + follow_redirects=False, + ) + assert response.status_code == 307 + set_cookie = response.headers.get("set-cookie", "") + assert "nukelab_token=" in set_cookie + assert "Domain=lab.nukehub.org" in set_cookie + finally: + settings.public_url = original_public_url + + assert _cookie_domain_from_url("http://localhost:8080") == "localhost" + assert _cookie_domain_from_url("https://lab.nukehub.org") == "lab.nukehub.org" + assert _cookie_domain_from_url("http://192.168.1.100") is None + assert _cookie_domain_from_url("") is None diff --git a/backend/tests/api/quotas/test_quotas.py b/backend/tests/api/quotas/test_quotas.py index 2f68b290..954cb21d 100644 --- a/backend/tests/api/quotas/test_quotas.py +++ b/backend/tests/api/quotas/test_quotas.py @@ -156,6 +156,68 @@ async def test_update_user_quota_forbidden_for_user(self, client, user_token, te ) assert response.status_code == 403 + @pytest.mark.asyncio + async def test_update_user_quota_persists(self, client, admin_token, test_user, db_session): + """Admin update should persist and be reflected in the list endpoint.""" + response = await client.put( + f"/api/quotas/{test_user.id}", + headers={"Authorization": f"Bearer {admin_token}"}, + json={ + "max_cpu_total": 4.0, + "max_memory_total": "32g", + "max_disk_total": "200g", + "max_gpu_total": 1, + "max_servers_total": 10, + }, + ) + assert response.status_code == 200 + data = response.json() + assert data["success"] is True + assert data["data"]["limits"]["max_cpu_total"] == 4.0 + assert data["data"]["limits"]["max_servers_total"] == 10 + + # Verify the list endpoint reflects the change + list_response = await client.get( + "/api/quotas/all", headers={"Authorization": f"Bearer {admin_token}"} + ) + assert list_response.status_code == 200 + items = list_response.json()["data"]["items"] + user_item = next((i for i in items if i["user_id"] == str(test_user.id)), None) + assert user_item is not None + assert user_item["limits"]["max_cpu_total"] == 4.0 + assert user_item["limits"]["max_memory_total"] == "32g" + assert user_item["limits"]["max_servers_total"] == 10 + + @pytest.mark.asyncio + async def test_get_default_quota_limits_as_admin(self, client, admin_token): + """Admin should get system default quota limits.""" + response = await client.get( + "/api/admin/quotas/default-limits", headers={"Authorization": f"Bearer {admin_token}"} + ) + assert response.status_code == 200 + data = response.json() + assert "default_limits" in data + assert data["default_limits"]["max_cpu_total"] == 8.0 + + @pytest.mark.asyncio + async def test_update_default_quota_limits_as_admin(self, client, admin_token): + """Admin should update system default quota limits.""" + response = await client.put( + "/api/admin/quotas/default-limits", + headers={"Authorization": f"Bearer {admin_token}"}, + json={ + "max_cpu_total": 4.0, + "max_memory_total": "8g", + "max_disk_total": "50g", + "max_gpu_total": 0, + "max_servers_total": 3, + }, + ) + assert response.status_code == 200 + data = response.json() + assert data["default_limits"]["max_cpu_total"] == 4.0 + assert data["default_limits"]["max_servers_total"] == 3 + """Coverage tests for smaller API modules: health, system, quotas, ip_restriction.""" diff --git a/backend/tests/api/servers/test_servers_create.py b/backend/tests/api/servers/test_servers_create.py index 715bcf7b..48c677ce 100644 --- a/backend/tests/api/servers/test_servers_create.py +++ b/backend/tests/api/servers/test_servers_create.py @@ -232,7 +232,7 @@ async def test_create_server_exception_cleanup( mock_vol = mock_vol_cls.return_value auto_vol = mock.Mock() auto_vol.id = uuid_mod.uuid4() - auto_vol.name = f"nukelab-server-{test_user.username}-cleanup-server-data" + auto_vol.name = f"nukelab-server-{str(test_user.id)}-cleanup-server-data" mock_vol.create_volume = mock.AsyncMock(return_value=auto_vol) mock_vol.record_mount = mock.AsyncMock() @@ -280,3 +280,56 @@ async def test_create_server_exception_cleanup( "try again" in response.json()["detail"].lower() or "contact support" in response.json()["detail"].lower() ) + + @pytest.mark.asyncio + async def test_create_server_volume_mount_auto_create_cleanup( + self, client, user_token, test_user, db_session, test_plan_env + ): + """Auto-created volume failure in volume_mounts must not raise UnboundLocalError.""" + plan, env = test_plan_env + + with mock.patch("app.services.quota_service.QuotaService") as mock_quota_cls: + mock_quota = mock_quota_cls.return_value + mock_quota.check_spawn_allowed = mock.AsyncMock(return_value={"allowed": True}) + with mock.patch( + "app.services.resource_pool_service.ResourcePoolService" + ) as mock_pool_cls: + mock_pool = mock_pool_cls.return_value + mock_pool.can_fit = mock.AsyncMock(return_value=True) + with mock.patch("app.services.credit_service.CreditService") as mock_credit_cls: + mock_credit = mock_credit_cls.return_value + mock_credit.check_sufficient_credits = mock.AsyncMock(return_value=True) + with mock.patch("app.services.volume_service.VolumeService") as mock_vol_cls: + mock_vol = mock_vol_cls.return_value + mock_vol.create_volume = mock.AsyncMock( + side_effect=Exception("volume create failed") + ) + mock_vol.check_volumes_quota = mock.AsyncMock( + return_value={"allowed": True} + ) + mock_vol._parse_memory = mock.Mock(return_value=10737418240) + with mock.patch( + "app.services.volume_access_service.VolumeAccessService" + ) as mock_access_cls: + mock_access = mock_access_cls.return_value + mock_access.can_access_volume = mock.AsyncMock(return_value=True) + + response = await client.post( + "/api/servers/", + headers={"Authorization": f"Bearer {user_token}"}, + json={ + "name": "cleanup-server", + "plan_id": str(plan.id), + "environment_id": str(env.id), + "volume_mounts": [ + { + "volume_id": "", + "mount_path": "/data", + "mode": "read_write", + } + ], + }, + ) + + assert response.status_code == 500 + assert "try again" in response.json()["detail"].lower() diff --git a/backend/tests/container/test_spawner.py b/backend/tests/container/test_spawner.py index d0798e6e..885f7339 100644 --- a/backend/tests/container/test_spawner.py +++ b/backend/tests/container/test_spawner.py @@ -566,9 +566,10 @@ class TestSpawnSuccess: @pytest.mark.asyncio async def test_spawn_default_volume(self, fresh_spawner): """spawn with no volume_mounts should use default volume.""" + user_id = str(uuid_mod.uuid4()) with mock.patch("app.container.spawner.settings.public_url", "http://test"): server = await fresh_spawner.spawn( - user_id=str(uuid_mod.uuid4()), + user_id=user_id, username="testuser", server_name="srv1", environment="dev", @@ -579,7 +580,7 @@ async def test_spawn_default_volume(self, fresh_spawner): assert server.status == "running" fresh_spawner.container_client.create_container.assert_awaited_once() call_kwargs = fresh_spawner.container_client.create_container.await_args.kwargs - assert "nukelab-server-testuser-srv1-data" in call_kwargs["volumes"] + assert f"nukelab-server-{user_id}-srv1-data" in call_kwargs["volumes"] assert call_kwargs["command"] == "/start.sh" assert call_kwargs["hostname"] == "NukeLab" @@ -661,9 +662,10 @@ async def test_spawn_with_env_vars(self, fresh_spawner): @pytest.mark.asyncio async def test_spawn_with_volume_mounts_no_vol_id(self, fresh_spawner): """spawn with volume_mounts lacking volume_id should generate default volume name.""" + user_id = str(uuid_mod.uuid4()) with mock.patch("app.container.spawner.settings.public_url", "http://test"): await fresh_spawner.spawn( - user_id=str(uuid_mod.uuid4()), + user_id=user_id, username="testuser", server_name="srv1", volume_mounts=[{"mount_path": "/data", "mode": "read_write"}], @@ -671,14 +673,15 @@ async def test_spawn_with_volume_mounts_no_vol_id(self, fresh_spawner): call_kwargs = fresh_spawner.container_client.create_container.await_args.kwargs vols = call_kwargs["volumes"] - assert any("testuser-srv1-data" in k for k in vols) + assert any(f"{user_id}-srv1-data" in k for k in vols) @pytest.mark.asyncio async def test_spawn_with_volume_mounts_read_only(self, fresh_spawner): """spawn with read_only mode should use 'ro' bind mode.""" + user_id = str(uuid_mod.uuid4()) with mock.patch("app.container.spawner.settings.public_url", "http://test"): await fresh_spawner.spawn( - user_id=str(uuid_mod.uuid4()), + user_id=user_id, username="testuser", server_name="srv1", volume_mounts=[{"mount_path": "/data", "mode": "read_only"}], @@ -686,7 +689,7 @@ async def test_spawn_with_volume_mounts_read_only(self, fresh_spawner): call_kwargs = fresh_spawner.container_client.create_container.await_args.kwargs vols = call_kwargs["volumes"] - mount_info = next(v for k, v in vols.items() if "testuser-srv1-data" in k) + mount_info = next(v for k, v in vols.items() if f"{user_id}-srv1-data" in k) assert mount_info["mode"] == "ro" @pytest.mark.asyncio @@ -708,20 +711,23 @@ async def test_spawn_returns_server_with_url(self, fresh_spawner): @pytest.mark.asyncio async def test_spawn_waits_for_container_ready(self, fresh_spawner): """spawn should wait for container readiness before returning.""" + user_id = str(uuid_mod.uuid4()) with mock.patch("app.container.spawner.settings.public_url", "http://test"): await fresh_spawner.spawn( - user_id=str(uuid_mod.uuid4()), + user_id=user_id, username="testuser", server_name="srv1", ) fresh_spawner.container_client.wait_for_container_ready.assert_awaited_once_with( - "nukelab-server-testuser-srv1", "http://nukelab-server-testuser-srv1:8080/health" + f"nukelab-server-{user_id}-srv1", + f"http://nukelab-server-{user_id}-srv1:8080/health", ) @pytest.mark.asyncio async def test_spawn_removes_existing_container_before_create(self, fresh_spawner): """spawn should delete an existing container with the same name before creating a new one.""" + user_id = str(uuid_mod.uuid4()) mock_existing = mock.AsyncMock() fresh_spawner.container_client.client.containers.get = mock.AsyncMock( return_value=mock_existing @@ -730,13 +736,13 @@ async def test_spawn_removes_existing_container_before_create(self, fresh_spawne with mock.patch("app.container.spawner.settings.public_url", "http://test"): with mock.patch("asyncio.sleep"): await fresh_spawner.spawn( - user_id=str(uuid_mod.uuid4()), + user_id=user_id, username="testuser", server_name="srv1", ) fresh_spawner.container_client.client.containers.get.assert_awaited_once_with( - "nukelab-server-testuser-srv1" + f"nukelab-server-{user_id}-srv1" ) mock_existing.delete.assert_awaited_once_with(force=True) fresh_spawner.container_client.create_container.assert_awaited_once() @@ -744,6 +750,7 @@ async def test_spawn_removes_existing_container_before_create(self, fresh_spawne @pytest.mark.asyncio async def test_spawn_ignores_missing_existing_container(self, fresh_spawner): """spawn should proceed normally when no existing container is found.""" + user_id = str(uuid_mod.uuid4()) fresh_spawner.container_client.client.containers.get = mock.AsyncMock( side_effect=Exception("not found") ) @@ -751,13 +758,13 @@ async def test_spawn_ignores_missing_existing_container(self, fresh_spawner): with mock.patch("app.container.spawner.settings.public_url", "http://test"): with mock.patch("asyncio.sleep"): await fresh_spawner.spawn( - user_id=str(uuid_mod.uuid4()), + user_id=user_id, username="testuser", server_name="srv1", ) fresh_spawner.container_client.client.containers.get.assert_awaited_once_with( - "nukelab-server-testuser-srv1" + f"nukelab-server-{user_id}-srv1" ) fresh_spawner.container_client.create_container.assert_awaited_once() @@ -776,8 +783,8 @@ async def test_spawn_with_server_id(self, fresh_spawner): assert str(server.id) == sid @pytest.mark.asyncio - async def test_spawn_permission_fix_skips_home(self, fresh_spawner): - """spawn should skip chmod on /home/{username}.""" + async def test_spawn_permission_fix_includes_home(self, fresh_spawner): + """spawn should chmod /home/{username} and extra volume mounts.""" mock_exec = MockExec() mock_container = mock.AsyncMock() mock_container.id = str(uuid_mod.uuid4()) @@ -798,10 +805,11 @@ async def test_spawn_permission_fix_skips_home(self, fresh_spawner): ], ) - # Only /data should get chmod, not /home/testuser + # Home directory and extra mounts are both chmod-ed so the non-root + # container user can write to them in hardened mode. exec_calls = mock_container.exec.call_args_list paths = [c[0][0][2] for c in exec_calls] - assert "/home/testuser" not in paths + assert "/home/testuser" in paths assert "/data" in paths @pytest.mark.asyncio @@ -827,7 +835,8 @@ async def test_spawn_permission_fix_failure_logged(self, fresh_spawner): chmod_warnings = [ c for c in mock_warn.call_args_list if "Could not fix permissions" in c[0][0] ] - assert len(chmod_warnings) == 1 + # Home directory is always fixed, plus the supplied /data mount. + assert len(chmod_warnings) == 2 @pytest.mark.asyncio async def test_spawn_with_auth_volume(self, fresh_spawner): diff --git a/backend/tests/services/test_quota_service.py b/backend/tests/services/test_quota_service.py index a6758259..da03c1c1 100644 --- a/backend/tests/services/test_quota_service.py +++ b/backend/tests/services/test_quota_service.py @@ -43,6 +43,22 @@ async def test_get_or_create_user_quota_creates(self, db_session, test_user): assert result is not None assert result.user_id == test_user.id + @pytest.mark.asyncio + async def test_get_or_create_user_quota_uses_system_defaults(self, db_session, test_user): + """get_or_create_user_quota should apply system default limits.""" + from app.models.system_setting import SystemSetting + + db_session.add(SystemSetting(key="quota_default_max_cpu_total", value="16")) + db_session.add(SystemSetting(key="quota_default_max_memory_total", value="32g")) + db_session.add(SystemSetting(key="quota_default_max_servers_total", value="10")) + await db_session.commit() + + service = QuotaService(db_session) + result = await service.get_or_create_user_quota(str(test_user.id)) + assert result.max_cpu_total == 16.0 + assert result.max_memory_total == "32g" + assert result.max_servers_total == 10 + @pytest.mark.asyncio async def test_get_or_create_user_quota_returns_existing(self, db_session, test_user): """get_or_create_user_quota should return existing.""" diff --git a/backend/tests/services/test_setting_service.py b/backend/tests/services/test_setting_service.py index adc23c33..97f226dc 100644 --- a/backend/tests/services/test_setting_service.py +++ b/backend/tests/services/test_setting_service.py @@ -143,3 +143,53 @@ async def test_get_maintenance_fallback_to_config(self, db_session): result = await service.get_maintenance() assert result["maintenance_mode"] == settings.maintenance_mode assert result["maintenance_message"] == settings.maintenance_message + + +class TestSettingServiceQuotaDefaults: + """Tests for default resource quota settings.""" + + @pytest.mark.asyncio + async def test_get_quota_defaults_fallback(self, db_session): + """Should return hardcoded defaults when no settings exist.""" + service = SettingService(db_session) + result = await service.get_quota_defaults() + assert result["max_cpu_total"] == 8.0 + assert result["max_memory_total"] == "16g" + assert result["max_disk_total"] == "100g" + assert result["max_gpu_total"] == 0 + assert result["max_servers_total"] == 5 + + @pytest.mark.asyncio + async def test_get_quota_defaults_from_db(self, db_session): + """Should read defaults from system settings.""" + db_session.add(SystemSetting(key="quota_default_max_cpu_total", value="16")) + db_session.add(SystemSetting(key="quota_default_max_memory_total", value="32g")) + db_session.add(SystemSetting(key="quota_default_max_servers_total", value="10")) + await db_session.commit() + + service = SettingService(db_session) + result = await service.get_quota_defaults() + assert result["max_cpu_total"] == 16.0 + assert result["max_memory_total"] == "32g" + assert result["max_servers_total"] == 10 + + @pytest.mark.asyncio + async def test_set_quota_defaults(self, db_session): + """Should persist default quota settings.""" + service = SettingService(db_session) + await service.set_quota_defaults( + { + "max_cpu_total": 4.0, + "max_memory_total": "8g", + "max_disk_total": "50g", + "max_gpu_total": 0, + "max_servers_total": 3, + } + ) + + result = await service.get_quota_defaults() + assert result["max_cpu_total"] == 4.0 + assert result["max_memory_total"] == "8g" + assert result["max_disk_total"] == "50g" + assert result["max_gpu_total"] == 0 + assert result["max_servers_total"] == 3 diff --git a/backend/tests/services/test_user_service.py b/backend/tests/services/test_user_service.py index 2a334224..60770990 100644 --- a/backend/tests/services/test_user_service.py +++ b/backend/tests/services/test_user_service.py @@ -150,10 +150,44 @@ async def test_create_user_success(self, db_session): first_name="New", last_name="User", credits=1000, + daily_allowance=250, ) assert user.username == "newuser" assert user.nuke_balance == 1000 - assert user.daily_allowance == 1000 + assert user.daily_allowance == 250 + + @pytest.mark.asyncio + async def test_create_user_uses_system_default_daily_allowance(self, db_session): + """create_user should use the persisted system default daily allowance.""" + from app.models.system_setting import SystemSetting + + db_session.add(SystemSetting(key="credits_daily_allowance", value="10")) + await db_session.commit() + + service = UserService(db_session) + user = await service.create_user( + username="newuser", + email="new@example.com", + password="password123", + ) + assert user.daily_allowance == 10 + + @pytest.mark.asyncio + async def test_create_user_override_daily_allowance(self, db_session): + """create_user should allow caller to override the system default.""" + from app.models.system_setting import SystemSetting + + db_session.add(SystemSetting(key="credits_daily_allowance", value="10")) + await db_session.commit() + + service = UserService(db_session) + user = await service.create_user( + username="newuser", + email="new@example.com", + password="password123", + daily_allowance=50, + ) + assert user.daily_allowance == 50 @pytest.mark.asyncio async def test_create_user_invalid_role(self, db_session): diff --git a/backend/tests/services/test_volume_service.py b/backend/tests/services/test_volume_service.py index 4540db4e..01acb952 100644 --- a/backend/tests/services/test_volume_service.py +++ b/backend/tests/services/test_volume_service.py @@ -15,7 +15,7 @@ from app.models.user import User from app.models.volume import Volume from app.models.workspace_volume import WorkspaceVolume -from app.services.volume_service import VolumeService +from app.services.volume_service import VolumeService, make_docker_resource_name @pytest.fixture @@ -58,6 +58,64 @@ def test_get_volume_storage_paths(self, vol_service): assert len(paths) > 0 assert any("test-vol" in p for p in paths) + def test_make_docker_resource_name_basic(self): + name = make_docker_resource_name( + prefix="nukelab-server", + user_identifier="user-uuid-1234", + server_name="my-server", + suffix="data", + ) + assert name == "nukelab-server-user-uuid-1234-my-server-data" + + def test_make_docker_resource_name_sanitizes_user_identifier(self): + name = make_docker_resource_name( + prefix="nukelab-server", + user_identifier="user@example.com", + server_name="server", + suffix="data", + ) + assert name == "nukelab-server-user-example.com-server-data" + assert name[0].isalnum() + assert "@" not in name + + def test_make_docker_resource_name_sanitizes_server_name(self): + name = make_docker_resource_name( + prefix="nukelab-server", + user_identifier="user-uuid", + server_name="--weird.server--name--", + suffix="data", + ) + assert name == "nukelab-server-user-uuid-weird.server--name-data" + assert name[0].isalnum() + + def test_make_docker_resource_name_truncates_long_names(self): + name = make_docker_resource_name( + prefix="nukelab-server", + user_identifier="user-uuid", + server_name="a" * 300, + suffix="data", + max_len=100, + ) + assert len(name) <= 100 + assert name.startswith("nukelab-server-user-uuid-") + assert name.endswith("-data") + + def test_make_docker_resource_name_unique_per_user(self): + """Distinct users that sanitize to the same string still get distinct names.""" + name1 = make_docker_resource_name( + prefix="nukelab-server", + user_identifier="uuid-1", + server_name="server", + suffix="data", + ) + name2 = make_docker_resource_name( + prefix="nukelab-server", + user_identifier="uuid-2", + server_name="server", + suffix="data", + ) + assert name1 != name2 + class TestVolumeServiceCreate: """Tests for create_volume.""" @@ -101,6 +159,19 @@ async def test_create_volume_public(self, db_session, vol_service, test_user): assert volume.visibility == "public" + @pytest.mark.asyncio + async def test_create_volume_rejects_invalid_name(self, db_session, vol_service, test_user): + from fastapi import HTTPException + + with pytest.raises(HTTPException) as exc_info: + await vol_service.create_volume( + name="invalid@name", + display_name="Invalid Volume", + owner_id=str(test_user.id), + ) + assert exc_info.value.status_code == 400 + assert "invalid docker volume name" in exc_info.value.detail.lower() + class TestVolumeServiceGet: """Tests for get_volume and get_volume_by_name.""" diff --git a/compose.yml b/compose.yml index 742df544..eec6d0fc 100644 --- a/compose.yml +++ b/compose.yml @@ -202,6 +202,8 @@ services: - user-secrets:${USER_AUTH_SECRETS_DIR:-/run/user-secrets} - server-secrets:${SERVER_AUTH_SECRETS_DIR:-/run/server-secrets} - xfs-projects:/data/xfs + devices: + - ${XFS_QUOTA_DEVICE_PATH:-/dev/null}:${XFS_QUOTA_DEVICE_PATH:-/dev/null}:r cap_add: - SYS_ADMIN networks: @@ -364,6 +366,8 @@ services: - user-secrets:${USER_AUTH_SECRETS_DIR:-/run/user-secrets} - server-secrets:${SERVER_AUTH_SECRETS_DIR:-/run/server-secrets} - xfs-projects:/data/xfs + devices: + - ${XFS_QUOTA_DEVICE_PATH:-/dev/null}:${XFS_QUOTA_DEVICE_PATH:-/dev/null}:r cap_add: - SYS_ADMIN networks: diff --git a/docs/operations/PRODUCTION-DEPLOYMENT.md b/docs/operations/PRODUCTION-DEPLOYMENT.md index 39a310f0..5949b62d 100644 --- a/docs/operations/PRODUCTION-DEPLOYMENT.md +++ b/docs/operations/PRODUCTION-DEPLOYMENT.md @@ -174,42 +174,61 @@ INFO:Mounted lxcfs /proc files: 7 files ### XFS with Project Quotas (pquota) +The examples below use `/data/docker` as the Docker `data-root`. Replace it with your actual path (e.g., `/var/lib/docker`, `/var/lib/containers/storage`, or wherever `VOLUME_STORAGE_PATH` points). + **1. Check current mount:** ```bash -findmnt /var/lib/docker +findmnt /data/docker # or -findmnt /var/lib/containers +findmnt /var/lib/docker ``` **2. Remount with pquota (temporary):** ```bash -sudo mount -o remount,prjquota /var/lib/docker +sudo mount -o remount,prjquota /data/docker ``` **3. Make permanent in `/etc/fstab`:** ``` -/dev/mapper/vg-docker /var/lib/docker xfs defaults,prjquota 0 0 +/dev/mapper/your-vg-data /data/docker xfs defaults,prjquota 0 0 ``` -**4. Enable in Docker daemon** (`/etc/docker/daemon.json`): +**4. Restart Docker:** -```json -{ - "storage-driver": "overlay2", - "storage-opts": [ - "overlay2.override_kernel_check=true", - "xfs.pquota=true" - ] -} +```bash +sudo systemctl restart docker ``` -**5. Restart Docker:** +Modern Docker's `overlay2` driver automatically uses XFS project quotas for per-container writable-layer size limits when the backing filesystem is mounted with `pquota`. + +**5. Expose the XFS block device to the backend container:** + +`xfs_quota` needs the underlying block device node visible inside the container, even though it only performs filesystem-level quota ioctls. Find the device for your volume storage path: ```bash -sudo systemctl restart docker +sudo df -P "$VOLUME_STORAGE_PATH" | tail -1 | awk '{print $1}' +# → /dev/mapper/your-vg-data +``` + +Set it in `.env`: + +```env +XFS_QUOTA_DEVICE_PATH=/dev/mapper/your-vg-data +``` + +The device is mounted read-only into the backend and celery-worker containers via `compose.yml`. + +**6. Rebuild and restart:** + +NukeLab's XFS project quota service needs `xfsprogs` (`xfs_quota`, `xfs_io`) inside the backend container. The backend `Dockerfile` installs it; rebuild the image after pulling the change: + +```bash +./nukelabctl down +./nukelabctl build backend +./nukelabctl up prod ``` ### ZFS @@ -250,6 +269,86 @@ Common in rootless dev environments (overlayfs). Expected in production with XFS(pquota)/ZFS/Btrfs. ``` +### Hide host storage size from containers + +By default, Docker bind-mounts per-container metadata files (`/etc/hosts`, `/etc/hostname`, `/etc/resolv.conf`) from `/data/docker/containers//`. Because that directory lives on the host's XFS data partition, `df` inside a server container leaks the full host storage size: + +```text +/dev/mapper/your-vg-data 500G 50G 450G 10% /etc/hosts +``` + +To hide this, move Docker's container metadata onto a small dedicated loopback filesystem and enable Docker log rotation so the metadata volume does not fill up. + +**1. Stop Docker:** + +```bash +sudo systemctl stop docker +``` + +**2. Create a loopback filesystem for container metadata:** + +```bash +# Move existing metadata aside +sudo mv /data/docker/containers /data/docker/containers-under + +# Create a 10 GiB loopback file (adjust size to your needs) +sudo mkdir -p /data/docker-meta +sudo dd if=/dev/zero of=/data/docker-meta/containers.img bs=1M count=10240 +sudo mkfs.ext4 -L docker-containers /data/docker-meta/containers.img + +# Mount it at the original path +sudo mkdir -p /data/docker/containers +sudo mount -o loop /data/docker-meta/containers.img /data/docker/containers +``` + +**3. Copy the original metadata back:** + +```bash +sudo rsync -a --delete /data/docker/containers-under/ /data/docker/containers/ +sudo rm -rf /data/docker/containers-under +``` + +**4. Make it permanent in `/etc/fstab`:** + +```fstab +/data/docker-meta/containers.img /data/docker/containers ext4 loop 0 0 +``` + +**5. Enable Docker log rotation in `/etc/docker/daemon.json`:** + +```json +{ + "data-root": "/data/docker", + "storage-driver": "overlay2", + "log-driver": "json-file", + "log-opts": { + "max-size": "10m", + "max-file": "3" + } +} +``` + +**6. Restart Docker:** + +```bash +sudo systemctl daemon-reload +sudo systemctl start docker +``` + +**7. Verify:** + +Start a new server and run inside it: + +```bash +df -h /etc/hosts +``` + +Expected output shows the loop device size (10G), not the host's data partition: + +```text +/dev/loop0 10G 24K 10G 1% /etc/hosts +``` + --- ## Docker vs Podman diff --git a/environments/base/nginx.conf b/environments/base/nginx.conf index 4ffd8cb4..0069aa6a 100644 --- a/environments/base/nginx.conf +++ b/environments/base/nginx.conf @@ -14,6 +14,12 @@ http { # container can start with ReadonlyRootfs. client_body_temp_path /tmp/nginx_client_body; proxy_temp_path /tmp/nginx_proxy; + + # Remove the default 1 MB request-body limit so Theia file uploads and + # other workspace uploads are gated by application-level limits rather than + # nginx. The hardened container writes request bodies to the tmpfs path + # configured above instead of the read-only root filesystem. + client_max_body_size 0; fastcgi_temp_path /tmp/nginx_fastcgi; uwsgi_temp_path /tmp/nginx_uwsgi; scgi_temp_path /tmp/nginx_scgi; @@ -46,6 +52,13 @@ http { listen 8080; server_name localhost; + # Force relative redirects and never include the internal listen port. + # The container sits behind Traefik and one or more reverse proxies; + # absolute redirects would otherwise leak http://...:8080 URLs. + absolute_redirect off; + port_in_redirect off; + server_name_in_redirect off; + # Custom error pages error_page 401 = @handle_unauthorized; error_page 403 /403.html; diff --git a/environments/base/start.sh b/environments/base/start.sh index 517510f3..399b5459 100644 --- a/environments/base/start.sh +++ b/environments/base/start.sh @@ -54,13 +54,30 @@ if $RUN_AS_ROOT && ! id "$USERNAME" &> /dev/null; then fi # Ensure home directory exists and is accessible. -# When running as root we make the mount point world-writable (non-recursive) -# to avoid slow recursive chown on large volumes and ownership fights when the -# same volume is shared across multiple users. When running as non-root we -# assume the backend/spawner has already arranged writable permissions. +# Make the mount point world-writable (non-recursive) to avoid slow recursive +# chown on large volumes and ownership fights when the same volume is shared +# across multiple users. This is required both for root and hardened (non-root) +# runtimes: the hardened user (UID 65532) owns /home and must be able to write +# its own home directory. mkdir -p "/home/$USERNAME" -if $RUN_AS_ROOT; then - chmod 777 "/home/$USERNAME" +chmod 777 "/home/$USERNAME" 2> /dev/null || true + +# In hardened mode the container user (65532) cannot chmod a root-owned named +# volume that was initialized by an earlier non-hardened run. The backend +# spawner will run an explicit chmod as root shortly after the container starts, +# so wait briefly for the mount point to become writable before launching the +# user application. This prevents Theia/IDE from crashing on EACCES. +if [ ! -w "/home/$USERNAME" ]; then + for _ in {1..10}; do + sleep 1 + if [ -w "/home/$USERNAME" ]; then + break + fi + done + if [ ! -w "/home/$USERNAME" ]; then + echo "ERROR: /home/$USERNAME is not writable after waiting; aborting start." >&2 + exit 1 + fi fi # Export a friendly shell prompt and identity. diff --git a/environments/dev/terminal.conf b/environments/dev/terminal.conf index 8cd0fb3a..6e7913aa 100644 --- a/environments/dev/terminal.conf +++ b/environments/dev/terminal.conf @@ -5,7 +5,9 @@ location / { auth_request_set $auth_server $upstream_http_x_server_id; proxy_pass http://127.0.0.1:7681; - proxy_set_header Host $http_host; + proxy_set_header Host $host; + proxy_set_header X-Forwarded-Host $host; + proxy_set_header X-Forwarded-Proto $scheme; proxy_set_header Upgrade $http_upgrade; proxy_set_header Connection $connection_upgrade; diff --git a/environments/workspace/ide.conf b/environments/workspace/ide.conf index 75cee5a1..6e3e2cc2 100644 --- a/environments/workspace/ide.conf +++ b/environments/workspace/ide.conf @@ -5,7 +5,9 @@ location / { auth_request_set $auth_server $upstream_http_x_server_id; proxy_pass http://localhost:3000; - proxy_set_header Host $http_host; + proxy_set_header Host $host; + proxy_set_header X-Forwarded-Host $host; + proxy_set_header X-Forwarded-Proto $scheme; proxy_set_header Upgrade $http_upgrade; proxy_set_header Connection $connection_upgrade; diff --git a/frontend/src/components/admin/quota-defaults-card.tsx b/frontend/src/components/admin/quota-defaults-card.tsx new file mode 100644 index 00000000..1fdc80de --- /dev/null +++ b/frontend/src/components/admin/quota-defaults-card.tsx @@ -0,0 +1,161 @@ +// SPDX-FileCopyrightText: 2023-2026 NukeHub Developers +// SPDX-License-Identifier: BSD-2-Clause + +import { useState, useEffect } from 'react' +import { Gauge, Server, Cpu, HardDrive, MemoryStick, CircuitBoard } from 'lucide-react' +import { motion } from 'framer-motion' +import { Card, CardContent, CardHeader, CardTitle } from '../ui/card' +import { Input } from '../ui/input' +import { Label } from '../ui/label' +import { Button } from '../ui/button' +import { + useSystemQuotaDefaults, + useUpdateSystemQuotaDefaults, + type QuotaLimits, +} from '../../hooks/use-quotas' + +interface QuotaDefaultsCardProps { + canManage: boolean +} + +export function QuotaDefaultsCard({ canManage }: QuotaDefaultsCardProps) { + const { data: defaults, isLoading } = useSystemQuotaDefaults() + const updateDefaults = useUpdateSystemQuotaDefaults() + + const [formData, setFormData] = useState({ + max_cpu_total: 8, + max_memory_total: '16g', + max_disk_total: '100g', + max_gpu_total: 0, + max_servers_total: 5, + }) + + useEffect(() => { + if (defaults) { + setFormData(defaults) + } + }, [defaults]) + + const handleSave = () => { + updateDefaults.mutate(formData) + } + + const isChanged = + defaults && + (defaults.max_cpu_total !== formData.max_cpu_total || + defaults.max_memory_total !== formData.max_memory_total || + defaults.max_disk_total !== formData.max_disk_total || + defaults.max_gpu_total !== formData.max_gpu_total || + defaults.max_servers_total !== formData.max_servers_total) + + return ( + + + + + + System Default Resource Quotas + +

+ Default limits applied to new users when they are created +

+
+ + {isLoading && !defaults ? ( +
+ ) : ( +
+
+ + + setFormData({ ...formData, max_servers_total: parseInt(e.target.value) || 0 }) + } + disabled={!canManage || updateDefaults.isPending} + /> +
+
+ + + setFormData({ ...formData, max_cpu_total: parseFloat(e.target.value) || 0 }) + } + disabled={!canManage || updateDefaults.isPending} + /> +
+
+ + setFormData({ ...formData, max_memory_total: e.target.value })} + placeholder="e.g. 16g" + disabled={!canManage || updateDefaults.isPending} + /> +
+
+ + setFormData({ ...formData, max_disk_total: e.target.value })} + placeholder="e.g. 100g" + disabled={!canManage || updateDefaults.isPending} + /> +
+
+ + + setFormData({ ...formData, max_gpu_total: parseInt(e.target.value) || 0 }) + } + disabled={!canManage || updateDefaults.isPending} + /> +
+
+ )} + {canManage && ( +
+ +
+ )} + + + + ) +} diff --git a/frontend/src/hooks/use-quotas.ts b/frontend/src/hooks/use-quotas.ts index 34e2d9bc..540db9c8 100644 --- a/frontend/src/hooks/use-quotas.ts +++ b/frontend/src/hooks/use-quotas.ts @@ -96,3 +96,38 @@ export function useQuotaActions() { return { updateQuota } } + +export function useSystemQuotaDefaults() { + return useQuery({ + queryKey: ['system-quota-defaults'], + queryFn: async () => { + const response = await api.get<{ default_limits: QuotaLimits }>( + '/admin/quotas/default-limits' + ) + return response.default_limits + }, + }) +} + +type UpdateSystemQuotaDefaultsData = QuotaLimits + +export function useUpdateSystemQuotaDefaults() { + const queryClient = useQueryClient() + const { success, error: showError } = useToast() + + return useMutation({ + mutationFn: (limits: UpdateSystemQuotaDefaultsData) => + api.put<{ message: string; default_limits: QuotaLimits }>( + '/admin/quotas/default-limits', + limits + ), + onSuccess: () => { + queryClient.invalidateQueries({ queryKey: ['system-quota-defaults'] }) + queryClient.invalidateQueries({ queryKey: ['quotas'] }) + success('Default quotas updated', 'New users will receive these limits') + }, + onError: (err) => { + showError('Failed to update default quotas', getErrorMessage(err)) + }, + }) +} diff --git a/frontend/src/hooks/use-users.ts b/frontend/src/hooks/use-users.ts index 15971801..e4bd4a5a 100644 --- a/frontend/src/hooks/use-users.ts +++ b/frontend/src/hooks/use-users.ts @@ -56,6 +56,7 @@ interface CreateUserData { first_name?: string last_name?: string credits?: number + daily_allowance?: number } interface UpdateUserData { diff --git a/frontend/src/routes/admin.quotas.tsx b/frontend/src/routes/admin.quotas.tsx index 9460e657..56edfabf 100644 --- a/frontend/src/routes/admin.quotas.tsx +++ b/frontend/src/routes/admin.quotas.tsx @@ -7,6 +7,7 @@ import { useState, useEffect } from 'react' import { ResourcePageLayout } from '../components/layout/resource-page-layout' import { DataTable } from '../components/data/data-table' import { useQuotas, useQuotaActions, type UserQuota } from '../hooks/use-quotas' +import { QuotaDefaultsCard } from '../components/admin/quota-defaults-card' import { useDataTable } from '../hooks/use-data-table' import { useThemeStore } from '../stores/theme-store' import { useAuthStore, PERMISSIONS } from '../stores/auth-store' @@ -40,6 +41,7 @@ function QuotasPage() { const density = useThemeStore((state) => state.density) const hasPermission = useAuthStore((state) => state.hasPermission) const canUpdateQuotas = hasPermission(PERMISSIONS.QUOTA_UPDATE) + const canManageDefaults = hasPermission(PERMISSIONS.ADMIN_ACCESS) const { state: tableState, @@ -354,6 +356,7 @@ function QuotasPage() { backTo="/admin" stats={stats} > + (null) @@ -127,8 +130,19 @@ function UsersPage() { role: 'user', first_name: '', last_name: '', + credits: 500, + daily_allowance: systemAllowance, }) + useEffect(() => { + if (!editingUser) { + setFormData((prev) => ({ + ...prev, + daily_allowance: systemAllowance, + })) + } + }, [systemAllowance, editingUser]) + const users = data?.data || [] const pagination = data?.pagination @@ -141,6 +155,8 @@ function UsersPage() { role: 'user', first_name: '', last_name: '', + credits: 500, + daily_allowance: systemAllowance, }) setDialogOpen(true) } @@ -154,6 +170,8 @@ function UsersPage() { role: user.role, first_name: user.first_name || '', last_name: user.last_name || '', + credits: 500, + daily_allowance: user.daily_allowance ?? systemAllowance, }) setDialogOpen(true) } @@ -192,7 +210,8 @@ function UsersPage() { role: formData.role, first_name: formData.first_name || undefined, last_name: formData.last_name || undefined, - credits: 500, + credits: formData.credits, + daily_allowance: formData.daily_allowance, }, { onSuccess: () => setDialogOpen(false), @@ -705,7 +724,40 @@ function UsersPage() { {canDeleteUsers && Super Admin}
+ {!editingUser && ( +
+ + + setFormData({ ...formData, credits: parseInt(e.target.value) || 0 }) + } + /> +
+ )} + {!editingUser && ( +
+ + + setFormData({ + ...formData, + daily_allowance: parseInt(e.target.value) || 0, + }) + } + /> +

+ Defaults to the system setting ({systemAllowance.toLocaleString()} NUKE / day) +

+
+ )}