""" systemd-nspawn sandbox for isolated ISO builds. Security measures: - --private-network: No network access (packages pre-cached in base) - --read-only: Immutable root filesystem - --tmpfs: Writable temp directories only - --capability: Minimal capabilities for mkarchiso - Resource limits: 8GB RAM, 4 cores (from CONTEXT.md) """ import asyncio import shutil from dataclasses import dataclass from pathlib import Path from backend.app.core.config import settings @dataclass class SandboxConfig: """Configuration for sandbox execution.""" memory_limit: str = "8G" cpu_quota: str = "400%" # 4 cores timeout_seconds: int = 1200 # 20 minutes (with 15min warning) warning_seconds: int = 900 # 15 minutes class BuildSandbox: """Manages systemd-nspawn sandboxed build environments.""" def __init__( self, sandbox_root: Path | None = None, config: SandboxConfig | None = None, ): self.sandbox_root = sandbox_root or Path(settings.sandbox_root) self.base_path = self.sandbox_root / "base" self.builds_path = self.sandbox_root / "builds" self.config = config or SandboxConfig() async def create_build_container(self, build_id: str) -> Path: """ Create isolated container for a specific build. Uses overlay filesystem on base for efficiency. """ container_path = self.builds_path / build_id if container_path.exists(): shutil.rmtree(container_path) container_path.mkdir(parents=True) # Copy base (in production, use overlayfs for efficiency) # For now, simple copy is acceptable proc = await asyncio.create_subprocess_exec( "cp", "-a", str(self.base_path) + "/.", str(container_path), stdout=asyncio.subprocess.PIPE, stderr=asyncio.subprocess.PIPE, ) await proc.wait() return container_path async def run_build( self, container_path: Path, profile_path: Path, output_path: Path, source_date_epoch: int, ) -> tuple[int, str, str]: """ Execute archiso build in sandboxed container. Returns: Tuple of (return_code, stdout, stderr) """ output_path.mkdir(parents=True, exist_ok=True) nspawn_cmd = [ "systemd-nspawn", f"--directory={container_path}", "--private-network", # No network access "--read-only", # Immutable root "--tmpfs=/tmp:mode=1777", "--tmpfs=/var/tmp:mode=1777", f"--bind={profile_path}:/build/profile:ro", f"--bind={output_path}:/build/output", f"--setenv=SOURCE_DATE_EPOCH={source_date_epoch}", "--setenv=LC_ALL=C", "--setenv=TZ=UTC", "--capability=CAP_SYS_ADMIN", # Required for mkarchiso "--console=pipe", "--quiet", "--", "mkarchiso", "-v", "-r", # Remove work directory after build "-w", "/tmp/archiso-work", "-o", "/build/output", "/build/profile", ] proc = await asyncio.create_subprocess_exec( *nspawn_cmd, stdout=asyncio.subprocess.PIPE, stderr=asyncio.subprocess.PIPE, ) try: stdout, stderr = await asyncio.wait_for( proc.communicate(), timeout=self.config.timeout_seconds, ) return_code = proc.returncode if proc.returncode is not None else -1 return return_code, stdout.decode(), stderr.decode() except TimeoutError: proc.kill() timeout_msg = f"Build timed out after {self.config.timeout_seconds} seconds" return -1, "", timeout_msg async def cleanup_container(self, container_path: Path) -> None: """Remove container after build.""" if container_path.exists(): shutil.rmtree(container_path)