From e39e01efd041a4473fb07a119c81ebadbad25cfa Mon Sep 17 00:00:00 2001 From: Ahnaf Tahmid Chowdhury Date: Fri, 3 Jul 2026 22:34:36 +0600 Subject: [PATCH 01/17] Allow Gravatar in CSP img-src directives --- infrastructure/traefik/dynamic/middlewares.yml | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/infrastructure/traefik/dynamic/middlewares.yml b/infrastructure/traefik/dynamic/middlewares.yml index 67b86ab..d5ad230 100644 --- a/infrastructure/traefik/dynamic/middlewares.yml +++ b/infrastructure/traefik/dynamic/middlewares.yml @@ -65,14 +65,14 @@ http: # ------------------------------------------------------------------------- csp-header: headers: - contentSecurityPolicy: "default-src 'self'; script-src 'self' 'unsafe-inline' 'unsafe-eval'; style-src 'self' 'unsafe-inline'; img-src 'self' data: blob:; font-src 'self'; connect-src 'self' ws: wss:; frame-ancestors 'self'; base-uri 'self'; form-action 'self';" + contentSecurityPolicy: "default-src 'self'; script-src 'self' 'unsafe-inline' 'unsafe-eval'; style-src 'self' 'unsafe-inline'; img-src 'self' data: blob: https://*.gravatar.com; font-src 'self'; connect-src 'self' ws: wss:; frame-ancestors 'self'; base-uri 'self'; form-action 'self';" # Strict CSP for production builds (no unsafe-eval). # Switch frontend-chain / api-chain to use this after verifying your # production build does not require eval() or inline scripts beyond styles. csp-header-strict: headers: - contentSecurityPolicy: "default-src 'self'; script-src 'self'; style-src 'self' 'unsafe-inline'; img-src 'self' data: blob:; font-src 'self'; connect-src 'self' ws: wss:; frame-ancestors 'self'; base-uri 'self'; form-action 'self';" + contentSecurityPolicy: "default-src 'self'; script-src 'self'; style-src 'self' 'unsafe-inline'; img-src 'self' data: blob: https://*.gravatar.com; font-src 'self'; connect-src 'self' ws: wss:; frame-ancestors 'self'; base-uri 'self'; form-action 'self';" # ------------------------------------------------------------------------- # CORS Headers (fallback when backend CORS is bypassed or unavailable) From 0b5a01da93f9c10ffa8e42de3a6071580ff93d4d Mon Sep 17 00:00:00 2001 From: Ahnaf Tahmid Chowdhury Date: Fri, 3 Jul 2026 22:45:20 +0600 Subject: [PATCH 02/17] Set correct proxy headers and forwarded host in nginx confs Replace raw `$http_host` with canonical `$host` for routing and add `X-Forwarded-Host` and `X-Forwarded-Proto` headers to preserve the original client request through the proxy chain. --- environments/dev/terminal.conf | 4 +++- environments/workspace/ide.conf | 4 +++- 2 files changed, 6 insertions(+), 2 deletions(-) diff --git a/environments/dev/terminal.conf b/environments/dev/terminal.conf index 8cd0fb3..6e7913a 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 75cee5a..6e3e2cc 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; From 02eb43a27812773becb2677d10c26b2625e2fb07 Mon Sep 17 00:00:00 2001 From: Ahnaf Tahmid Chowdhury Date: Fri, 3 Jul 2026 23:59:57 +0600 Subject: [PATCH 03/17] Make home directory world-writable in both root and hardened modes --- environments/base/start.sh | 13 ++++++------- 1 file changed, 6 insertions(+), 7 deletions(-) diff --git a/environments/base/start.sh b/environments/base/start.sh index 517510f..c9f94de 100644 --- a/environments/base/start.sh +++ b/environments/base/start.sh @@ -54,14 +54,13 @@ 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" -fi +chmod 777 "/home/$USERNAME" 2>/dev/null || true # Export a friendly shell prompt and identity. export HOME="/home/$USERNAME" From 8cb3fe20f38ddd8ecec91d20702445c62030ac71 Mon Sep 17 00:00:00 2001 From: Ahnaf Tahmid Chowdhury Date: Sat, 4 Jul 2026 01:04:29 +0600 Subject: [PATCH 04/17] Configure nginx to emit relative redirects behind reverse proxy --- environments/base/nginx.conf | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/environments/base/nginx.conf b/environments/base/nginx.conf index 4ffd8cb..575c0ff 100644 --- a/environments/base/nginx.conf +++ b/environments/base/nginx.conf @@ -46,6 +46,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; From 37a9e973b658906d8ba2734b20bbfbdb916325ac Mon Sep 17 00:00:00 2001 From: Ahnaf Tahmid Chowdhury Date: Sat, 4 Jul 2026 01:29:12 +0600 Subject: [PATCH 05/17] Fix container-stop race and harden forwarded headers - Correct "Server stopped" to "Server already stopped" in stop path - Use `_get_client_ip` helper instead of raw `request.client.host` - Remove unnecessary f-string on regex replacement label - Suppress stderr in chmod with portable redirection - Whitelist private subnets for Traefik forwarded headers --- backend/app/api/servers.py | 6 ++++-- backend/app/container/spawner.py | 2 +- environments/base/start.sh | 2 +- infrastructure/traefik/traefik.yml | 8 ++++++++ 4 files changed, 14 insertions(+), 4 deletions(-) diff --git a/backend/app/api/servers.py b/backend/app/api/servers.py index 7fcb98d..80788b3 100644 --- a/backend/app/api/servers.py +++ b/backend/app/api/servers.py @@ -1112,7 +1112,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", } @@ -2010,7 +2010,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/container/spawner.py b/backend/app/container/spawner.py index 69d5cc6..4c1c563 100644 --- a/backend/app/container/spawner.py +++ b/backend/app/container/spawner.py @@ -148,7 +148,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, diff --git a/environments/base/start.sh b/environments/base/start.sh index c9f94de..36c1f83 100644 --- a/environments/base/start.sh +++ b/environments/base/start.sh @@ -60,7 +60,7 @@ fi # runtimes: the hardened user (UID 65532) owns /home and must be able to write # its own home directory. mkdir -p "/home/$USERNAME" -chmod 777 "/home/$USERNAME" 2>/dev/null || true +chmod 777 "/home/$USERNAME" 2> /dev/null || true # Export a friendly shell prompt and identity. export HOME="/home/$USERNAME" diff --git a/infrastructure/traefik/traefik.yml b/infrastructure/traefik/traefik.yml index 9589f9c..4c7b230 100644 --- a/infrastructure/traefik/traefik.yml +++ b/infrastructure/traefik/traefik.yml @@ -9,8 +9,16 @@ providers: entryPoints: web: address: ":80" + forwardedHeaders: + trustedIPs: + - "127.0.0.1/32" + - "172.16.0.0/12" websecure: address: ":443" + forwardedHeaders: + trustedIPs: + - "127.0.0.1/32" + - "172.16.0.0/12" certificatesResolvers: letsencrypt: From fcd76d7adbc4372dd796393545de697b26b057da Mon Sep 17 00:00:00 2001 From: Ahnaf Tahmid Chowdhury Date: Sat, 4 Jul 2026 02:03:03 +0600 Subject: [PATCH 06/17] Run chmod as root for hardened mount path fix --- backend/app/container/spawner.py | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/backend/app/container/spawner.py b/backend/app/container/spawner.py index 4c1c563..021e23d 100644 --- a/backend/app/container/spawner.py +++ b/backend/app/container/spawner.py @@ -271,7 +271,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: From 6870314b0c3ca33670fd32937d02f9365f9253fd Mon Sep 17 00:00:00 2001 From: Ahnaf Tahmid Chowdhury Date: Sat, 4 Jul 2026 02:21:40 +0600 Subject: [PATCH 07/17] Wait for /home/$USERNAME to become writable in hardened mode --- environments/base/start.sh | 18 ++++++++++++++++++ 1 file changed, 18 insertions(+) diff --git a/environments/base/start.sh b/environments/base/start.sh index 36c1f83..399b545 100644 --- a/environments/base/start.sh +++ b/environments/base/start.sh @@ -62,6 +62,24 @@ fi mkdir -p "/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. export HOME="/home/$USERNAME" export USER="$USERNAME" From 7ac007193af972ebbca31c33f4a5ab624f911698 Mon Sep 17 00:00:00 2001 From: Ahnaf Tahmid Chowdhury Date: Sat, 4 Jul 2026 02:33:23 +0600 Subject: [PATCH 08/17] Remove default 1 MB request-body limit in nginx The hardened container uses tmpfs paths, so removing this limit allows Theia file uploads and other workspace uploads to be gated solely by application-level limits --- environments/base/nginx.conf | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/environments/base/nginx.conf b/environments/base/nginx.conf index 575c0ff..0069aa6 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; From 34c01f5a4c37b0f6999a9a08a00007f5dbef7a75 Mon Sep 17 00:00:00 2001 From: Ahnaf Tahmid Chowdhury Date: Sat, 4 Jul 2026 12:07:35 +0600 Subject: [PATCH 09/17] Add system-wide default resource quota limits --- backend/app/api/admin.py | 50 ++++++ backend/app/api/auth.py | 8 +- backend/app/api/users.py | 2 + backend/app/services/quota_service.py | 25 ++- backend/app/services/setting_service.py | 45 +++++ backend/app/services/user_service.py | 16 +- backend/tests/api/quotas/test_quotas.py | 62 +++++++ backend/tests/container/test_spawner.py | 12 +- backend/tests/services/test_quota_service.py | 16 ++ .../tests/services/test_setting_service.py | 50 ++++++ backend/tests/services/test_user_service.py | 36 +++- .../components/admin/quota-defaults-card.tsx | 161 ++++++++++++++++++ frontend/src/hooks/use-quotas.ts | 35 ++++ frontend/src/hooks/use-users.ts | 1 + frontend/src/routes/admin.quotas.tsx | 3 + frontend/src/routes/admin.users.tsx | 54 +++++- 16 files changed, 558 insertions(+), 18 deletions(-) create mode 100644 frontend/src/components/admin/quota-defaults-card.tsx diff --git a/backend/app/api/admin.py b/backend/app/api/admin.py index 48486ba..a9dba77 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 005fd68..bf49022 100644 --- a/backend/app/api/auth.py +++ b/backend/app/api/auth.py @@ -1154,7 +1154,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 +1169,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/users.py b/backend/app/api/users.py index 4763ae1..61e7e8e 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/services/quota_service.py b/backend/app/services/quota_service.py index 0e0584c..e017d16 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 45a41b8..c6b8d91 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 5d7de6b..d3190ac 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/tests/api/quotas/test_quotas.py b/backend/tests/api/quotas/test_quotas.py index 2f68b29..954cb21 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/container/test_spawner.py b/backend/tests/container/test_spawner.py index d0798e6..4f01e33 100644 --- a/backend/tests/container/test_spawner.py +++ b/backend/tests/container/test_spawner.py @@ -776,8 +776,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 +798,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 +828,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 a675825..da03c1c 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 adc23c3..97f226d 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 2a33422..6077099 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/frontend/src/components/admin/quota-defaults-card.tsx b/frontend/src/components/admin/quota-defaults-card.tsx new file mode 100644 index 0000000..1fdc80d --- /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 34e2d9b..540db9c 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 1597180..e4bd4a5 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 9460e65..56edfab 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) +

+
+ )}