secure_agent_envs/setup_env_windows.ps1
guessthepw ca12925111 Adds security hardening to Windows Hyper-V script
Security improvements:
- SHA256 checksum verification for Ubuntu image downloads
- Strict input validation for all user inputs (git name/email, passwords)
- Blocks shell metacharacters to prevent injection attacks
- Config file created with restricted ACL from the start
- VNC password minimum increased to 8 characters
- Security reminder to remove cloud-init ISO after first boot

Reliability improvements:
- ARM64 architecture detection for Windows on ARM
- Log file creation for troubleshooting
- Automatic cleanup on failure (VM, disk, ISO)
- Hosts file backup before modification

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-25 13:23:08 -05:00

1076 lines
39 KiB
PowerShell

<#
.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: <sha256> <filename> or <sha256> *<filename>
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$<salt>$<hash>
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"