Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
17 commits
Select commit Hold shift + click to select a range
e39e01e
Allow Gravatar in CSP img-src directives
ahnaf-tahmid-chowdhury Jul 3, 2026
0b5a01d
Set correct proxy headers and forwarded host in nginx confs
ahnaf-tahmid-chowdhury Jul 3, 2026
02eb43a
Make home directory world-writable in both root and hardened modes
ahnaf-tahmid-chowdhury Jul 3, 2026
8cb3fe2
Configure nginx to emit relative redirects behind reverse proxy
ahnaf-tahmid-chowdhury Jul 3, 2026
37a9e97
Fix container-stop race and harden forwarded headers
ahnaf-tahmid-chowdhury Jul 3, 2026
fcd76d7
Run chmod as root for hardened mount path fix
ahnaf-tahmid-chowdhury Jul 3, 2026
6870314
Wait for /home/$USERNAME to become writable in hardened mode
ahnaf-tahmid-chowdhury Jul 3, 2026
7ac0071
Remove default 1 MB request-body limit in nginx
ahnaf-tahmid-chowdhury Jul 3, 2026
34c01f5
Add system-wide default resource quota limits
ahnaf-tahmid-chowdhury Jul 4, 2026
0f6aa34
Install xfsprogs and update XFS quota docs
ahnaf-tahmid-chowdhury Jul 4, 2026
b01f568
Expose XFS block device to container for xfs_quota access
ahnaf-tahmid-chowdhury Jul 4, 2026
11f4640
Do not inject LD_PRELOAD from spawner
ahnaf-tahmid-chowdhury Jul 4, 2026
611d367
Add production guide for hiding host storage from containers
ahnaf-tahmid-chowdhury Jul 4, 2026
a27a6a3
Derive monitoring cookie domain from public URL, not hardcoded localhost
ahnaf-tahmid-chowdhury Jul 4, 2026
7193ab0
Extract volume name sanitization into shared utility
ahnaf-tahmid-chowdhury Jul 6, 2026
afeb747
Rename `make_docker_volume_name` to `make_docker_resource_name` and
ahnaf-tahmid-chowdhury Jul 6, 2026
f4bb5fc
Add dev build subcommand and stop --include-servers flag
ahnaf-tahmid-chowdhury Jul 6, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions .env.example
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
1 change: 1 addition & 0 deletions backend/Dockerfile
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
50 changes: 50 additions & 0 deletions backend/app/api/admin.py
Original file line number Diff line number Diff line change
Expand Up @@ -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)")
Expand Down
32 changes: 29 additions & 3 deletions backend/app/api/auth.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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,
Expand All @@ -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)
Expand All @@ -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,
Expand Down Expand Up @@ -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"],
Expand All @@ -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)
Expand Down
78 changes: 49 additions & 29 deletions backend/app/api/servers.py
Original file line number Diff line number Diff line change
Expand Up @@ -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 = []

Expand All @@ -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()}",
Expand All @@ -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",
Expand Down Expand Up @@ -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)
Expand Down Expand Up @@ -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",
}
Expand Down Expand Up @@ -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)
Expand Down Expand Up @@ -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()}",
Expand Down Expand Up @@ -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.
Expand Down
2 changes: 2 additions & 0 deletions backend/app/api/users.py
Original file line number Diff line number Diff line change
Expand Up @@ -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):
Expand Down Expand Up @@ -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,
)

Expand Down
Loading
Loading