<# .SYNOPSIS Hyper-V Development Sandbox for Windows - Maximum Security Mode .DESCRIPTION Creates fully isolated Hyper-V VMs for running Claude Code with maximum security. Uses Ubuntu Cloud Images with cloud-init for automated provisioning. Unlike WSL2, Hyper-V provides: - Complete kernel isolation (separate kernel per VM) - Full network isolation (own network stack) - No shared filesystem by default - Process isolation from Windows host .PARAMETER VMName Name for the virtual machine (default: dev-sandbox) .PARAMETER MemoryGB Memory allocation in GB (default: 8) .PARAMETER DiskGB Virtual disk size in GB (default: 60) .PARAMETER CPUs Number of virtual CPUs (default: 4) .PARAMETER SkipVNC Skip VNC/XFCE desktop installation .PARAMETER SkipPostgreSQL Skip PostgreSQL installation .PARAMETER SkipOllama Skip Ollama LLM runner installation .PARAMETER Force Replace existing VM with same name .EXAMPLE .\setup_env_windows.ps1 -VMName my-project .EXAMPLE .\setup_env_windows.ps1 -VMName elixir-dev -MemoryGB 16 -DiskGB 100 -SkipOllama .NOTES Requirements: - Windows 10/11 Pro, Enterprise, or Education (Hyper-V not available on Home) - Hyper-V enabled - Administrator privileges - Internet connection for downloading Ubuntu image #> [CmdletBinding()] param( [Parameter(Position=0)] [ValidatePattern('^[a-zA-Z][a-zA-Z0-9_-]*$')] [ValidateLength(1, 64)] [string]$VMName = "dev-sandbox", [ValidateRange(2, 64)] [int]$MemoryGB = 8, [ValidateRange(40, 500)] [int]$DiskGB = 60, [ValidateRange(1, 16)] [int]$CPUs = 4, [switch]$SkipVNC, [switch]$SkipPostgreSQL, [switch]$SkipOllama, [switch]$SkipPlaywright, [switch]$Force ) # ============================================================================ # Configuration # ============================================================================ $ErrorActionPreference = "Stop" $ProgressPreference = "SilentlyContinue" # Speed up downloads $UBUNTU_VERSION = "24.04" $VM_STORAGE_PATH = "$env:ProgramData\HyperV-DevSandbox" $IMAGES_PATH = "$VM_STORAGE_PATH\Images" $VMS_PATH = "$VM_STORAGE_PATH\VMs" $CONFIG_FILE = "$PSScriptRoot\config.env" $LOG_FILE = "$env:TEMP\setup_env_windows_$(Get-Date -Format 'yyyyMMdd_HHmmss').log" # Detect architecture for correct image $ARCH = if ([System.Environment]::Is64BitOperatingSystem) { $cpu = Get-CimInstance -ClassName Win32_Processor | Select-Object -First 1 if ($cpu.Architecture -eq 12) { "arm64" } else { "amd64" } } else { "amd64" } $UBUNTU_IMAGE_URL = "https://cloud-images.ubuntu.com/releases/$UBUNTU_VERSION/release/ubuntu-$UBUNTU_VERSION-server-cloudimg-$ARCH.vhdx" $UBUNTU_CHECKSUM_URL = "https://cloud-images.ubuntu.com/releases/$UBUNTU_VERSION/release/SHA256SUMS" # ============================================================================ # Helper Functions # ============================================================================ # Global cleanup tracking $script:CleanupResources = @{ VMCreated = $false VMName = "" DiskPath = "" ISOPath = "" } function Write-Log { param([string]$Message, [string]$Level = "INFO") $timestamp = Get-Date -Format "yyyy-MM-dd HH:mm:ss" $logEntry = "[$timestamp] [$Level] $Message" Add-Content -Path $LOG_FILE -Value $logEntry -ErrorAction SilentlyContinue } function Write-Status { param([string]$Message, [string]$Type = "Info") $colors = @{ "Info" = "Cyan" "Success" = "Green" "Warning" = "Yellow" "Error" = "Red" } $prefix = @{ "Info" = "==>" "Success" = "[OK]" "Warning" = "[!]" "Error" = "[X]" } Write-Host "$($prefix[$Type]) $Message" -ForegroundColor $colors[$Type] Write-Log $Message $Type.ToUpper() } function Test-Administrator { $identity = [Security.Principal.WindowsIdentity]::GetCurrent() $principal = New-Object Security.Principal.WindowsPrincipal($identity) return $principal.IsInRole([Security.Principal.WindowsBuiltInRole]::Administrator) } function Test-HyperVEnabled { $hyperv = Get-WindowsOptionalFeature -Online -FeatureName Microsoft-Hyper-V-All -ErrorAction SilentlyContinue return $hyperv -and $hyperv.State -eq "Enabled" } function Get-VMSwitch-Default { # Try to find default switch first (most common), then external $switch = Get-VMSwitch -Name "Default Switch" -ErrorAction SilentlyContinue if (-not $switch) { $switch = Get-VMSwitch -SwitchType External -ErrorAction SilentlyContinue | Select-Object -First 1 } if (-not $switch) { $switch = Get-VMSwitch -SwitchType Internal -ErrorAction SilentlyContinue | Select-Object -First 1 } return $switch } function Get-SecureInput { param([string]$Prompt, [switch]$AsPlainText) if ($AsPlainText) { return Read-Host -Prompt $Prompt } $secure = Read-Host -Prompt $Prompt -AsSecureString $bstr = [System.Runtime.InteropServices.Marshal]::SecureStringToBSTR($secure) try { return [System.Runtime.InteropServices.Marshal]::PtrToStringAuto($bstr) } finally { [System.Runtime.InteropServices.Marshal]::ZeroFreeBSTR($bstr) } } # ============================================================================ # Input Validation Functions # ============================================================================ function Test-SafeInput { # Validates input contains only safe characters (no shell metacharacters) param([string]$Input, [string]$AllowedPattern = '^[a-zA-Z0-9@._\s-]+$') return $Input -match $AllowedPattern } function Test-GitName { param([string]$Name) # Allow letters, numbers, spaces, hyphens, periods, apostrophes # Block shell metacharacters: $ ` \ " ' ; | & < > ( ) { } [ ] ! # % ^ if ([string]::IsNullOrWhiteSpace($Name)) { return $false } if ($Name.Length -gt 100) { return $false } return $Name -match "^[a-zA-Z0-9\s.'_-]+$" } function Test-GitEmail { param([string]$Email) # Standard email validation + block dangerous characters if ([string]::IsNullOrWhiteSpace($Email)) { return $false } if ($Email.Length -gt 254) { return $false } # Must be valid email format and not contain shell metacharacters return $Email -match '^[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\.[a-zA-Z]{2,}$' } function Test-VMPassword { param([string]$Password) # Password requirements: # - Minimum 8 characters # - No characters that could break YAML or shell (no: ' " ` $ \ newlines) if ($Password.Length -lt 8) { return $false } if ($Password.Length -gt 128) { return $false } # Block characters that could cause YAML/shell injection if ($Password -match '[''"`$\\]') { return $false } if ($Password -match '[\r\n]') { return $false } return $true } function Test-VNCPassword { param([string]$Password) # VNC password requirements: # - Minimum 8 characters (increased from 6 for security) # - Maximum 8 characters (VNC limitation - only first 8 are used) # - No shell metacharacters if ($Password.Length -lt 8) { return $false } # Block characters that could cause issues if ($Password -match '[''"`$\\;&|<>]') { return $false } return $true } function Invoke-Cleanup { param([switch]$OnError) if ($OnError) { Write-Status "Cleaning up after error..." "Warning" Write-Log "Cleanup triggered due to error" } # Stop and remove VM if it was created if ($script:CleanupResources.VMCreated -and $script:CleanupResources.VMName) { $vmName = $script:CleanupResources.VMName Write-Log "Removing VM: $vmName" Stop-VM -Name $vmName -Force -ErrorAction SilentlyContinue Start-Sleep -Seconds 2 Remove-VM -Name $vmName -Force -ErrorAction SilentlyContinue } # Remove disk if created if ($script:CleanupResources.DiskPath -and (Test-Path $script:CleanupResources.DiskPath)) { Write-Log "Removing disk: $($script:CleanupResources.DiskPath)" Remove-Item -Path $script:CleanupResources.DiskPath -Force -ErrorAction SilentlyContinue } # Remove ISO if created (contains sensitive data) if ($script:CleanupResources.ISOPath -and (Test-Path $script:CleanupResources.ISOPath)) { Write-Log "Removing cloud-init ISO: $($script:CleanupResources.ISOPath)" Remove-Item -Path $script:CleanupResources.ISOPath -Force -ErrorAction SilentlyContinue } } function Get-FileChecksum { param([string]$FilePath) $hash = Get-FileHash -Path $FilePath -Algorithm SHA256 return $hash.Hash.ToLower() } function Test-ImageChecksum { param( [string]$ImagePath, [string]$ExpectedChecksum ) Write-Status "Verifying image checksum..." "Info" $actualChecksum = Get-FileChecksum -FilePath $ImagePath if ($actualChecksum -eq $ExpectedChecksum.ToLower()) { Write-Status "Checksum verified" "Success" Write-Log "Checksum OK: $actualChecksum" return $true } else { Write-Status "Checksum mismatch!" "Error" Write-Log "Checksum FAILED: expected=$ExpectedChecksum actual=$actualChecksum" return $false } } function Get-UbuntuImageChecksum { param([string]$ImageFileName) try { Write-Log "Fetching checksums from $UBUNTU_CHECKSUM_URL" $checksums = Invoke-WebRequest -Uri $UBUNTU_CHECKSUM_URL -UseBasicParsing -ErrorAction Stop $lines = $checksums.Content -split "`n" foreach ($line in $lines) { # Format: or * if ($line -match "^([a-f0-9]{64})\s+\*?(.+)$") { $checksum = $matches[1] $filename = $matches[2].Trim() if ($filename -eq $ImageFileName -or $filename -eq "*$ImageFileName") { Write-Log "Found checksum for $ImageFileName : $checksum" return $checksum } } } Write-Log "No checksum found for $ImageFileName" return $null } catch { Write-Log "Failed to fetch checksums: $_" return $null } } function ConvertTo-Base64 { param([string]$Text) return [Convert]::ToBase64String([System.Text.Encoding]::UTF8.GetBytes($Text)) } function ConvertTo-SHA512Hash { # Create a SHA-512 crypt password hash compatible with Linux /etc/shadow # Format: $6$$ param([string]$Password) # Generate random salt (16 chars from allowed set) $saltChars = "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789./" $salt = -join ((1..16) | ForEach-Object { $saltChars[(Get-Random -Maximum $saltChars.Length)] }) # For cloud-init, we can use the plain password with chpasswd instead # Return a placeholder that cloud-init will handle return $Password } function New-CloudInitISO { param( [string]$OutputPath, [string]$UserDataContent, [string]$MetaDataContent ) $tempDir = Join-Path $env:TEMP "cloudinit-$(Get-Random)" New-Item -ItemType Directory -Path $tempDir -Force | Out-Null try { # Write cloud-init files with Unix line endings (LF only) $UserDataContent -replace "`r`n", "`n" | Set-Content -Path "$tempDir\user-data" -Encoding UTF8 -NoNewline $MetaDataContent -replace "`r`n", "`n" | Set-Content -Path "$tempDir\meta-data" -Encoding UTF8 -NoNewline # Method 1: Try oscdimg (Windows ADK) $oscdimg = Get-Command oscdimg -ErrorAction SilentlyContinue if ($oscdimg) { Write-Status "Creating ISO with oscdimg..." "Info" $result = & oscdimg -j2 -lcidata "$tempDir" "$OutputPath" 2>&1 if ($LASTEXITCODE -eq 0) { return $true } } # Method 2: Try WSL with genisoimage $wsl = Get-Command wsl -ErrorAction SilentlyContinue if ($wsl) { Write-Status "Creating ISO with WSL genisoimage..." "Info" # Convert Windows path to WSL path $wslTempDir = $tempDir -replace '\\', '/' -replace '^([A-Za-z]):', '/mnt/$1'.ToLower() $wslOutputPath = $OutputPath -replace '\\', '/' -replace '^([A-Za-z]):', '/mnt/$1'.ToLower() # Check if genisoimage is available in WSL $result = wsl which genisoimage 2>&1 if ($LASTEXITCODE -eq 0) { wsl genisoimage -output "$wslOutputPath" -volid cidata -joliet -rock "$wslTempDir/user-data" "$wslTempDir/meta-data" 2>&1 | Out-Null if ($LASTEXITCODE -eq 0 -and (Test-Path $OutputPath)) { return $true } } # Try mkisofs as alternative $result = wsl which mkisofs 2>&1 if ($LASTEXITCODE -eq 0) { wsl mkisofs -output "$wslOutputPath" -volid cidata -joliet -rock "$wslTempDir/user-data" "$wslTempDir/meta-data" 2>&1 | Out-Null if ($LASTEXITCODE -eq 0 -and (Test-Path $OutputPath)) { return $true } } } # Method 3: PowerShell native ISO creation using .NET Write-Status "Creating ISO with PowerShell/.NET..." "Info" New-ISOFile -SourcePath $tempDir -DestinationPath $OutputPath -VolumeName "cidata" return $true } finally { Remove-Item -Path $tempDir -Recurse -Force -ErrorAction SilentlyContinue } } function New-ISOFile { # Pure PowerShell ISO creation using IMAPI2 param( [string]$SourcePath, [string]$DestinationPath, [string]$VolumeName = "DVDROM" ) # Load IMAPI2 COM objects $fsi = New-Object -ComObject IMAPI2FS.MsftFileSystemImage $fsi.FileSystemsToCreate = 3 # FsiFileSystemISO9660 | FsiFileSystemJoliet $fsi.VolumeName = $VolumeName # Add files from source directory $files = Get-ChildItem -Path $SourcePath -File foreach ($file in $files) { $stream = New-Object -ComObject ADODB.Stream $stream.Type = 1 # Binary $stream.Open() $stream.LoadFromFile($file.FullName) $fsi.Root.AddFile($file.Name, $stream) } # Create the ISO $result = $fsi.CreateResultImage() $stream = $result.ImageStream # Write to file $outputStream = [System.IO.File]::Create($DestinationPath) try { $buffer = New-Object byte[] 2048 do { $bytesRead = $stream.Read($buffer, 0, $buffer.Length) if ($bytesRead -gt 0) { $outputStream.Write($buffer, 0, $bytesRead) } } while ($bytesRead -gt 0) } finally { $outputStream.Close() [System.Runtime.InteropServices.Marshal]::ReleaseComObject($stream) | Out-Null [System.Runtime.InteropServices.Marshal]::ReleaseComObject($fsi) | Out-Null } } # ============================================================================ # Pre-flight Checks # ============================================================================ # Initialize log file $null = New-Item -Path $LOG_FILE -ItemType File -Force -ErrorAction SilentlyContinue Write-Log "==========================================" Write-Log "Hyper-V Development Sandbox Setup Started" Write-Log "VM Name: $VMName" Write-Log "Architecture: $ARCH" Write-Log "PowerShell Version: $($PSVersionTable.PSVersion)" Write-Log "==========================================" Write-Host "" Write-Host "============================================================================" -ForegroundColor Green Write-Host " Hyper-V Development Sandbox - Maximum Security Mode" -ForegroundColor Green Write-Host "============================================================================" -ForegroundColor Green Write-Host "" Write-Host " Log file: $LOG_FILE" -ForegroundColor Gray Write-Host "" # Check admin privileges if (-not (Test-Administrator)) { Write-Status "This script requires Administrator privileges." "Error" Write-Host "" Write-Host "Right-click PowerShell and select 'Run as Administrator'" -ForegroundColor Yellow exit 1 } # Check Hyper-V if (-not (Test-HyperVEnabled)) { Write-Status "Hyper-V is not enabled." "Error" Write-Host "" Write-Host "To enable Hyper-V, run:" -ForegroundColor Yellow Write-Host " Enable-WindowsOptionalFeature -Online -FeatureName Microsoft-Hyper-V -All" -ForegroundColor Cyan Write-Host "" Write-Host "Then restart your computer and run this script again." -ForegroundColor Yellow exit 1 } # Check for existing VM $existingVM = Get-VM -Name $VMName -ErrorAction SilentlyContinue if ($existingVM) { if (-not $Force) { Write-Status "VM '$VMName' already exists." "Error" Write-Host "" Write-Host "Options:" -ForegroundColor Yellow Write-Host " Connect: vmconnect localhost $VMName" -ForegroundColor White Write-Host " Replace: .\setup_env_windows.ps1 -VMName $VMName -Force" -ForegroundColor White Write-Host " Delete: Stop-VM -Name $VMName -Force; Remove-VM -Name $VMName -Force" -ForegroundColor White exit 1 } else { Write-Status "Removing existing VM '$VMName'..." "Warning" Stop-VM -Name $VMName -Force -ErrorAction SilentlyContinue Start-Sleep -Seconds 2 Remove-VM -Name $VMName -Force Remove-Item -Path "$VMS_PATH\$VMName" -Recurse -Force -ErrorAction SilentlyContinue } } # Get VM switch $vmSwitch = Get-VMSwitch-Default if (-not $vmSwitch) { Write-Status "No suitable network switch found." "Error" Write-Host "" Write-Host "The 'Default Switch' should be available on Windows 10/11." -ForegroundColor Yellow Write-Host "If missing, create one in Hyper-V Manager or run:" -ForegroundColor Yellow Write-Host ' New-VMSwitch -Name "Default Switch" -SwitchType Internal' -ForegroundColor Cyan exit 1 } Write-Status "Using network switch: $($vmSwitch.Name)" "Info" # ============================================================================ # Load or Create Config # ============================================================================ $GitName = "" $GitEmail = "" if (Test-Path $CONFIG_FILE) { Write-Status "Loading config from: $CONFIG_FILE" "Info" Get-Content $CONFIG_FILE | ForEach-Object { if ($_ -match '^GIT_NAME="(.+)"$') { $GitName = $matches[1] } if ($_ -match '^GIT_EMAIL="(.+)"$') { $GitEmail = $matches[1] } } Write-Host " Name: $GitName" -ForegroundColor Gray Write-Host " Email: $GitEmail" -ForegroundColor Gray } else { Write-Status "First run - creating config file" "Info" Write-Host "" do { $GitName = Get-SecureInput "Git commit author name" -AsPlainText if (-not (Test-GitName $GitName)) { Write-Host " Invalid name. Use only letters, numbers, spaces, periods, hyphens." -ForegroundColor Yellow } } while (-not (Test-GitName $GitName)) do { $GitEmail = Get-SecureInput "Git commit author email" -AsPlainText if (-not (Test-GitEmail $GitEmail)) { Write-Host " Invalid email format." -ForegroundColor Yellow } } while (-not (Test-GitEmail $GitEmail)) # Create config file with restricted permissions from the start # First create empty file with restricted ACL, then write content $null = New-Item -Path $CONFIG_FILE -ItemType File -Force $acl = Get-Acl $CONFIG_FILE $acl.SetAccessRuleProtection($true, $false) $rule = New-Object System.Security.AccessControl.FileSystemAccessRule( [System.Security.Principal.WindowsIdentity]::GetCurrent().Name, "FullControl", "Allow" ) $acl.SetAccessRule($rule) Set-Acl -Path $CONFIG_FILE -AclObject $acl # Now write config content @" GIT_NAME="$GitName" GIT_EMAIL="$GitEmail" "@ | Out-File -FilePath $CONFIG_FILE -Encoding UTF8 -Force Write-Host "" Write-Status "Config saved to: $CONFIG_FILE" "Success" } # Validate loaded config values (could be tampered with) if (-not (Test-GitName $GitName)) { Write-Status "Invalid git name in config file. Please delete $CONFIG_FILE and run again." "Error" exit 1 } if (-not (Test-GitEmail $GitEmail)) { Write-Status "Invalid git email in config file. Please delete $CONFIG_FILE and run again." "Error" exit 1 } # Prompt for VM password (not stored) Write-Host "" do { $VMPassword = Get-SecureInput "VM user password (min 8 chars, no quotes/backslashes)" if (-not (Test-VMPassword $VMPassword)) { Write-Host " Password must be 8+ chars without quotes, backticks, dollar signs, or backslashes." -ForegroundColor Yellow } } while (-not (Test-VMPassword $VMPassword)) # Prompt for VNC password if VNC is enabled $VNCPassword = "" if (-not $SkipVNC) { Write-Host "" do { $VNCPassword = Get-SecureInput "VNC password (exactly 8 chars, no special shell chars)" if (-not (Test-VNCPassword $VNCPassword)) { Write-Host " Password must be exactly 8 chars without quotes, backslashes, or shell characters." -ForegroundColor Yellow } } while (-not (Test-VNCPassword $VNCPassword)) } # ============================================================================ # Download Ubuntu Cloud Image (with checksum verification) # ============================================================================ New-Item -ItemType Directory -Path $IMAGES_PATH -Force | Out-Null New-Item -ItemType Directory -Path "$VMS_PATH\$VMName" -Force | Out-Null $imageFileName = "ubuntu-$UBUNTU_VERSION-server-cloudimg-$ARCH.vhdx" $imagePath = "$IMAGES_PATH\$imageFileName" $imageChecksumPath = "$imagePath.sha256" # Get expected checksum $expectedChecksum = $null if (Test-Path $imageChecksumPath) { $expectedChecksum = (Get-Content $imageChecksumPath -ErrorAction SilentlyContinue).Trim() Write-Log "Loaded cached checksum: $expectedChecksum" } if (-not $expectedChecksum) { Write-Status "Fetching image checksum from Canonical..." "Info" $expectedChecksum = Get-UbuntuImageChecksum -ImageFileName $imageFileName if ($expectedChecksum) { $expectedChecksum | Set-Content -Path $imageChecksumPath } } $needsDownload = $true if (Test-Path $imagePath) { if ($expectedChecksum) { # Verify existing image if (Test-ImageChecksum -ImagePath $imagePath -ExpectedChecksum $expectedChecksum) { Write-Status "Using cached Ubuntu image (checksum verified)" "Info" $needsDownload = $false } else { Write-Status "Cached image checksum mismatch, re-downloading..." "Warning" Remove-Item -Path $imagePath -Force } } else { Write-Status "Using cached Ubuntu image (checksum unavailable)" "Warning" $needsDownload = $false } } if ($needsDownload) { Write-Status "Downloading Ubuntu $UBUNTU_VERSION cloud image ($ARCH, ~700MB)..." "Info" Write-Host " URL: $UBUNTU_IMAGE_URL" -ForegroundColor Gray try { # Use BITS for better download handling $bitsJob = Start-BitsTransfer -Source $UBUNTU_IMAGE_URL -Destination $imagePath -Asynchronous while ($bitsJob.JobState -eq "Transferring" -or $bitsJob.JobState -eq "Connecting") { $percent = [math]::Round(($bitsJob.BytesTransferred / $bitsJob.BytesTotal) * 100, 0) Write-Host "`r Progress: $percent%" -NoNewline Start-Sleep -Seconds 2 } Write-Host "" if ($bitsJob.JobState -ne "Transferred") { throw "Download failed: $($bitsJob.JobState)" } Complete-BitsTransfer -BitsJob $bitsJob Write-Status "Download complete" "Success" # Verify downloaded image if ($expectedChecksum) { if (-not (Test-ImageChecksum -ImagePath $imagePath -ExpectedChecksum $expectedChecksum)) { Remove-Item -Path $imagePath -Force -ErrorAction SilentlyContinue throw "Downloaded image failed checksum verification. This could indicate a corrupted download or MITM attack." } } else { Write-Status "Warning: Could not verify image checksum (checksum unavailable)" "Warning" } } catch { # Fallback to Invoke-WebRequest Write-Status "BITS transfer failed, trying direct download..." "Warning" try { Invoke-WebRequest -Uri $UBUNTU_IMAGE_URL -OutFile $imagePath -UseBasicParsing Write-Status "Download complete" "Success" # Verify downloaded image if ($expectedChecksum) { if (-not (Test-ImageChecksum -ImagePath $imagePath -ExpectedChecksum $expectedChecksum)) { Remove-Item -Path $imagePath -Force -ErrorAction SilentlyContinue throw "Downloaded image failed checksum verification. This could indicate a corrupted download or MITM attack." } } else { Write-Status "Warning: Could not verify image checksum (checksum unavailable)" "Warning" } } catch { Write-Status "Failed to download Ubuntu image: $_" "Error" exit 1 } } } # ============================================================================ # Create VM Disk (copy and resize) # ============================================================================ $vmDiskPath = "$VMS_PATH\$VMName\disk.vhdx" Write-Status "Creating VM disk ($DiskGB GB)..." "Info" try { Copy-Item -Path $imagePath -Destination $vmDiskPath -Force $script:CleanupResources.DiskPath = $vmDiskPath Resize-VHD -Path $vmDiskPath -SizeBytes ($DiskGB * 1GB) } catch { Write-Status "Failed to create VM disk: $_" "Error" Invoke-Cleanup -OnError exit 1 } # ============================================================================ # Generate SSH Key # ============================================================================ $sshDir = "$env:USERPROFILE\.ssh" if (-not (Test-Path $sshDir)) { New-Item -ItemType Directory -Path $sshDir -Force | Out-Null } $sshKeyPath = "$sshDir\id_ed25519_$VMName" if (-not (Test-Path $sshKeyPath)) { Write-Status "Generating SSH key pair..." "Info" # Use ssh-keygen (available on Windows 10+) $sshKeygen = Get-Command ssh-keygen -ErrorAction SilentlyContinue if ($sshKeygen) { & ssh-keygen -t ed25519 -f $sshKeyPath -N '""' -C "$VMName-sandbox" 2>&1 | Out-Null } else { Write-Status "ssh-keygen not found. Please install OpenSSH." "Error" Write-Host " Settings > Apps > Optional Features > OpenSSH Client" -ForegroundColor Yellow exit 1 } } $sshPublicKey = Get-Content "$sshKeyPath.pub" Write-Status "SSH key ready: $sshKeyPath" "Info" # ============================================================================ # Create Cloud-Init Configuration # ============================================================================ Write-Status "Generating cloud-init configuration..." "Info" # Encode setup script for transfer $setupScriptPath = "$PSScriptRoot\setup_env.sh" if (-not (Test-Path $setupScriptPath)) { Write-Status "setup_env.sh not found in script directory" "Error" Write-Host " Expected at: $setupScriptPath" -ForegroundColor Yellow exit 1 } $setupScriptContent = Get-Content $setupScriptPath -Raw $setupScriptB64 = ConvertTo-Base64 $setupScriptContent # Build skip exports for components $skipExports = @() if ($SkipVNC) { $skipExports += "export SKIP_VNC=1" } if ($SkipPostgreSQL) { $skipExports += "export SKIP_POSTGRESQL=1" } if ($SkipOllama) { $skipExports += "export SKIP_OLLAMA=1" } if ($SkipPlaywright) { $skipExports += "export SKIP_PLAYWRIGHT=1; export SKIP_CHROMIUM=1" } $skipExportsStr = ($skipExports -join "; ") if ($skipExportsStr) { $skipExportsStr += "; " } # VNC password handling $vncPasswordExport = "" if (-not $SkipVNC -and $VNCPassword) { $vncPasswordB64 = ConvertTo-Base64 $VNCPassword $vncPasswordExport = "export VNC_PASSWORD=`$(echo '$vncPasswordB64' | base64 -d); " } $cloudInitUserData = @" #cloud-config hostname: $VMName manage_etc_hosts: true fqdn: $VMName.local users: - name: dev gecos: Development User sudo: ALL=(ALL) NOPASSWD:ALL shell: /bin/bash lock_passwd: false ssh_authorized_keys: - $sshPublicKey chpasswd: expire: false users: - name: dev password: $VMPassword type: text package_update: true package_upgrade: false packages: - openssh-server - curl - wget - git - ca-certificates write_files: - path: /tmp/setup_env.sh permissions: '0755' encoding: b64 content: $setupScriptB64 - path: /tmp/provision.sh permissions: '0755' content: | #!/bin/bash set -e echo "=== Starting provisioning ===" # Wait for apt locks to be released while fuser /var/lib/apt/lists/lock >/dev/null 2>&1; do echo "Waiting for apt lock..." sleep 5 done # Set environment variables export GIT_NAME="$GitName" export GIT_EMAIL="$GitEmail" $vncPasswordExport$skipExportsStr # Run setup script as dev user cd /home/dev sudo -E -u dev bash /tmp/setup_env.sh --non-interactive --yes 2>&1 | tee /var/log/provision.log echo "=== Provisioning complete ===" runcmd: - systemctl enable ssh - systemctl start ssh - echo "Starting provisioning in background..." - nohup bash /tmp/provision.sh > /var/log/provision-runner.log 2>&1 & final_message: "Cloud-init complete after \$UPTIME seconds. Provisioning running in background." "@ $cloudInitMetaData = @" instance-id: $VMName-$(Get-Date -Format 'yyyyMMddHHmmss') local-hostname: $VMName "@ # Create cloud-init ISO (contains sensitive data - will be cleaned up) $cloudInitISO = "$VMS_PATH\$VMName\cloud-init.iso" $script:CleanupResources.ISOPath = $cloudInitISO Write-Status "Creating cloud-init ISO..." "Info" try { $isoResult = New-CloudInitISO -OutputPath $cloudInitISO -UserDataContent $cloudInitUserData -MetaDataContent $cloudInitMetaData if (-not (Test-Path $cloudInitISO)) { throw "ISO file was not created" } Write-Status "Cloud-init ISO created" "Success" Write-Log "Cloud-init ISO contains passwords - will be removed after first boot" } catch { Write-Status "Failed to create cloud-init ISO: $_" "Error" Write-Host "" Write-Host "Options to fix this:" -ForegroundColor Yellow Write-Host "" Write-Host "1. Install Windows ADK (includes oscdimg):" -ForegroundColor Cyan Write-Host " https://docs.microsoft.com/en-us/windows-hardware/get-started/adk-install" -ForegroundColor White Write-Host "" Write-Host "2. Install genisoimage in WSL:" -ForegroundColor Cyan Write-Host " wsl sudo apt-get install genisoimage" -ForegroundColor White Write-Host "" Invoke-Cleanup -OnError exit 1 } # ============================================================================ # Create Hyper-V VM # ============================================================================ Write-Status "Creating Hyper-V VM: $VMName" "Info" Write-Host " CPUs: $CPUs" -ForegroundColor Gray Write-Host " Memory: $MemoryGB GB (dynamic)" -ForegroundColor Gray Write-Host " Disk: $DiskGB GB" -ForegroundColor Gray Write-Host " Switch: $($vmSwitch.Name)" -ForegroundColor Gray try { # Create VM (Generation 2 for UEFI) $vm = New-VM -Name $VMName ` -Generation 2 ` -MemoryStartupBytes ($MemoryGB * 1GB) ` -VHDPath $vmDiskPath ` -SwitchName $vmSwitch.Name ` -Path "$VMS_PATH\$VMName" # Track for cleanup $script:CleanupResources.VMCreated = $true $script:CleanupResources.VMName = $VMName # Configure VM processor Set-VMProcessor -VM $vm -Count $CPUs # Configure dynamic memory Set-VMMemory -VM $vm -DynamicMemoryEnabled $true ` -MinimumBytes 2GB ` -StartupBytes ($MemoryGB * 1GB) ` -MaximumBytes ($MemoryGB * 1GB) # Disable Secure Boot for Ubuntu (required for cloud images) Set-VMFirmware -VM $vm -EnableSecureBoot Off # Add cloud-init ISO as DVD drive Add-VMDvdDrive -VM $vm -Path $cloudInitISO # Configure boot order (DVD first for initial boot, disk for subsequent) $dvd = Get-VMDvdDrive -VM $vm $hdd = Get-VMHardDiskDrive -VM $vm Set-VMFirmware -VM $vm -BootOrder $dvd, $hdd # Security: Disable integration services for maximum isolation Write-Status "Configuring security settings..." "Info" Disable-VMIntegrationService -VM $vm -Name "Guest Service Interface" -ErrorAction SilentlyContinue Disable-VMIntegrationService -VM $vm -Name "Heartbeat" -ErrorAction SilentlyContinue # Keep Time Synchronization and Shutdown for usability # Enable nested virtualization (for Docker inside VM) try { Set-VMProcessor -VM $vm -ExposeVirtualizationExtensions $true Write-Host " Nested virtualization: enabled" -ForegroundColor Gray } catch { Write-Host " Nested virtualization: not available" -ForegroundColor Gray } # Set automatic checkpoints off (for performance) Set-VM -VM $vm -AutomaticCheckpointsEnabled $false Write-Status "VM created successfully" "Success" } catch { Write-Status "Failed to create VM: $_" "Error" Invoke-Cleanup -OnError exit 1 } # ============================================================================ # Start VM and Wait for Provisioning # ============================================================================ Write-Status "Starting VM..." "Info" Start-VM -Name $VMName Write-Host "" Write-Host "VM is starting. Cloud-init will provision the development environment." -ForegroundColor Yellow Write-Host "This typically takes 10-15 minutes for all components." -ForegroundColor Yellow Write-Host "" # Wait for VM to get an IP Write-Status "Waiting for VM to obtain IP address..." "Info" $maxWait = 180 # 3 minutes for IP $waited = 0 $vmIP = $null while ($waited -lt $maxWait) { Start-Sleep -Seconds 5 $waited += 5 $vmIP = (Get-VM -Name $VMName | Get-VMNetworkAdapter).IPAddresses | Where-Object { $_ -match '^\d+\.\d+\.\d+\.\d+$' -and $_ -notmatch '^169\.254\.' } | Select-Object -First 1 if ($vmIP) { break } Write-Host "." -NoNewline } Write-Host "" if ($vmIP) { Write-Status "VM IP: $vmIP" "Success" # Update hosts file for easy access (with backup) $hostsFile = "$env:SystemRoot\System32\drivers\etc\hosts" $hostsBackup = "$env:SystemRoot\System32\drivers\etc\hosts.backup_$(Get-Date -Format 'yyyyMMdd_HHmmss')" $hostsEntry = "$vmIP`t$VMName.local" try { # Create backup before modification if (Test-Path $hostsFile) { Copy-Item -Path $hostsFile -Destination $hostsBackup -Force Write-Log "Created hosts file backup: $hostsBackup" } # Remove old entry if exists $hostsContent = Get-Content $hostsFile -ErrorAction SilentlyContinue $hostsContent = $hostsContent | Where-Object { $_ -notmatch "\s+$VMName\.local\s*$" } # Add new entry $hostsContent += $hostsEntry $hostsContent | Set-Content $hostsFile -Force Write-Status "Updated hosts file: $VMName.local -> $vmIP" "Info" # Remove backup if successful (keep only on failure) Remove-Item -Path $hostsBackup -Force -ErrorAction SilentlyContinue } catch { Write-Status "Failed to update hosts file: $_" "Warning" Write-Host " You may need to manually add: $hostsEntry" -ForegroundColor Yellow Write-Host " To file: $hostsFile" -ForegroundColor Yellow if (Test-Path $hostsBackup) { Write-Host " Backup available at: $hostsBackup" -ForegroundColor Yellow } } # Wait a moment for SSH to be ready Write-Status "Waiting for SSH to become available..." "Info" $sshReady = $false $sshWait = 0 while ($sshWait -lt 120 -and -not $sshReady) { Start-Sleep -Seconds 5 $sshWait += 5 $tcpTest = Test-NetConnection -ComputerName $vmIP -Port 22 -WarningAction SilentlyContinue if ($tcpTest.TcpTestSucceeded) { $sshReady = $true } Write-Host "." -NoNewline } Write-Host "" if ($sshReady) { Write-Status "SSH is ready!" "Success" } else { Write-Status "SSH not responding yet (may still be starting)" "Warning" } Write-Host "" Write-Host "============================================================================" -ForegroundColor Green Write-Host " VM '$VMName' is ready!" -ForegroundColor Green Write-Host "============================================================================" -ForegroundColor Green Write-Host "" Write-Host "CONNECT:" -ForegroundColor Yellow Write-Host " SSH: ssh -i `"$sshKeyPath`" dev@$VMName.local" -ForegroundColor White Write-Host " Console: vmconnect localhost $VMName" -ForegroundColor White Write-Host "" Write-Host "MONITOR PROVISIONING:" -ForegroundColor Yellow Write-Host " ssh -i `"$sshKeyPath`" dev@$VMName.local tail -f /var/log/provision.log" -ForegroundColor White Write-Host "" Write-Host "SERVICES (after provisioning completes):" -ForegroundColor Yellow Write-Host " Claude: ssh -i `"$sshKeyPath`" dev@$VMName.local claude" -ForegroundColor White if (-not $SkipPostgreSQL) { Write-Host " PostgreSQL: ssh -i `"$sshKeyPath`" dev@$VMName.local psql" -ForegroundColor White } if (-not $SkipVNC) { Write-Host " VNC: Connect to $vmIP`:5901 (start with: vnc-start)" -ForegroundColor White } Write-Host "" Write-Host "MANAGEMENT:" -ForegroundColor Yellow Write-Host " Stop: Stop-VM -Name $VMName" -ForegroundColor White Write-Host " Start: Start-VM -Name $VMName" -ForegroundColor White Write-Host " Delete: Stop-VM -Name $VMName -Force; Remove-VM -Name $VMName -Force" -ForegroundColor White Write-Host " Cleanup: Remove-Item -Path `"$VMS_PATH\$VMName`" -Recurse -Force" -ForegroundColor White Write-Host "" } else { Write-Status "Could not obtain VM IP within timeout." "Warning" Write-Host "" Write-Host "The VM may still be starting. To check:" -ForegroundColor Yellow Write-Host " vmconnect localhost $VMName" -ForegroundColor White Write-Host "" Write-Host "To get IP later:" -ForegroundColor Yellow Write-Host " (Get-VM -Name $VMName | Get-VMNetworkAdapter).IPAddresses" -ForegroundColor White Write-Host "" } # Security reminder about cloud-init ISO Write-Host "SECURITY:" -ForegroundColor Yellow Write-Host " The cloud-init ISO contains your VM password. After first boot completes," -ForegroundColor White Write-Host " remove it for security:" -ForegroundColor White Write-Host " Stop-VM -Name $VMName; Remove-VMDvdDrive -VMName $VMName -ControllerNumber 0 -ControllerLocation 1" -ForegroundColor Cyan Write-Host " Remove-Item `"$cloudInitISO`"" -ForegroundColor Cyan Write-Host "" Write-Host "LOG FILE:" -ForegroundColor Yellow Write-Host " $LOG_FILE" -ForegroundColor White Write-Host "" Write-Host "============================================================================" -ForegroundColor Green Write-Host " Security: Full Hyper-V isolation (separate kernel, network, filesystem)" -ForegroundColor Green Write-Host "============================================================================" -ForegroundColor Green Write-Host "" Write-Log "Setup completed successfully for VM: $VMName"