├── tools ├── oscdimg.exe ├── Metadata-Functions.ps1 └── Virtio-Functions.ps1 ├── bootstrap.ps1 ├── Enable-RemoteManagementViaSession.ps1 ├── Get-VirtioImage.ps1 ├── New-VMSession.ps1 ├── Set-NetIPv6AddressViaSession.ps1 ├── Get-DebianImage.ps1 ├── Get-UbuntuImage.ps1 ├── Set-NetIPAddressViaSession.ps1 ├── Get-OPNsenseImage.ps1 ├── Add-VirtioDrivers.ps1 ├── Move-VMOffline.ps1 ├── Download-VerifiedFile.ps1 ├── New-VMFromWindowsImage.ps1 ├── New-VHDXFromWindowsImage.ps1 ├── New-VMFromIsoImage.ps1 ├── New-WindowsUnattendFile.ps1 ├── New-VMFromDebianImage.ps1 ├── New-VMFromUbuntuImage.ps1 └── README.md /tools/oscdimg.exe: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/fdcastel/Hyper-V-Automation/HEAD/tools/oscdimg.exe -------------------------------------------------------------------------------- /bootstrap.ps1: -------------------------------------------------------------------------------- 1 | # Enables TLS 1.2 2 | [Net.ServicePointManager]::SecurityProtocol = [Net.ServicePointManager]::SecurityProtocol -bor [Net.SecurityProtocolType]::Tls12 3 | 4 | $url = 'https://codeload.github.com/fdcastel/Hyper-V-Automation/zip/master' 5 | $fileName = Join-Path $env:TEMP 'Hyper-V-Automation-master.zip' 6 | 7 | Invoke-RestMethod $url -OutFile $fileName 8 | Expand-Archive $fileName -DestinationPath $env:TEMP -Force 9 | 10 | cd "$env:TEMP\Hyper-V-Automation-master" 11 | -------------------------------------------------------------------------------- /Enable-RemoteManagementViaSession.ps1: -------------------------------------------------------------------------------- 1 | [CmdletBinding()] 2 | param( 3 | [Parameter(Mandatory=$true)] 4 | [System.Management.Automation.Runspaces.PSSession[]]$Session 5 | ) 6 | 7 | $ErrorActionPreference = 'Stop' 8 | 9 | Invoke-Command -Session $Session { 10 | # Enable remote administration 11 | Enable-PSRemoting -SkipNetworkProfileCheck -Force 12 | Enable-WSManCredSSP -Role server -Force 13 | 14 | # Default rule is for 'Local Subnet' only. Change to 'Any'. 15 | Set-NetFirewallRule -DisplayName 'Windows Remote Management (HTTP-In)' -RemoteAddress Any 16 | } | Out-Null 17 | -------------------------------------------------------------------------------- /Get-VirtioImage.ps1: -------------------------------------------------------------------------------- 1 | [CmdletBinding()] 2 | param( 3 | [string]$OutputPath 4 | ) 5 | 6 | $ErrorActionPreference = 'Stop' 7 | 8 | $urlRoot = 'https://fedorapeople.org/groups/virt/virtio-win/direct-downloads/stable-virtio/' 9 | $urlFile = 'virtio-win.iso' 10 | 11 | $url = "$urlRoot/$urlFile" 12 | 13 | if (-not $OutputPath) { 14 | $OutputPath = Get-Item '.\' 15 | } 16 | 17 | $imgFile = Join-Path $OutputPath $urlFile 18 | 19 | if ([System.IO.File]::Exists($imgFile)) { 20 | Write-Verbose "File '$imgFile' already exists. Nothing to do." 21 | } else { 22 | Write-Verbose "Downloading file '$imgFile'..." 23 | 24 | $client = New-Object System.Net.WebClient 25 | $client.DownloadFile($url, $imgFile) 26 | } 27 | 28 | $imgFile 29 | -------------------------------------------------------------------------------- /New-VMSession.ps1: -------------------------------------------------------------------------------- 1 | [CmdletBinding()] 2 | param( 3 | [Parameter(Mandatory=$true)] 4 | [string]$VMName, 5 | 6 | [Parameter(Mandatory=$true)] 7 | [string]$AdministratorPassword, 8 | 9 | [string]$DomainName 10 | ) 11 | 12 | if ($DomainName) { 13 | $userName = "$DomainName\administrator" 14 | } else { 15 | $userName = 'administrator' 16 | } 17 | $pass = ConvertTo-SecureString $AdministratorPassword -AsPlainText -Force 18 | $cred = New-Object System.Management.Automation.PSCredential($userName, $pass) 19 | 20 | do { 21 | $result = New-PSSession -VMName $VMName -Credential $cred -ErrorAction SilentlyContinue 22 | 23 | if (-not $result) { 24 | Write-Verbose "Waiting for connection with '$VMName'..." 25 | Start-Sleep -Seconds 1 26 | } 27 | } while (-not $result) 28 | $result 29 | -------------------------------------------------------------------------------- /Set-NetIPv6AddressViaSession.ps1: -------------------------------------------------------------------------------- 1 | [CmdletBinding()] 2 | param( 3 | [Parameter(Mandatory=$true)] 4 | [System.Management.Automation.Runspaces.PSSession[]]$Session, 5 | 6 | [string]$AdapterName, 7 | 8 | [ValidateScript({ 9 | if ($_.AddressFamily -ne 'InterNetworkV6') { 10 | throw 'IPAddress must be an IPv6 address.' 11 | } 12 | $true 13 | })] 14 | [Parameter(Mandatory=$true)] 15 | [ipaddress]$IPAddress, 16 | 17 | [Parameter(Mandatory=$true)] 18 | [byte]$PrefixLength, 19 | 20 | [string[]]$DnsAddresses = @('2001:4860:4860::8888','2001:4860:4860::8844') 21 | ) 22 | 23 | $ErrorActionPreference = 'Stop' 24 | 25 | Invoke-Command -Session $Session { 26 | $ifName = $using:AdapterName 27 | 28 | if (-not $ifName) { 29 | # Get the gateway interface for IPv4 30 | $ifName = (Get-NetIPConfiguration | Foreach IPv4DefaultGateway).InterfaceAlias 31 | } 32 | 33 | $neta = Get-NetAdapter -Name $ifName 34 | $neta | Get-NetIPAddress -AddressFamily IPv6 -PrefixOrigin Manual -ErrorAction SilentlyContinue | Remove-NetIPAddress -Confirm:$false 35 | $neta | New-NetIPAddress -AddressFamily IPv6 -IPAddress $using:IPAddress -PrefixLength $using:PrefixLength 36 | 37 | $neta | Set-DnsClientServerAddress -Addresses $using:DnsAddresses 38 | } | Out-Null 39 | -------------------------------------------------------------------------------- /Get-DebianImage.ps1: -------------------------------------------------------------------------------- 1 | [CmdletBinding()] 2 | param( 3 | [string]$OutputPath, 4 | [switch]$Previous 5 | ) 6 | 7 | $ErrorActionPreference = 'Stop' 8 | 9 | # Enables TLS 1.2 10 | [Net.ServicePointManager]::SecurityProtocol = [Net.ServicePointManager]::SecurityProtocol -bor [Net.SecurityProtocolType]::Tls12 11 | 12 | if ($Previous) { 13 | $urlRoot = "https://cloud.debian.org/images/cloud/bookworm/latest/" 14 | $urlFile = "debian-12-genericcloud-amd64.qcow2" 15 | } else { 16 | $urlRoot = 'https://cloud.debian.org/images/cloud/trixie/latest/' 17 | $urlFile = 'debian-13-genericcloud-amd64.qcow2' 18 | } 19 | 20 | $url = "$urlRoot/$urlFile" 21 | 22 | if (-not $OutputPath) { 23 | $OutputPath = Get-Item '.\' 24 | } 25 | 26 | $imgFile = Join-Path $OutputPath $urlFile 27 | 28 | if ([System.IO.File]::Exists($imgFile)) { 29 | Write-Verbose "File '$imgFile' already exists. Nothing to do." 30 | } else { 31 | Write-Verbose "Downloading file '$imgFile'..." 32 | 33 | $client = New-Object System.Net.WebClient 34 | $client.DownloadFile($url, $imgFile) 35 | 36 | Write-Verbose "Checking file integrity..." 37 | $sha1Hash = Get-FileHash $imgFile -Algorithm SHA512 38 | $allHashs = $client.DownloadString("$urlRoot/SHA512SUMS") 39 | $m = [regex]::Matches($allHashs, "(?\w{128})\s\s$urlFile") 40 | if (-not $m[0]) { throw "Cannot get hash for $urlFile." } 41 | $expectedHash = $m[0].Groups['Hash'].Value 42 | if ($sha1Hash.Hash -ne $expectedHash) { throw "Integrity check for '$imgFile' failed." } 43 | } 44 | 45 | $imgFile 46 | -------------------------------------------------------------------------------- /Get-UbuntuImage.ps1: -------------------------------------------------------------------------------- 1 | [CmdletBinding()] 2 | param( 3 | [string]$OutputPath, 4 | [switch]$Previous 5 | ) 6 | 7 | $ErrorActionPreference = 'Stop' 8 | 9 | # Enables TLS 1.2 10 | [Net.ServicePointManager]::SecurityProtocol = [Net.ServicePointManager]::SecurityProtocol -bor [Net.SecurityProtocolType]::Tls12 11 | 12 | if ($Previous) { 13 | $urlRoot = 'https://cloud-images.ubuntu.com/releases/jammy/release/' 14 | $urlFile = 'ubuntu-22.04-server-cloudimg-amd64.img' 15 | } else { 16 | $urlRoot = 'https://cloud-images.ubuntu.com/releases/noble/release/' 17 | $urlFile = 'ubuntu-24.04-server-cloudimg-amd64.img' 18 | } 19 | 20 | $url = "$urlRoot/$urlFile" 21 | 22 | if (-not $OutputPath) { 23 | $OutputPath = Get-Item '.\' 24 | } 25 | 26 | $imgFile = Join-Path $OutputPath $urlFile 27 | 28 | if ([System.IO.File]::Exists($imgFile)) { 29 | Write-Verbose "File '$imgFile' already exists. Nothing to do." 30 | } else { 31 | Write-Verbose "Downloading file '$imgFile'..." 32 | 33 | $client = New-Object System.Net.WebClient 34 | $client.DownloadFile($url, $imgFile) 35 | 36 | Write-Verbose "Checking file integrity..." 37 | $sha1Hash = Get-FileHash $imgFile -Algorithm SHA256 38 | $allHashs = $client.DownloadString("$urlRoot/SHA256SUMS") 39 | $m = [regex]::Matches($allHashs, "(?\w{64})\s\*$urlFile") 40 | if (-not $m[0]) { throw "Cannot get hash for $urlFile." } 41 | $expectedHash = $m[0].Groups['Hash'].Value 42 | if ($sha1Hash.Hash -ne $expectedHash) { throw "Integrity check for '$imgFile' failed." } 43 | } 44 | 45 | $imgFile 46 | -------------------------------------------------------------------------------- /Set-NetIPAddressViaSession.ps1: -------------------------------------------------------------------------------- 1 | [CmdletBinding()] 2 | param( 3 | [Parameter(Mandatory=$true)] 4 | [System.Management.Automation.Runspaces.PSSession[]]$Session, 5 | 6 | [string]$AdapterName = 'Ethernet', 7 | 8 | [Parameter(Mandatory=$true)] 9 | [string]$IPAddress, 10 | 11 | [Parameter(Mandatory=$true)] 12 | [byte]$PrefixLength, 13 | 14 | [Parameter(Mandatory=$true)] 15 | [string]$DefaultGateway, 16 | 17 | [string[]]$DnsAddresses = @('8.8.8.8','8.8.4.4'), 18 | 19 | [ValidateSet('Public', 'Private')] 20 | [string]$NetworkCategory = 'Public' 21 | ) 22 | 23 | $ErrorActionPreference = 'Stop' 24 | 25 | Invoke-Command -Session $Session { 26 | Remove-NetRoute -NextHop $using:DefaultGateway -Confirm:$false -ErrorAction SilentlyContinue 27 | $neta = Get-NetAdapter $using:AdapterName # Use the exact adapter name for multi-adapter VMs 28 | $neta | Set-NetConnectionProfile -NetworkCategory $using:NetworkCategory 29 | $neta | Set-NetIPInterface -Dhcp Disabled 30 | $neta | Get-NetIPAddress -AddressFamily IPv4 -ErrorAction SilentlyContinue | Remove-NetIPAddress -Confirm:$false 31 | 32 | # New-NetIPAddress may fail for certain scenarios (e.g. PrefixLength = 32). Using netsh instead. 33 | $mask = [IPAddress](([UInt32]::MaxValue) -shl (32 - $using:PrefixLength) -shr (32 - $using:PrefixLength)) 34 | netsh interface ipv4 set address name="$($neta.InterfaceAlias)" static $using:IPAddress $mask.IPAddressToString $using:DefaultGateway 35 | 36 | $neta | Set-DnsClientServerAddress -Addresses $using:DnsAddresses 37 | } | Out-Null 38 | -------------------------------------------------------------------------------- /Get-OPNsenseImage.ps1: -------------------------------------------------------------------------------- 1 | [CmdletBinding()] 2 | param( 3 | [string]$OutputPath 4 | ) 5 | 6 | $ErrorActionPreference = 'Stop' 7 | 8 | $urlRoot = 'https://mirror.wdc1.us.leaseweb.net/opnsense/releases/25.7' 9 | $urlFile = 'OPNsense-25.7-dvd-amd64.iso.bz2' 10 | 11 | $url = "$urlRoot/$urlFile" 12 | 13 | if (-not $OutputPath) { 14 | $OutputPath = Get-Item '.\' 15 | } 16 | 17 | $isoFile = Join-Path $OutputPath $urlFile 18 | 19 | $uncompressedUrlFile = [System.IO.Path]::GetFileNameWithoutExtension($urlFile) 20 | $uncompressedIsoFile = Join-Path $OutputPath $uncompressedUrlFile 21 | 22 | if ([System.IO.File]::Exists($uncompressedIsoFile)) { 23 | Write-Verbose "File '$uncompressedIsoFile' already exists. Nothing to do." 24 | } else { 25 | if ([System.IO.File]::Exists($isoFile)) { 26 | Write-Verbose "File '$isoFile' already exists." 27 | } else 28 | { 29 | Write-Verbose "Downloading file '$isoFile'..." 30 | 31 | $client = New-Object System.Net.WebClient 32 | $client.DownloadFile($url, $isoFile) 33 | } 34 | 35 | $7zCommand = Get-Command "7z.exe" -ErrorAction SilentlyContinue 36 | if (-not $7zCommand) 37 | { 38 | throw "7z.exe not found. Please install it with 'choco install 7zip -y'." 39 | } 40 | 41 | Write-Verbose "Extracting file '$isoFile' to '$OutputPath'..." 42 | & 7z.exe e $isoFile "-o$($OutputPath)" | Out-Null 43 | 44 | $fileExists = Test-Path -Path $uncompressedisoFile 45 | if (-not $fileExists) { 46 | throw "Image '$uncompressedUrlFile' not found after extracting .bz2 file." 47 | } 48 | } 49 | 50 | $uncompressedIsoFile 51 | -------------------------------------------------------------------------------- /Add-VirtioDrivers.ps1: -------------------------------------------------------------------------------- 1 | #Requires -RunAsAdministrator 2 | 3 | [CmdletBinding()] 4 | param( 5 | [Parameter(Mandatory=$true)] 6 | [string]$VirtioIsoPath, 7 | 8 | [Parameter(Mandatory=$true)] 9 | [string]$ImagePath, 10 | 11 | [Parameter(Mandatory=$true)] 12 | [ValidateSet('Server2025Datacenter', 13 | 'Server2025Standard', 14 | 'Server2022Datacenter', 15 | 'Server2022Standard', 16 | 'Server2019Datacenter', 17 | 'Server2019Standard', 18 | 'Server2016Datacenter', 19 | 'Server2016Standard', 20 | 'Windows11Enterprise', 21 | 'Windows11Professional', 22 | 'Windows10Enterprise', 23 | 'Windows10Professional', 24 | 'Windows81Professional')] 25 | [string]$Version, 26 | 27 | [int]$ImageIndex = 1 28 | ) 29 | 30 | $ErrorActionPreference = 'Stop' 31 | 32 | 33 | 34 | # 35 | # Main 36 | # 37 | 38 | # Reference: https://pve.proxmox.com/wiki/Windows_10_guest_best_practices 39 | 40 | . .\tools\Virtio-Functions.ps1 41 | 42 | With-IsoImage -IsoFileName $VirtioIsoPath { 43 | Param($virtioDriveLetter) 44 | 45 | # Throws if the ISO does not contain Virtio drivers. 46 | $virtioDrivers = Get-VirtioDrivers -VirtioDriveLetter $virtioDriveLetter -Version $Version 47 | 48 | With-WindowsImage -ImagePath $ImagePath -ImageIndex $ImageIndex -VirtioDriveLetter $VirtioDriveLetter { 49 | Param($mountPath) 50 | 51 | $virtioDrivers | ForEach-Object { 52 | Add-WindowsDriver -Path $mountPath -Driver $_ -Recurse -ForceUnsigned > $null 53 | } 54 | } 55 | } 56 | -------------------------------------------------------------------------------- /tools/Metadata-Functions.ps1: -------------------------------------------------------------------------------- 1 | # 2 | # Functions for working with cloud-init Metadata drives. 3 | # 4 | 5 | function New-MetadataIso( 6 | [string]$VMName, 7 | [string]$Metadata, 8 | [string]$UserData, 9 | [string]$NetworkConfig 10 | ) { 11 | Write-Verbose 'Creating metadata ISO image...' 12 | $tempPath = [System.IO.Path]::GetTempPath() 13 | 14 | # Creates temporary folder for ISO content. 15 | $metadataContentRoot = Join-Path $tempPath "$VMName-metadata" 16 | mkdir $metadataContentRoot > $null 17 | try { 18 | # Write metadata files. 19 | $Metadata | Out-File "$metadataContentRoot\meta-data" -Encoding ascii 20 | $UserData | Out-File "$metadataContentRoot\user-data" -Encoding ascii 21 | if ($NetworkConfig) { 22 | $NetworkConfig | Out-File "$metadataContentRoot\network-config" -Encoding ascii 23 | } 24 | 25 | # Use temp folder for metadata ISO -- https://github.com/fdcastel/Hyper-V-Automation/issues/13 26 | $metadataIso = Join-Path $tempPath "$VMName-metadata.iso" 27 | 28 | # Write metadata ISO file. 29 | $oscdimgPath = Join-Path $PSScriptRoot '.\oscdimg.exe' 30 | & { 31 | $ErrorActionPreference = 'Continue' 32 | & $oscdimgPath $metadataContentRoot $metadataIso -j2 -lCIDATA 33 | if ($LASTEXITCODE -gt 0) { 34 | throw "oscdimg.exe returned $LASTEXITCODE." 35 | } 36 | } *> $null 37 | 38 | Write-Verbose "Metadata ISO created: $metadataIso" 39 | return $metadataIso 40 | } 41 | finally { 42 | # Clear temporary folder. 43 | Remove-Item -Path $metadataContentRoot -Recurse -Force 44 | $ErrorActionPreference = 'Stop' 45 | } 46 | } 47 | -------------------------------------------------------------------------------- /Move-VMOffline.ps1: -------------------------------------------------------------------------------- 1 | [CmdletBinding()] 2 | param( 3 | [Parameter(Mandatory=$true)] 4 | [string]$VMName, 5 | 6 | [Parameter(Mandatory=$true)] 7 | [string]$DestinationHost, 8 | 9 | [Parameter(Mandatory=$true)] 10 | [string]$CertificateThumbprint 11 | ) 12 | 13 | $ErrorActionPreference = 'Stop' 14 | 15 | if ((Get-VM $VMName).State -eq 'Running') { 16 | throw "The virtual machine must be stopped to use this command." 17 | } 18 | 19 | # Remove current replication (if any) 20 | Remove-VMReplication -VMName $VMName -ErrorAction SilentlyContinue 21 | 22 | # Setup temporary replication to destination host 23 | Enable-VMReplication -VMName $VMName -ReplicaServerName $DestinationHost -AuthenticationType Certificate -CertificateThumbprint $CertificateThumbprint -ReplicaServerPort 443 -CompressionEnabled $true 24 | 25 | # Move VM files from Replica to Primary location 26 | $sourceVhdx = (Get-VM $VMName -ComputerName $DestinationHost | Get-VMHardDiskDrive).Path 27 | $targetVhdx = (Get-VM $VMName | Get-VMHardDiskDrive).Path 28 | $targetVhdx = Join-Path (Split-Path $targetVhdx -Parent) (Split-Path $sourceVhdx -Leaf) 29 | $vhds = @(@{'SourceFilePath' = $sourceVhdx; 'DestinationFilePath' = $targetVhdx}) 30 | Invoke-Command -ComputerName $DestinationHost { 31 | Move-VMStorage -VMName $using:VMName -VirtualMachinePath 'C:\Hyper-V\Virtual Machines' -SnapshotFilePath 'C:\Hyper-V\Snapshots' -VHDs $using:vhds 32 | } 33 | 34 | # Replicate 35 | Start-VMInitialReplication -VMName $VMName -AsJob | 36 | Receive-Job -Wait 37 | 38 | # Start Failover 39 | Start-VMFailover -Prepare -VMName $VMName -Confirm:$false 40 | Start-VMFailover -VMName $VMName -ComputerName $DestinationHost -Confirm:$false 41 | 42 | # Promote Replica to Primary 43 | Set-VMReplication -Reverse -VMName $VMName -ComputerName $DestinationHost -CertificateThumbprint $CertificateThumbprint 44 | 45 | # Remove temporary replication 46 | Remove-VMReplication -VMName $VMName 47 | Remove-VMReplication -VMName $VMName -ComputerName $DestinationHost 48 | 49 | # Connect VM to switch 50 | Get-VMNetworkAdapter -ComputerName $DestinationHost -VMName $VMName | 51 | Connect-VMNetworkAdapter -SwitchName 'SWITCH' 52 | 53 | # Start VM 54 | $vm = Start-VM -VMName $VMName -ComputerName $DestinationHost -Passthru 55 | 56 | # Wait for VM 57 | $heartbeatService = $vm | Get-VMIntegrationService -Name 'Heartbeat' 58 | $vmOnline = $false 59 | do { 60 | Start-Sleep -Seconds 1 61 | $vmOnline = $heartbeatService.PrimaryStatusDescription -eq 'OK' 62 | } until ($vmOnline) 63 | 64 | # If VM is responding on new server, remove the source VM (do not erase .VHDXs) 65 | if ($vmOnline) { 66 | Remove-VM -VMName $VMName -Force 67 | } 68 | -------------------------------------------------------------------------------- /Download-VerifiedFile.ps1: -------------------------------------------------------------------------------- 1 | # 2 | # Develop a Windows PowerShell function that downloads a file to a target directory based on a specified URL and SHA-256 hash. 3 | # The target directory can be specified as a parameter. If not provided, the default location will be $env:TEMP. 4 | # The function should verify whether the file already exists. If the file is not present, it must be downloaded. 5 | # After downloading, the function must compute the file's SHA-256 hash and compare it to the expected value. If the hashes do not match, an error should be raised. 6 | # In cases where the file already exists but fails the hash validation, the function must re-download the file and overwrite the existing version. 7 | # 8 | # GitHub Copilot > Claude 3.7 Sonnet (Preview) 9 | # 10 | [CmdletBinding()] 11 | param ( 12 | [Parameter(Mandatory = $true)] 13 | [string]$Url, 14 | 15 | [Parameter(Mandatory = $true)] 16 | [string]$ExpectedHash, 17 | 18 | [Parameter(Mandatory = $false)] 19 | [string]$TargetDirectory = $env:TEMP 20 | ) 21 | 22 | # Ensure target directory exists 23 | if (-not (Test-Path -Path $TargetDirectory -PathType Container)) { 24 | New-Item -Path $TargetDirectory -ItemType Directory -Force | Out-Null 25 | Write-Verbose "Created directory: $TargetDirectory" 26 | } 27 | 28 | # Extract filename from URL 29 | $fileName = [System.IO.Path]::GetFileName($Url) 30 | $filePath = Join-Path -Path $TargetDirectory -ChildPath $fileName 31 | 32 | # Flag to determine if download is needed 33 | $downloadRequired = $true 34 | 35 | # Check if file already exists 36 | if (Test-Path -Path $filePath -PathType Leaf) { 37 | Write-Verbose "File already exists: $filePath. Verifying hash..." 38 | 39 | # Calculate hash of existing file 40 | $fileHash = (Get-FileHash -Path $filePath -Algorithm SHA256).Hash 41 | 42 | # Compare hash 43 | if ($fileHash -eq $ExpectedHash) { 44 | Write-Verbose "Hash verification successful for existing file." 45 | $downloadRequired = $false 46 | } 47 | else { 48 | Write-Warning "Existing file hash does not match expected hash. Re-downloading..." 49 | } 50 | } 51 | 52 | # Download file if required 53 | if ($downloadRequired) { 54 | try { 55 | Write-Verbose "Downloading $Url to $filePath..." 56 | Invoke-WebRequest -Uri $Url -OutFile $filePath -UseBasicParsing 57 | 58 | # Verify hash of downloaded file 59 | $fileHash = (Get-FileHash -Path $filePath -Algorithm SHA256).Hash 60 | 61 | if ($fileHash -ne $ExpectedHash) { 62 | Remove-Item -Path $filePath -Force 63 | throw "Downloaded file hash ($fileHash) does not match expected hash ($ExpectedHash)." 64 | } 65 | 66 | Write-Verbose "Download complete and hash verification successful." 67 | } 68 | catch { 69 | throw "Failed to download or verify file: $_" 70 | } 71 | } 72 | 73 | # Return the file path 74 | return $filePath 75 | -------------------------------------------------------------------------------- /New-VMFromWindowsImage.ps1: -------------------------------------------------------------------------------- 1 | #Requires -RunAsAdministrator 2 | 3 | [CmdletBinding()] 4 | param( 5 | [Parameter(Mandatory=$true)] 6 | [string]$SourcePath, 7 | 8 | [Parameter(Mandatory=$true)] 9 | [string]$Edition, 10 | 11 | [Parameter(Mandatory=$true)] 12 | [string]$VMName, 13 | 14 | [Parameter(Mandatory=$true)] 15 | [uint64]$VHDXSizeBytes, 16 | 17 | [Parameter(Mandatory=$true)] 18 | [string]$AdministratorPassword, 19 | 20 | [Parameter(Mandatory=$true)] 21 | [ValidateSet('Server2025Datacenter', 22 | 'Server2025Standard', 23 | 'Server2022Datacenter', 24 | 'Server2022Standard', 25 | 'Server2019Datacenter', 26 | 'Server2019Standard', 27 | 'Server2016Datacenter', 28 | 'Server2016Standard', 29 | 'Windows11Enterprise', 30 | 'Windows11Professional', 31 | 'Windows10Enterprise', 32 | 'Windows10Professional', 33 | 'Windows81Professional')] 34 | [string]$Version, 35 | 36 | [Parameter(Mandatory=$true)] 37 | [int64]$MemoryStartupBytes, 38 | 39 | [switch]$EnableDynamicMemory, 40 | 41 | [int64]$VMProcessorCount = 2, 42 | 43 | [string]$VMSwitchName = 'SWITCH', 44 | 45 | [string]$VMMacAddress, 46 | 47 | [string]$Locale = 'en-US' 48 | ) 49 | 50 | $ErrorActionPreference = 'Stop' 51 | 52 | # Get default VHD path (requires administrative privileges) 53 | $vmms = Get-WmiObject -namespace root\virtualization\v2 Msvm_VirtualSystemManagementService 54 | $vmmsSettings = Get-WmiObject -namespace root\virtualization\v2 Msvm_VirtualSystemManagementServiceSettingData 55 | $vhdxPath = Join-Path $vmmsSettings.DefaultVirtualHardDiskPath "$VMName.vhdx" 56 | 57 | # Create VHDX from ISO image 58 | .\New-VHDXFromWindowsImage.ps1 -SourcePath $SourcePath -Edition $Edition -ComputerName $VMName -VHDXSizeBytes $VHDXSizeBytes -VHDXPath $vhdxPath -AdministratorPassword $AdministratorPassword -Version $Version -Locale $Locale 59 | 60 | # Create VM 61 | Write-Verbose 'Creating VM...' 62 | $vm = New-VM -Name $VMName -Generation 2 -MemoryStartupBytes $MemoryStartupBytes -VHDPath $vhdxPath -SwitchName $VMSwitchName 63 | $vm | Set-VMProcessor -Count $VMProcessorCount 64 | $vm | Get-VMIntegrationService | 65 | Where-Object { $_ -is [Microsoft.HyperV.PowerShell.GuestServiceInterfaceComponent] } | 66 | Enable-VMIntegrationService -Passthru 67 | $vm | Set-VMMemory -DynamicMemoryEnabled:$EnableDynamicMemory.IsPresent 68 | 69 | if ($VMMacAddress) { 70 | $vm | Set-VMNetworkAdapter -StaticMacAddress ($VMMacAddress -replace ':','') 71 | } 72 | # Disable Automatic Checkpoints (doesn't exist in Server 2016) 73 | $command = Get-Command Set-VM 74 | if ($command.Parameters.AutomaticCheckpointsEnabled) { 75 | $vm | Set-VM -AutomaticCheckpointsEnabled $false 76 | } 77 | $vm | Start-VM 78 | 79 | # Wait for installation complete 80 | Write-Verbose 'Waiting for VM integration services...' 81 | Wait-VM -Name $vmName -For Heartbeat 82 | 83 | # Return the VM created. 84 | Write-Verbose 'All done!' 85 | $vm 86 | -------------------------------------------------------------------------------- /tools/Virtio-Functions.ps1: -------------------------------------------------------------------------------- 1 | # 2 | # Functions for working with Virtio drivers in Windows images. 3 | # 4 | 5 | # All drivers installed by virtio-win-gt-x64.msi. 6 | $InstallerVirtioDrivers = @( 7 | 'Balloon', 8 | 'fwcfg', 9 | 'NetKVM', 10 | 'pvpanic', 11 | 'qemupciserial', 12 | 'viofs', 13 | 'viogpudo', 14 | 'vioinput', 15 | 'viomem', 16 | 'viorng', 17 | 'vioscsi', 18 | 'vioserial', 19 | 'viostor' 20 | ) 21 | 22 | function Get-VirtioDriverFolderName([string]$Version) 23 | { 24 | $folder = switch ($Version) { 25 | 'Server2025Datacenter' {'2k25'} 26 | 'Server2025Standard' {'2k25'} 27 | 'Server2022Datacenter' {'2k22'} 28 | 'Server2022Standard' {'2k22'} 29 | 'Server2019Datacenter' {'2k19'} 30 | 'Server2019Standard' {'2k19'} 31 | 'Server2016Datacenter' {'2k16'} 32 | 'Server2016Standard' {'2k16'} 33 | 'Windows11Enterprise' {'w11'} 34 | 'Windows11Professional' {'w11'} 35 | 'Windows10Enterprise' {'w10'} 36 | 'Windows10Professional' {'w10'} 37 | 'Windows81Professional' {'w8.1'} 38 | default {'2k25'} 39 | } 40 | return $folder 41 | } 42 | 43 | function Get-VirtioDrivers([string]$VirtioDriveLetter, [string]$Version) 44 | { 45 | $virtioInstaller = "$($virtioDriveLetter):\virtio-win-gt-x64.msi" 46 | $exists = Test-Path $virtioInstaller 47 | if (-not $exists) 48 | { 49 | throw "The specified ISO does not appear to be a valid Virtio installation media." 50 | } 51 | 52 | $folder = Get-VirtioDriverFolderName $Version 53 | 54 | # All AMD64 drivers for the specified Windows version 55 | $allDrivers = Get-ChildItem "$($VirtioDriveLetter):\*\$folder\amd64\*.inf" 56 | 57 | # Just the drivers installed by virtio-win-gt-x64.msi 58 | $filteredDrivers = $allDrivers | Where-Object { 59 | $_.Directory.Parent.Parent.BaseName -in $InstallerVirtioDrivers 60 | } 61 | 62 | return $filteredDrivers.Directory.FullName 63 | } 64 | 65 | function With-IsoImage([string]$IsoFileName, [scriptblock]$ScriptBlock) 66 | { 67 | $IsoFileName = (Resolve-Path $IsoFileName).Path 68 | 69 | Write-Verbose "Mounting '$IsoFileName'..." 70 | $mountedImage = Mount-DiskImage -ImagePath $IsoFileName -StorageType ISO -PassThru 71 | try 72 | { 73 | $driveLetter = ($mountedImage | Get-Volume).DriveLetter 74 | Invoke-Command $ScriptBlock -ArgumentList $driveLetter 75 | } 76 | finally 77 | { 78 | Write-Verbose "Dismounting '$IsoFileName'..." 79 | Dismount-DiskImage -ImagePath $IsoFileName | Out-Null 80 | } 81 | } 82 | 83 | function With-WindowsImage([string]$ImagePath, [int]$ImageIndex, [string]$VirtioDriveLetter, [scriptblock]$ScriptBlock) 84 | { 85 | $mountPath = Join-Path ([System.IO.Path]::GetTempPath()) "winmount\" 86 | 87 | Write-Verbose "Mounting '$ImagePath' ($ImageIndex)..." 88 | mkdir $mountPath -Force | Out-Null 89 | Mount-WindowsImage -Path $mountPath -ImagePath $ImagePath -Index $ImageIndex | Out-Null 90 | try 91 | { 92 | Invoke-Command $ScriptBlock -ArgumentList $mountPath 93 | } 94 | finally 95 | { 96 | Write-Verbose "Dismounting '$ImagePath' ($ImageIndex)..." 97 | Dismount-WindowsImage -Path $mountPath -Save | Out-Null 98 | } 99 | } 100 | -------------------------------------------------------------------------------- /New-VHDXFromWindowsImage.ps1: -------------------------------------------------------------------------------- 1 | #Requires -RunAsAdministrator 2 | #Requires -PSEdition Desktop 3 | 4 | [CmdletBinding()] 5 | param( 6 | [Parameter(Mandatory=$true)] 7 | [string]$SourcePath, 8 | 9 | [Parameter(Mandatory=$true)] 10 | [string]$Edition, 11 | 12 | [string]$ComputerName, 13 | 14 | [string]$VHDXPath, 15 | 16 | [uint64]$VHDXSizeBytes, 17 | 18 | [string]$AdministratorPassword, 19 | 20 | [Parameter(Mandatory=$true)] 21 | [ValidateSet('Server2025Datacenter', 22 | 'Server2025Standard', 23 | 'Server2022Datacenter', 24 | 'Server2022Standard', 25 | 'Server2019Datacenter', 26 | 'Server2019Standard', 27 | 'Server2016Datacenter', 28 | 'Server2016Standard', 29 | 'Windows11Enterprise', 30 | 'Windows11Professional', 31 | 'Windows10Enterprise', 32 | 'Windows10Professional', 33 | 'Windows81Professional')] 34 | [string]$Version, 35 | 36 | [string]$Locale = 'en-US', 37 | 38 | [string]$AddVirtioDrivers 39 | ) 40 | 41 | $ErrorActionPreference = 'Stop' 42 | 43 | if (-not $VHDXPath) 44 | { 45 | # Resolve path that might not exist -- https://stackoverflow.com/a/3040982 46 | $VHDXPath = $ExecutionContext.SessionState.Path.GetUnresolvedProviderPathFromPSPath(".\$($ComputerName).vhdx") 47 | } 48 | 49 | if (-not $VHDXSizeBytes) { 50 | $VHDXSizeBytes = 120GB 51 | } 52 | 53 | if (-not $AdministratorPassword) { 54 | # Random password 55 | $AdministratorPassword = -join ( 56 | (65..90) + (97..122) + (48..57) | 57 | Get-Random -Count 16 | 58 | ForEach-Object {[char]$_} 59 | ) 60 | } 61 | 62 | # Create unattend.xml 63 | $unattendPath = .\New-WindowsUnattendFile.ps1 -AdministratorPassword $AdministratorPassword -Version $Version -ComputerName $ComputerName -Locale $Locale -AddVirtioDrivers:(!!$AddVirtioDrivers) 64 | 65 | # Create VHDX from ISO image 66 | Write-Verbose 'Creating VHDX from image...' 67 | . .\tools\Convert-WindowsImage.ps1 68 | 69 | # Create temporary folder to store files to be merged into the VHDX. 70 | $mergeFolder = Join-Path $env:TEMP 'New-VHDXFromWindowsImage-root' 71 | if (Test-Path $mergeFolder) { 72 | Remove-Item -Recurse -Force $mergeFolder 73 | } 74 | New-Item -ItemType Directory -Path $mergeFolder -Force > $null 75 | 76 | $cwiArguments = @{ 77 | SourcePath = $SourcePath 78 | Edition = $Edition 79 | VHDPath = $vhdxPath 80 | SizeBytes = $VHDXSizeBytes 81 | VHDFormat = 'VHDX' 82 | DiskLayout = 'UEFI' 83 | UnattendPath = $unattendPath 84 | MergeFolder = $mergeFolder 85 | } 86 | 87 | # Removes unattend.xml files after the setup is complete. 88 | # https://learn.microsoft.com/en-us/windows-hardware/manufacture/desktop/add-a-custom-script-to-windows-setup#run-a-script-after-setup-is-complete-setupcompletecmd 89 | $scriptsFolder = Join-Path $mergeFolder '\Windows\Setup\Scripts' 90 | New-Item -ItemType Directory -Path $scriptsFolder -Force > $null 91 | @' 92 | DEL /Q /F C:\Windows\Panther\unattend.xml 93 | DEL /Q /F C:\unattend.xml 94 | '@ | Out-File "$scriptsFolder\SetupComplete.cmd" -Encoding ascii 95 | 96 | $driversFolder = Join-Path $mergeFolder '\Windows\drivers' 97 | New-Item -ItemType Directory -Path $driversFolder -Force > $null 98 | 99 | if ($AddVirtioDrivers) { 100 | . .\tools\Virtio-Functions.ps1 101 | 102 | With-IsoImage -IsoFileName $AddVirtioDrivers { 103 | Param($virtioDriveLetter) 104 | 105 | # Throws if the ISO does not contain Virtio drivers. 106 | $virtioDrivers = Get-VirtioDrivers -VirtioDriveLetter $virtioDriveLetter -Version $Version 107 | 108 | # Adds QEMU Guest Agent installer (will be installed by unattend.xml) 109 | $msiFile = Get-Item "$($virtioDriveLetter):\guest-agent\qemu-ga-x86_64.msi" 110 | Copy-Item $msiFile -Destination $driversFolder -Force 111 | 112 | Convert-WindowsImage @cwiArguments -Driver $virtioDrivers 113 | } 114 | } else { 115 | Convert-WindowsImage @cwiArguments 116 | } 117 | 118 | $VHDXPath 119 | -------------------------------------------------------------------------------- /New-VMFromIsoImage.ps1: -------------------------------------------------------------------------------- 1 | #Requires -RunAsAdministrator 2 | 3 | [CmdletBinding()] 4 | param( 5 | [Parameter(Mandatory=$true)] 6 | [string]$IsoPath, 7 | 8 | [Parameter(Mandatory=$true)] 9 | [string]$VMName, 10 | 11 | [uint64]$VHDXSizeBytes = 120GB, 12 | 13 | [int64]$MemoryStartupBytes = 1GB, 14 | 15 | [switch]$EnableDynamicMemory, 16 | 17 | [int64]$ProcessorCount = 2, 18 | 19 | [string]$SwitchName = 'SWITCH', 20 | 21 | [string]$MacAddress, 22 | 23 | [string]$InterfaceName = 'eth0', 24 | 25 | [string]$VlanId, 26 | 27 | [string]$SecondarySwitchName, 28 | 29 | [string]$SecondaryMacAddress, 30 | 31 | [string]$SecondaryInterfaceName, 32 | 33 | [string]$SecondaryVlanId, 34 | 35 | [switch]$EnableSecureBoot 36 | ) 37 | 38 | $ErrorActionPreference = 'Stop' 39 | 40 | function Normalize-MacAddress ([string]$value) { 41 | $value.` 42 | Replace('-', '').` 43 | Replace(':', '').` 44 | Insert(2,':').Insert(5,':').Insert(8,':').Insert(11,':').Insert(14,':').` 45 | ToLowerInvariant() 46 | } 47 | 48 | # Get default VHD path (requires administrative privileges) 49 | $vmms = Get-WmiObject -namespace root\virtualization\v2 Msvm_VirtualSystemManagementService 50 | $vmmsSettings = Get-WmiObject -namespace root\virtualization\v2 Msvm_VirtualSystemManagementServiceSettingData 51 | $vhdxPath = Join-Path $vmmsSettings.DefaultVirtualHardDiskPath "$VMName.vhdx" 52 | $metadataIso = Join-Path $vmmsSettings.DefaultVirtualHardDiskPath "$VMName-metadata.iso" 53 | 54 | # Create VM 55 | Write-Verbose 'Creating VM...' 56 | $vm = New-VM -Name $VMName -Generation 2 -MemoryStartupBytes $MemoryStartupBytes -NewVHDPath $vhdxPath -NewVHDSizeBytes $VHDXSizeBytes -SwitchName $SwitchName 57 | $vm | Set-VMProcessor -Count $ProcessorCount 58 | $vm | Get-VMIntegrationService -Name "Guest Service Interface" | Enable-VMIntegrationService 59 | $vm | Set-VMMemory -DynamicMemoryEnabled:$EnableDynamicMemory.IsPresent 60 | 61 | # Adds DVD with image 62 | $dvd = $vm | Add-VMDvdDrive -Path $IsoPath -Passthru 63 | $vm | Set-VMFirmware -FirstBootDevice $dvd 64 | 65 | if ($EnableSecureBoot.IsPresent) { 66 | # Sets Secure Boot Template. 67 | # Set-VMFirmware -SecureBootTemplate 'MicrosoftUEFICertificateAuthority' doesn't work anymore (!?). 68 | $vm | Set-VMFirmware -SecureBootTemplateId ([guid]'272e7447-90a4-4563-a4b9-8e4ab00526ce') 69 | } else 70 | { 71 | # Disables Secure Boot. 72 | $vm | Set-VMFirmware -EnableSecureBoot:Off 73 | } 74 | 75 | # Setup first network adapter 76 | if ($MacAddress) { 77 | $MacAddress = Normalize-MacAddress $MacAddress 78 | $vm | Set-VMNetworkAdapter -StaticMacAddress $MacAddress.Replace(':', '') 79 | } 80 | $eth0 = Get-VMNetworkAdapter -VMName $VMName 81 | $eth0 | Rename-VMNetworkAdapter -NewName $InterfaceName 82 | if ($VlanId) { 83 | $eth0 | Set-VMNetworkAdapterVlan -Access -VlanId $VlanId 84 | } 85 | if ($SecondarySwitchName) { 86 | # Add secondary network adapter 87 | $eth1 = Add-VMNetworkAdapter -VMName $VMName -Name $SecondaryInterfaceName -SwitchName $SecondarySwitchName -PassThru 88 | 89 | if ($SecondaryMacAddress) { 90 | $SecondaryMacAddress = Normalize-MacAddress $SecondaryMacAddress 91 | $eth1 | Set-VMNetworkAdapter -StaticMacAddress $SecondaryMacAddress.Replace(':', '') 92 | if ($SecondaryVlanId) { 93 | $eth1 | Set-VMNetworkAdapterVlan -Access -VlanId $SecondaryVlanId 94 | } 95 | 96 | } 97 | } 98 | 99 | # Disable Automatic Checkpoints. Check if command is available since it doesn't exist in Server 2016. 100 | $command = Get-Command Set-VM 101 | if ($command.Parameters.AutomaticCheckpointsEnabled) { 102 | $vm | Set-VM -AutomaticCheckpointsEnabled $false 103 | } 104 | 105 | # Wait for VM 106 | $vm | Start-VM 107 | Write-Verbose 'Waiting for VM integration services (1)...' 108 | Wait-VM -Name $VMName -For Heartbeat 109 | 110 | Write-Verbose 'All done!' 111 | Write-Verbose 'After finished, please remember to remove the installation media with:' 112 | Write-Verbose " Get-VMDvdDrive -VMName '$VMName' | Remove-VMDvdDrive" 113 | 114 | # Return the VM created. 115 | $vm 116 | -------------------------------------------------------------------------------- /New-WindowsUnattendFile.ps1: -------------------------------------------------------------------------------- 1 | [CmdletBinding()] 2 | param( 3 | [Parameter(Mandatory=$true)] 4 | [string]$AdministratorPassword, 5 | 6 | [Parameter(Mandatory=$true)] 7 | [ValidateSet('Server2025Datacenter', 8 | 'Server2025Standard', 9 | 'Server2022Datacenter', 10 | 'Server2022Standard', 11 | 'Server2019Datacenter', 12 | 'Server2019Standard', 13 | 'Server2016Datacenter', 14 | 'Server2016Standard', 15 | 'Windows11Enterprise', 16 | 'Windows11Professional', 17 | 'Windows10Enterprise', 18 | 'Windows10Professional', 19 | 'Windows81Professional')] 20 | [string]$Version, 21 | 22 | [ValidateLength(0, 15)] 23 | [string]$ComputerName, 24 | 25 | [string]$FilePath, 26 | 27 | [string]$Locale, 28 | 29 | [switch]$AddVirtioDrivers 30 | ) 31 | 32 | $ErrorActionPreference = 'Stop' 33 | 34 | $runCommands = @' 35 | 36 | 10 37 | net user administrator /active:yes 38 | 39 | '@ 40 | 41 | if ($AddVirtioDrivers) { 42 | $runCommands += @' 43 | 44 | 20 45 | msiexec.exe /i C:\Windows\drivers\qemu-ga-x86_64.msi /qn /l*v C:\Windows\drivers\qemu-ga-x86_64.log /norestart 46 | 47 | '@ 48 | } 49 | 50 | $template = @" 51 | 52 | 53 | 54 | 55 | 56 | 57 | 58 | 59 | en-US 60 | en-US 61 | en-US 62 | 63 | 64 | true 65 | 66 | 67 | 0 68 | 69 | 70 | 71 | $runCommands 72 | 73 | 74 | 75 | 76 | 77 | 78 | true 79 | true 80 | true 81 | true 82 | true 83 | 3 84 | true 85 | true 86 | 87 | 88 | 89 | 90 | false</PlainText> 91 | </AdministratorPassword> 92 | </UserAccounts> 93 | </component> 94 | </settings> 95 | </unattend> 96 | "@ 97 | 98 | $xml = [xml]$template 99 | 100 | if (-not $FilePath) { 101 | $FilePath = Join-Path $env:TEMP 'unattend.xml' 102 | } 103 | 104 | $xml.unattend.settings[0].component[0].ComputerName = if ($ComputerName) { $ComputerName } else { '*' } 105 | 106 | if ($Locale) { 107 | $xml.unattend.settings[0].component[1].InputLocale = $Locale 108 | $xml.unattend.settings[0].component[1].SystemLocale = $Locale 109 | $xml.unattend.settings[0].component[1].UserLocale = $Locale 110 | } 111 | 112 | # Source: https://docs.microsoft.com/en-us/windows-server/get-started/kmsclientkeys 113 | $key = switch ($Version){ 114 | 'Server2025Datacenter' {'D764K-2NDRG-47T6Q-P8T8W-YP6DF'} 115 | 'Server2025Standard' {'TVRH6-WHNXV-R9WG3-9XRFY-MY832'} 116 | 'Server2022Datacenter' {'WX4NM-KYWYW-QJJR4-XV3QB-6VM33'} 117 | 'Server2022Standard' {'VDYBN-27WPP-V4HQT-9VMD4-VMK7H'} 118 | 'Server2019Datacenter' {'WMDGN-G9PQG-XVVXX-R3X43-63DFG'} 119 | 'Server2019Standard' {'N69G4-B89J2-4G8F4-WWYCC-J464C'} 120 | 'Server2016Datacenter' {'CB7KF-BWN84-R7R2Y-793K2-8XDDG'} 121 | 'Server2016Standard' {'WC2BQ-8NRM3-FDDYY-2BFGV-KHKQY'} 122 | 'Windows11Enterprise' {'NPPR9-FWDCX-D2C8J-H872K-2YT43'} 123 | 'Windows11Professional' {'W269N-WFGWX-YVC9B-4J6C9-T83GX'} 124 | 'Windows10Enterprise' {'NPPR9-FWDCX-D2C8J-H872K-2YT43'} 125 | 'Windows10Professional' {'W269N-WFGWX-YVC9B-4J6C9-T83GX'} 126 | 'Windows81Professional' {'GCRJD-8NW9H-F2CDX-CCM8D-9D6T9'} 127 | } 128 | $xml.unattend.settings[0].component[0].ProductKey = $key 129 | 130 | $encodedPassword = [System.Text.Encoding]::Unicode.GetBytes($AdministratorPassword + 'AdministratorPassword') 131 | $xml.unattend.settings[1].component.UserAccounts.AdministratorPassword.Value = [Convert]::ToBase64String($encodedPassword) 132 | 133 | $writer = New-Object System.XMl.XmlTextWriter($FilePath, [System.Text.Encoding]::UTF8) 134 | $writer.Formatting = [System.Xml.Formatting]::Indented 135 | $xml.Save($writer) 136 | $writer.Dispose() 137 | 138 | $FilePath -------------------------------------------------------------------------------- /New-VMFromDebianImage.ps1: -------------------------------------------------------------------------------- 1 | #Requires -RunAsAdministrator 2 | 3 | [CmdletBinding()] 4 | param( 5 | [Parameter(Mandatory=$true)] 6 | [string]$SourcePath, 7 | 8 | [ValidateScript({ 9 | $existingVm = Get-VM -Name $_ -ErrorAction SilentlyContinue 10 | if (-not $existingVm) { 11 | return $True 12 | } 13 | throw "There is already a VM named '$VMName' in this server." 14 | 15 | })] 16 | [Parameter(Mandatory=$true)] 17 | [string]$VMName, 18 | 19 | [string]$FQDN = $VMName, 20 | 21 | [Parameter(Mandatory=$true, ParameterSetName='RootPassword')] 22 | [string]$RootPassword, 23 | 24 | [Parameter(Mandatory=$true, ParameterSetName='RootPublicKey')] 25 | [string]$RootPublicKey, 26 | 27 | [uint64]$VHDXSizeBytes, 28 | 29 | [int64]$MemoryStartupBytes = 1GB, 30 | 31 | [switch]$EnableDynamicMemory, 32 | 33 | [int64]$ProcessorCount = 2, 34 | 35 | [string]$SwitchName = 'SWITCH', 36 | 37 | [ValidateScript({ 38 | if ($_ -match '^([0-9A-Fa-f]{2}:){5}([0-9A-Fa-f]{2})$') { 39 | return $True 40 | } 41 | throw "-MacAddress must be in format 'xx:xx:xx:xx:xx:xx'." 42 | })] 43 | [string]$MacAddress, 44 | 45 | [ValidateScript({ 46 | $sIp, $suffix = $_.Split('/') 47 | if ($ip = $sIp -as [ipaddress]) { 48 | $maxSuffix = if ($ip.AddressFamily -eq 'InterNetworkV6') { 128 } else { 32 } 49 | if ($suffix -in 1..$maxSuffix) { 50 | return $True 51 | } 52 | throw "Invalid -IPAddress suffix ($suffix)." 53 | } 54 | throw "Invalid -IPAddress ($sIp)." 55 | })] 56 | [string]$IPAddress, 57 | 58 | [string]$Gateway, 59 | 60 | [string[]]$DnsAddresses = @('1.1.1.1','1.0.0.1'), 61 | 62 | [string]$InterfaceName = 'eth0', 63 | 64 | [string]$VlanId, 65 | 66 | [Parameter(Mandatory=$false, ParameterSetName='RootPassword')] 67 | [Parameter(Mandatory=$false, ParameterSetName='RootPublicKey')] 68 | [string]$SecondarySwitchName, 69 | 70 | [ValidateScript({ 71 | if ($_ -match '^([0-9A-Fa-f]{2}:){5}([0-9A-Fa-f]{2})$') { 72 | return $True 73 | } 74 | throw "-SecondaryMacAddress must be in format 'xx:xx:xx:xx:xx:xx'." 75 | })] 76 | [string]$SecondaryMacAddress, 77 | 78 | [ValidateScript({ 79 | $sIp, $suffix = $_.Split('/') 80 | if ($ip = $sIp -as [ipaddress]) { 81 | $maxSuffix = if ($ip.AddressFamily -eq 'InterNetworkV6') { 128 } else { 32 } 82 | if ($suffix -in 1..$maxSuffix) { 83 | return $True 84 | } 85 | throw "Invalid -SecondaryIPAddress suffix ($suffix)." 86 | } 87 | throw "Invalid -SecondaryIPAddress ($sIp)." 88 | })] 89 | [string]$SecondaryIPAddress, 90 | 91 | [string]$SecondaryInterfaceName, 92 | 93 | [string]$SecondaryVlanId, 94 | 95 | [switch]$InstallDocker 96 | ) 97 | 98 | $ErrorActionPreference = 'Stop' 99 | 100 | function Normalize-MacAddress ([string]$value) { 101 | $value.` 102 | Replace('-', '').` 103 | Replace(':', '').` 104 | Insert(2,':').Insert(5,':').Insert(8,':').Insert(11,':').Insert(14,':').` 105 | ToLowerInvariant() 106 | } 107 | 108 | # Get default VHD path (requires administrative privileges) 109 | $vmmsSettings = Get-WmiObject -namespace root\virtualization\v2 Msvm_VirtualSystemManagementServiceSettingData 110 | $vhdxPath = Join-Path $vmmsSettings.DefaultVirtualHardDiskPath "$VMName.vhdx" 111 | 112 | # Convert cloud image to VHDX 113 | Write-Verbose 'Creating VHDX from cloud image...' 114 | & qemu-img.exe convert -f qcow2 $SourcePath -O vhdx -o subformat=dynamic $vhdxPath 2>&1 115 | if ($LASTEXITCODE -ne 0) { 116 | throw "qemu-img returned $LASTEXITCODE. Aborting." 117 | } 118 | 119 | # Latest versions of qemu-img create VHDX files with the sparse flag set (even with -S 0). 120 | # This causes issues with Hyper-V, so we need to clear it. 121 | & fsutil.exe sparse setflag $vhdxPath 0 2>&1 122 | if ($LASTEXITCODE -ne 0) { 123 | throw "fsutil returned $LASTEXITCODE. Aborting." 124 | } 125 | 126 | if ($VHDXSizeBytes) { 127 | Resize-VHD -Path $vhdxPath -SizeBytes $VHDXSizeBytes 128 | } 129 | 130 | # Create VM 131 | Write-Verbose 'Creating VM...' 132 | $vm = New-VM -Name $VMName -Generation 2 -MemoryStartupBytes $MemoryStartupBytes -VHDPath $vhdxPath -SwitchName $SwitchName 133 | $vm | Set-VMProcessor -Count $ProcessorCount 134 | $vm | Get-VMIntegrationService -Name "Guest Service Interface" | Enable-VMIntegrationService 135 | $vm | Set-VMMemory -DynamicMemoryEnabled:$EnableDynamicMemory.IsPresent 136 | 137 | # Sets Secure Boot Template. 138 | # Set-VMFirmware -SecureBootTemplate 'MicrosoftUEFICertificateAuthority' doesn't work anymore (!?). 139 | $vm | Set-VMFirmware -SecureBootTemplateId ([guid]'272e7447-90a4-4563-a4b9-8e4ab00526ce') 140 | 141 | # Cloud-init startup hangs without a serial port -- https://bit.ly/2AhsihL 142 | $vm | Set-VMComPort -Number 2 -Path "\\.\pipe\dbg1" 143 | 144 | # Setup first network adapter 145 | if ($MacAddress) { 146 | $MacAddress = Normalize-MacAddress $MacAddress 147 | $vm | Set-VMNetworkAdapter -StaticMacAddress $MacAddress.Replace(':', '') 148 | } 149 | $eth0 = Get-VMNetworkAdapter -VMName $VMName 150 | $eth0 | Rename-VMNetworkAdapter -NewName $InterfaceName 151 | if ($VlanId) { 152 | $eth0 | Set-VMNetworkAdapterVlan -Access -VlanId $VlanId 153 | } 154 | if ($SecondarySwitchName) { 155 | # Add secondary network adapter 156 | $eth1 = Add-VMNetworkAdapter -VMName $VMName -Name $SecondaryInterfaceName -SwitchName $SecondarySwitchName -PassThru 157 | 158 | if ($SecondaryMacAddress) { 159 | $SecondaryMacAddress = Normalize-MacAddress $SecondaryMacAddress 160 | $eth1 | Set-VMNetworkAdapter -StaticMacAddress $SecondaryMacAddress.Replace(':', '') 161 | if ($SecondaryVlanId) { 162 | $eth1 | Set-VMNetworkAdapterVlan -Access -VlanId $SecondaryVlanId 163 | } 164 | } 165 | } 166 | 167 | # Start VM just to create MAC Addresses 168 | $vm | Start-VM 169 | Start-Sleep -Seconds 1 170 | $vm | Stop-VM -Force 171 | 172 | # Wait for Mac Addresses 173 | Write-Verbose "Waiting for MAC addresses..." 174 | do { 175 | $eth0 = Get-VMNetworkAdapter -VMName $VMName -Name $InterfaceName 176 | $MacAddress = Normalize-MacAddress $eth0.MacAddress 177 | Start-Sleep -Seconds 1 178 | } while ($MacAddress -eq '00:00:00:00:00:00') 179 | 180 | if ($SecondarySwitchName) { 181 | do { 182 | $eth1 = Get-VMNetworkAdapter -VMName $VMName -Name $SecondaryInterfaceName 183 | $SecondaryMacAddress = Normalize-MacAddress $eth1.MacAddress 184 | Start-Sleep -Seconds 1 185 | } while ($SecondaryMacAddress -eq '00:00:00:00:00:00') 186 | } 187 | 188 | # Create metadata ISO image 189 | # Creates a NoCloud data source for cloud-init. 190 | # More info: https://cloudinit.readthedocs.io/en/latest/reference/datasources/nocloud.html 191 | $instanceId = [Guid]::NewGuid().ToString() 192 | 193 | $metadata = @" 194 | instance-id: $instanceId 195 | local-hostname: $VMName 196 | "@ 197 | 198 | $displayInterface = " $($InterfaceName): \4{$InterfaceName} \6{$InterfaceName}" 199 | $displaySecondaryInterface = '' 200 | if ($SecondarySwitchName) { 201 | $displaySecondaryInterface = " $($SecondaryInterfaceName): \4{$SecondaryInterfaceName} \6{$SecondaryInterfaceName}`n" 202 | } 203 | 204 | $sectionWriteFiles = @" 205 | write_files: 206 | - content: | 207 | \S{PRETTY_NAME} \n \l 208 | 209 | $displayInterface 210 | $displaySecondaryInterface 211 | path: /etc/issue 212 | owner: root:root 213 | permissions: '0644' 214 | 215 | "@ 216 | 217 | $sectionRunCmd = @' 218 | runcmd: 219 | - 'apt-get update' 220 | - 'grep -o "^[^#]*" /etc/netplan/50-cloud-init.yaml > /etc/netplan/80-static.yaml' # https://unix.stackexchange.com/a/157607 221 | - 'rm /etc/netplan/50-cloud-init.yaml' 222 | - 'touch /etc/cloud/cloud-init.disabled' 223 | - 'update-grub' # fix "error: no such device: root." -- https://bit.ly/2TBEdjl 224 | '@ 225 | 226 | if ($RootPassword) { 227 | $sectionPasswd = @" 228 | password: $RootPassword 229 | chpasswd: { expire: False } 230 | ssh_pwauth: True 231 | "@ 232 | } elseif ($RootPublicKey) { 233 | $sectionPasswd = @" 234 | ssh_authorized_keys: 235 | - $RootPublicKey 236 | "@ 237 | } 238 | 239 | if ($InstallDocker) { 240 | $sectionRunCmd += @' 241 | 242 | - 'apt update -y' 243 | - 'apt install -y ca-certificates curl gnupg lsb-release' 244 | - 'mkdir -p /etc/apt/keyrings' 245 | - 'curl -fsSL https://download.docker.com/linux/debian/gpg | sudo gpg --dearmor -o /etc/apt/keyrings/docker.gpg' 246 | - 'echo "deb [arch=$(dpkg --print-architecture) signed-by=/etc/apt/keyrings/docker.gpg] https://download.docker.com/linux/debian $(lsb_release -cs) stable" | sudo tee /etc/apt/sources.list.d/docker.list > /dev/null' 247 | - 'apt update -y' 248 | - 'apt install -y docker-ce docker-ce-cli containerd.io docker-compose' 249 | '@ 250 | } 251 | 252 | $userdata = @" 253 | #cloud-config 254 | hostname: $FQDN 255 | fqdn: $FQDN 256 | 257 | disable_root: false 258 | $sectionPasswd 259 | $sectionWriteFiles 260 | $sectionRunCmd 261 | 262 | power_state: 263 | mode: reboot 264 | timeout: 300 265 | "@ 266 | 267 | # Uses netplan to setup network. 268 | if ($IPAddress) { 269 | $NetworkConfig = @" 270 | version: 2 271 | ethernets: 272 | $($InterfaceName): 273 | match: 274 | macaddress: $MacAddress 275 | set-name: $($InterfaceName) 276 | addresses: [$IPAddress] 277 | nameservers: 278 | addresses: [$($DnsAddresses -join ', ')] 279 | routes: 280 | - to: 0.0.0.0/0 281 | via: $Gateway 282 | on-link: true 283 | 284 | "@ 285 | } else { 286 | $NetworkConfig = @" 287 | version: 2 288 | ethernets: 289 | $($InterfaceName): 290 | match: 291 | macaddress: $MacAddress 292 | set-name: $($InterfaceName) 293 | dhcp4: true 294 | dhcp-identifier: mac 295 | 296 | "@ 297 | } 298 | 299 | if ($SecondarySwitchName) { 300 | if ($SecondaryIPAddress) { 301 | $NetworkConfig += @" 302 | $($SecondaryInterfaceName): 303 | match: 304 | macaddress: $SecondaryMacAddress 305 | set-name: $($SecondaryInterfaceName) 306 | addresses: [$SecondaryIPAddress] 307 | 308 | "@ 309 | } else { 310 | $NetworkConfig += @" 311 | $($SecondaryInterfaceName): 312 | match: 313 | macaddress: $SecondaryMacAddress 314 | set-name: $($SecondaryInterfaceName) 315 | dhcp4: true 316 | dhcp-identifier: mac 317 | 318 | "@ 319 | } 320 | } 321 | 322 | # Adds DVD with metadata.iso 323 | . .\tools\Metadata-Functions.ps1 324 | $metadataIso = New-MetadataIso -VMName $VMName $metadata -UserData $userdata -NetworkConfig $NetworkConfig 325 | $dvd = $vm | Add-VMDvdDrive -Path $metadataIso -Passthru 326 | 327 | # Disable Automatic Checkpoints. Check if command is available since it doesn't exist in Server 2016. 328 | $command = Get-Command Set-VM 329 | if ($command.Parameters.AutomaticCheckpointsEnabled) { 330 | $vm | Set-VM -AutomaticCheckpointsEnabled $false 331 | } 332 | 333 | # Wait for VM 334 | $vm | Start-VM 335 | Write-Verbose 'Waiting for VM integration services (1)...' 336 | Wait-VM -Name $VMName -For Heartbeat 337 | 338 | # Cloud-init will reboot after initial machine setup. Wait for it... 339 | Write-Verbose 'Waiting for VM initial setup...' 340 | try { 341 | Wait-VM -Name $VMName -For Reboot 342 | } catch { 343 | # Win 2016 RTM doesn't have "Reboot" in WaitForVMTypes type. 344 | # Wait until heartbeat service stops responding. 345 | $heartbeatService = ($vm | Get-VMIntegrationService -Name 'Heartbeat') 346 | while ($heartbeatService.PrimaryStatusDescription -eq 'OK') { Start-Sleep 1 } 347 | } 348 | 349 | Write-Verbose 'Waiting for VM integration services (2)...' 350 | Wait-VM -Name $VMName -For Heartbeat 351 | 352 | # Removes DVD and metadata.iso 353 | $dvd | Remove-VMDvdDrive 354 | $metadataIso | Remove-Item -Force 355 | 356 | # Return the VM created. 357 | Write-Verbose 'All done!' 358 | $vm 359 | -------------------------------------------------------------------------------- /New-VMFromUbuntuImage.ps1: -------------------------------------------------------------------------------- 1 | #Requires -RunAsAdministrator 2 | 3 | [CmdletBinding()] 4 | param( 5 | [Parameter(Mandatory=$true)] 6 | [string]$SourcePath, 7 | 8 | [ValidateScript({ 9 | $existingVm = Get-VM -Name $_ -ErrorAction SilentlyContinue 10 | if (-not $existingVm) { 11 | return $True 12 | } 13 | throw "There is already a VM named '$VMName' in this server." 14 | 15 | })] 16 | [Parameter(Mandatory=$true)] 17 | [string]$VMName, 18 | 19 | [string]$FQDN = $VMName, 20 | 21 | [Parameter(Mandatory=$true, ParameterSetName='RootPassword')] 22 | [string]$RootPassword, 23 | 24 | [Parameter(Mandatory=$true, ParameterSetName='RootPublicKey')] 25 | [string]$RootPublicKey, 26 | 27 | [uint64]$VHDXSizeBytes, 28 | 29 | [int64]$MemoryStartupBytes = 1GB, 30 | 31 | [switch]$EnableDynamicMemory, 32 | 33 | [int64]$ProcessorCount = 2, 34 | 35 | [string]$SwitchName = 'SWITCH', 36 | 37 | [ValidateScript({ 38 | if ($_ -match '^([0-9A-Fa-f]{2}:){5}([0-9A-Fa-f]{2})$') { 39 | return $True 40 | } 41 | throw "-MacAddress must be in format 'xx:xx:xx:xx:xx:xx'." 42 | })] 43 | [string]$MacAddress, 44 | 45 | [ValidateScript({ 46 | $sIp, $suffix = $_.Split('/') 47 | if ($ip = $sIp -as [ipaddress]) { 48 | $maxSuffix = if ($ip.AddressFamily -eq 'InterNetworkV6') { 128 } else { 32 } 49 | if ($suffix -in 1..$maxSuffix) { 50 | return $True 51 | } 52 | throw "Invalid -IPAddress suffix ($suffix)." 53 | } 54 | throw "Invalid -IPAddress ($sIp)." 55 | })] 56 | [string]$IPAddress, 57 | 58 | [string]$Gateway, 59 | 60 | [string[]]$DnsAddresses = @('1.1.1.1','1.0.0.1'), 61 | 62 | [string]$InterfaceName = 'eth0', 63 | 64 | [string]$VlanId, 65 | 66 | [Parameter(Mandatory=$false, ParameterSetName='RootPassword')] 67 | [Parameter(Mandatory=$false, ParameterSetName='RootPublicKey')] 68 | [string]$SecondarySwitchName, 69 | 70 | [ValidateScript({ 71 | if ($_ -match '^([0-9A-Fa-f]{2}:){5}([0-9A-Fa-f]{2})$') { 72 | return $True 73 | } 74 | throw "-SecondaryMacAddress must be in format 'xx:xx:xx:xx:xx:xx'." 75 | })] 76 | [string]$SecondaryMacAddress, 77 | 78 | [ValidateScript({ 79 | $sIp, $suffix = $_.Split('/') 80 | if ($ip = $sIp -as [ipaddress]) { 81 | $maxSuffix = if ($ip.AddressFamily -eq 'InterNetworkV6') { 128 } else { 32 } 82 | if ($suffix -in 1..$maxSuffix) { 83 | return $True 84 | } 85 | throw "Invalid -SecondaryIPAddress suffix ($suffix)." 86 | } 87 | throw "Invalid -SecondaryIPAddress ($sIp)." 88 | })] 89 | [string]$SecondaryIPAddress, 90 | 91 | [string]$SecondaryInterfaceName, 92 | 93 | [string]$SecondaryVlanId, 94 | 95 | [switch]$InstallDocker 96 | ) 97 | 98 | $ErrorActionPreference = 'Stop' 99 | 100 | function Normalize-MacAddress ([string]$value) { 101 | $value.` 102 | Replace('-', '').` 103 | Replace(':', '').` 104 | Insert(2,':').Insert(5,':').Insert(8,':').Insert(11,':').Insert(14,':').` 105 | ToLowerInvariant() 106 | } 107 | 108 | # Get default VHD path (requires administrative privileges) 109 | $vmmsSettings = Get-WmiObject -namespace root\virtualization\v2 Msvm_VirtualSystemManagementServiceSettingData 110 | $vhdxPath = Join-Path $vmmsSettings.DefaultVirtualHardDiskPath "$VMName.vhdx" 111 | 112 | # Convert cloud image to VHDX 113 | Write-Verbose 'Creating VHDX from cloud image...' 114 | & qemu-img.exe convert -f qcow2 $SourcePath -O vhdx -o subformat=dynamic $vhdxPath 2>&1 115 | if ($LASTEXITCODE -ne 0) { 116 | throw "qemu-img returned $LASTEXITCODE. Aborting." 117 | } 118 | 119 | # Latest versions of qemu-img create VHDX files with the sparse flag set (even with -S 0). 120 | # This causes issues with Hyper-V, so we need to clear it. 121 | & fsutil.exe sparse setflag $vhdxPath 0 2>&1 122 | if ($LASTEXITCODE -ne 0) { 123 | throw "fsutil returned $LASTEXITCODE. Aborting." 124 | } 125 | 126 | if ($VHDXSizeBytes) { 127 | Resize-VHD -Path $vhdxPath -SizeBytes $VHDXSizeBytes 128 | } 129 | 130 | # Create VM 131 | Write-Verbose 'Creating VM...' 132 | $vm = New-VM -Name $VMName -Generation 2 -MemoryStartupBytes $MemoryStartupBytes -VHDPath $vhdxPath -SwitchName $SwitchName 133 | $vm | Set-VMProcessor -Count $ProcessorCount 134 | $vm | Get-VMIntegrationService -Name "Guest Service Interface" | Enable-VMIntegrationService 135 | $vm | Set-VMMemory -DynamicMemoryEnabled:$EnableDynamicMemory.IsPresent 136 | 137 | # Sets Secure Boot Template. 138 | # Set-VMFirmware -SecureBootTemplate 'MicrosoftUEFICertificateAuthority' doesn't work anymore (!?). 139 | $vm | Set-VMFirmware -SecureBootTemplateId ([guid]'272e7447-90a4-4563-a4b9-8e4ab00526ce') 140 | 141 | # Cloud-init startup hangs without a serial port -- https://bit.ly/2AhsihL 142 | $vm | Set-VMComPort -Number 2 -Path "\\.\pipe\dbg1" 143 | 144 | # Setup first network adapter 145 | if ($MacAddress) { 146 | $MacAddress = Normalize-MacAddress $MacAddress 147 | $vm | Set-VMNetworkAdapter -StaticMacAddress $MacAddress.Replace(':', '') 148 | } 149 | $eth0 = Get-VMNetworkAdapter -VMName $VMName 150 | $eth0 | Rename-VMNetworkAdapter -NewName $InterfaceName 151 | if ($VlanId) { 152 | $eth0 | Set-VMNetworkAdapterVlan -Access -VlanId $VlanId 153 | } 154 | if ($SecondarySwitchName) { 155 | # Add secondary network adapter 156 | $eth1 = Add-VMNetworkAdapter -VMName $VMName -Name $SecondaryInterfaceName -SwitchName $SecondarySwitchName -PassThru 157 | 158 | if ($SecondaryMacAddress) { 159 | $SecondaryMacAddress = Normalize-MacAddress $SecondaryMacAddress 160 | $eth1 | Set-VMNetworkAdapter -StaticMacAddress $SecondaryMacAddress.Replace(':', '') 161 | if ($SecondaryVlanId) { 162 | $eth1 | Set-VMNetworkAdapterVlan -Access -VlanId $SecondaryVlanId 163 | } 164 | } 165 | } 166 | 167 | # Start VM just to create MAC Addresses 168 | $vm | Start-VM 169 | Start-Sleep -Seconds 1 170 | $vm | Stop-VM -Force 171 | 172 | # Wait for Mac Addresses 173 | Write-Verbose "Waiting for MAC addresses..." 174 | do { 175 | $eth0 = Get-VMNetworkAdapter -VMName $VMName -Name $InterfaceName 176 | $MacAddress = Normalize-MacAddress $eth0.MacAddress 177 | Start-Sleep -Seconds 1 178 | } while ($MacAddress -eq '00:00:00:00:00:00') 179 | 180 | if ($SecondarySwitchName) { 181 | do { 182 | $eth1 = Get-VMNetworkAdapter -VMName $VMName -Name $SecondaryInterfaceName 183 | $SecondaryMacAddress = Normalize-MacAddress $eth1.MacAddress 184 | Start-Sleep -Seconds 1 185 | } while ($SecondaryMacAddress -eq '00:00:00:00:00:00') 186 | } 187 | 188 | # Create metadata ISO image 189 | # Creates a NoCloud data source for cloud-init. 190 | # More info: https://cloudinit.readthedocs.io/en/latest/reference/datasources/nocloud.html 191 | $instanceId = [Guid]::NewGuid().ToString() 192 | 193 | $metadata = @" 194 | instance-id: $instanceId 195 | local-hostname: $VMName 196 | "@ 197 | 198 | $displayInterface = " $($InterfaceName): \4{$InterfaceName} \6{$InterfaceName}" 199 | $displaySecondaryInterface = '' 200 | if ($SecondarySwitchName) { 201 | $displaySecondaryInterface = " $($SecondaryInterfaceName): \4{$SecondaryInterfaceName} \6{$SecondaryInterfaceName}`n" 202 | } 203 | 204 | $sectionWriteFiles = @" 205 | write_files: 206 | - content: | 207 | \S{PRETTY_NAME} \n \l 208 | 209 | $displayInterface 210 | $displaySecondaryInterface 211 | path: /etc/issue 212 | owner: root:root 213 | permissions: '0644' 214 | 215 | "@ 216 | 217 | $sectionRunCmd = @' 218 | runcmd: 219 | - 'apt-get update' 220 | - 'grep -o "^[^#]*" /etc/netplan/50-cloud-init.yaml > /etc/netplan/80-static.yaml' # https://unix.stackexchange.com/a/157607 221 | - 'rm /etc/netplan/50-cloud-init.yaml' 222 | - 'touch /etc/cloud/cloud-init.disabled' 223 | - 'update-grub' # fix "error: no such device: root." -- https://bit.ly/2TBEdjl 224 | '@ 225 | 226 | if ($RootPassword) { 227 | $sectionPasswd = @" 228 | password: $RootPassword 229 | chpasswd: { expire: False } 230 | ssh_pwauth: True 231 | "@ 232 | } elseif ($RootPublicKey) { 233 | $sectionPasswd = @" 234 | ssh_authorized_keys: 235 | - $RootPublicKey 236 | "@ 237 | } 238 | 239 | if ($InstallDocker) { 240 | $sectionRunCmd += @' 241 | 242 | - 'apt update -y' 243 | - 'apt install -y ca-certificates curl gnupg lsb-release' 244 | - 'mkdir -p /etc/apt/keyrings' 245 | - 'curl -fsSL https://download.docker.com/linux/ubuntu/gpg | sudo gpg --dearmor -o /etc/apt/keyrings/docker.gpg' 246 | - 'echo "deb [arch=$(dpkg --print-architecture) signed-by=/etc/apt/keyrings/docker.gpg] https://download.docker.com/linux/ubuntu $(lsb_release -cs) stable" | sudo tee /etc/apt/sources.list.d/docker.list > /dev/null' 247 | - 'apt update -y' 248 | - 'apt install -y docker-ce docker-ce-cli containerd.io docker-compose' 249 | '@ 250 | } 251 | 252 | $userdata = @" 253 | #cloud-config 254 | hostname: $FQDN 255 | fqdn: $FQDN 256 | 257 | disable_root: false 258 | $sectionPasswd 259 | $sectionWriteFiles 260 | $sectionRunCmd 261 | 262 | power_state: 263 | mode: reboot 264 | timeout: 300 265 | "@ 266 | 267 | # Uses netplan to setup network. 268 | if ($IPAddress) { 269 | $NetworkConfig = @" 270 | version: 2 271 | ethernets: 272 | $($InterfaceName): 273 | match: 274 | macaddress: $MacAddress 275 | set-name: $($InterfaceName) 276 | addresses: [$IPAddress] 277 | nameservers: 278 | addresses: [$($DnsAddresses -join ', ')] 279 | routes: 280 | - to: 0.0.0.0/0 281 | via: $Gateway 282 | on-link: true 283 | 284 | "@ 285 | } else { 286 | $NetworkConfig = @" 287 | version: 2 288 | ethernets: 289 | $($InterfaceName): 290 | match: 291 | macaddress: $MacAddress 292 | set-name: $($InterfaceName) 293 | dhcp4: true 294 | dhcp-identifier: mac 295 | 296 | "@ 297 | } 298 | 299 | if ($SecondarySwitchName) { 300 | if ($SecondaryIPAddress) { 301 | $NetworkConfig += @" 302 | $($SecondaryInterfaceName): 303 | match: 304 | macaddress: $SecondaryMacAddress 305 | set-name: $($SecondaryInterfaceName) 306 | addresses: [$SecondaryIPAddress] 307 | 308 | "@ 309 | } else { 310 | $NetworkConfig += @" 311 | $($SecondaryInterfaceName): 312 | match: 313 | macaddress: $SecondaryMacAddress 314 | set-name: $($SecondaryInterfaceName) 315 | dhcp4: true 316 | dhcp-identifier: mac 317 | 318 | "@ 319 | } 320 | } 321 | 322 | # Adds DVD with metadata.iso 323 | . .\tools\Metadata-Functions.ps1 324 | $metadataIso = New-MetadataIso -VMName $VMName $metadata -UserData $userdata -NetworkConfig $NetworkConfig 325 | $dvd = $vm | Add-VMDvdDrive -Path $metadataIso -Passthru 326 | 327 | # Disable Automatic Checkpoints. Check if command is available since it doesn't exist in Server 2016. 328 | $command = Get-Command Set-VM 329 | if ($command.Parameters.AutomaticCheckpointsEnabled) { 330 | $vm | Set-VM -AutomaticCheckpointsEnabled $false 331 | } 332 | 333 | # Wait for VM 334 | $vm | Start-VM 335 | Write-Verbose 'Waiting for VM integration services (1)...' 336 | Wait-VM -Name $VMName -For Heartbeat 337 | 338 | # Cloud-init will reboot after initial machine setup. Wait for it... 339 | Write-Verbose 'Waiting for VM initial setup...' 340 | try { 341 | Wait-VM -Name $VMName -For Reboot 342 | } catch { 343 | # Win 2016 RTM doesn't have "Reboot" in WaitForVMTypes type. 344 | # Wait until heartbeat service stops responding. 345 | $heartbeatService = ($vm | Get-VMIntegrationService -Name 'Heartbeat') 346 | while ($heartbeatService.PrimaryStatusDescription -eq 'OK') { Start-Sleep 1 } 347 | } 348 | 349 | Write-Verbose 'Waiting for VM integration services (2)...' 350 | Wait-VM -Name $VMName -For Heartbeat 351 | 352 | # Removes DVD and metadata.iso 353 | $dvd | Remove-VMDvdDrive 354 | $metadataIso | Remove-Item -Force 355 | 356 | # Return the VM created. 357 | Write-Verbose 'All done!' 358 | $vm 359 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Hyper-V automation scripts 2 | 3 | Collection of Powershell scripts to create Windows, Ubuntu and Debian VMs in Hyper-V. 4 | 5 | For Windows Server 2016+, Windows 10+ only. 6 | 7 | For Hyper-V Generation 2 (UEFI) VMs only. 8 | 9 | To migrate an existing Windows VM from Hyper-V to Proxmox (QEMU) see [Prepare a VHDX for QEMU migration](#prepare-a-vhdx-for-qemu-migration). 10 | 11 | 12 | 13 | ## How to install 14 | 15 | To download all scripts into your `$env:TEMP` folder: 16 | 17 | ```powershell 18 | iex (iwr 'bit.ly/h-v-a' -UseBasicParsing) 19 | ``` 20 | 21 | 22 | # Examples 23 | 24 | ## Create a new VM for Hyper-V 25 | 26 | ```powershell 27 | $isoFile = '.\en_windows_server_2019_x64_dvd_4cb967d8.iso' 28 | $vmName = 'TstWindows' 29 | $pass = 'u531@rg3pa55w0rd$!' 30 | 31 | .\New-VMFromWindowsImage.ps1 ` 32 | -SourcePath $isoFile ` 33 | -Edition 'Windows Server 2019 Standard' ` 34 | -VMName $vmName ` 35 | -VHDXSizeBytes 60GB ` 36 | -AdministratorPassword $pass ` 37 | -Version 'Server2019Standard' ` 38 | -MemoryStartupBytes 2GB ` 39 | -VMProcessorCount 2 40 | 41 | $sess = .\New-VMSession.ps1 -VMName $vmName -AdministratorPassword $pass 42 | 43 | .\Set-NetIPAddressViaSession.ps1 ` 44 | -Session $sess ` 45 | -IPAddress 10.10.1.195 ` 46 | -PrefixLength 16 ` 47 | -DefaultGateway 10.10.1.250 ` 48 | -DnsAddresses '8.8.8.8','8.8.4.4' ` 49 | -NetworkCategory 'Public' 50 | 51 | .\Enable-RemoteManagementViaSession.ps1 -Session $sess 52 | 53 | # You can run any commands on VM with Invoke-Command: 54 | Invoke-Command -Session $sess { 55 | echo "Hello, world! (from $env:COMPUTERNAME)" 56 | 57 | # Install chocolatey 58 | Set-ExecutionPolicy Bypass -Scope Process -Force 59 | [System.Net.ServicePointManager]::SecurityProtocol = [System.Net.ServicePointManager]::SecurityProtocol -bor 3072 60 | iex ((New-Object System.Net.WebClient).DownloadString('https://community.chocolatey.org/install.ps1')) 61 | 62 | # Install 7-zip 63 | choco install 7zip -y 64 | } 65 | 66 | Remove-PSSession -Session $sess 67 | ``` 68 | 69 | 70 | 71 | ## Prepare a VHDX for QEMU migration 72 | 73 | ```powershell 74 | $vmName = 'TstWindows' 75 | 76 | # Shutdown VM 77 | Stop-VM $vmName 78 | 79 | # Get VirtIO ISO 80 | $virtioIso = .\Get-VirtioImage.ps1 -OutputPath $env:TEMP 81 | 82 | # Install VirtIO drivers to Windows VM (offline) 83 | $vhdxFile = "C:\Hyper-V\Virtual Hard Disks\$vmName.vhdx" 84 | .\Add-VirtioDrivers.ps1 -VirtioIsoPath $virtioIso -ImagePath $vhdxFile 85 | 86 | # Copy VHDX file to QEMU host 87 | scp $vhdxFile "root@pve-host:/tmp/" 88 | ``` 89 | 90 | After the copy is complete, you may use [`import-vm-windows`](https://github.com/fdcastel/Proxmox-Automation#import-vm-windows) on Proxmox to import the `vhdx` file and create the Windows VM. 91 | 92 | Once the VM is running, ensure that the [QEMU Guest Agent](https://pve.proxmox.com/wiki/Qemu-guest-agent) is installed within the guest environment. 93 | 94 | 95 | 96 | # Command summary 97 | - For Windows VMs 98 | - [New-VMFromWindowsImage](#new-vmfromwindowsimage-) (*) 99 | - [New-VHDXFromWindowsImage](#new-vhdxfromwindowsimage-) (*) 100 | - [New-VMSession](#new-vmsession) 101 | - [Set-NetIPAddressViaSession](#set-netipaddressviasession) 102 | - [Set-NetIPv6AddressViaSession](#set-netipv6addressviasession) 103 | - [Get-VirtioImage](#get-virtioimage) 104 | - [Add-VirtioDrivers](#add-virtiodrivers) 105 | - [Enable-RemoteManagementViaSession](#enable-remotemanagementviasession) 106 | - For Ubuntu VMs 107 | - [Get-UbuntuImage](#get-ubuntuimage) 108 | - [New-VMFromUbuntuImage](#new-vmfromubuntuimage-) (*) 109 | - For Debian VMs 110 | - [Get-DebianImage](#get-debianimage) 111 | - [New-VMFromDebianImage](#new-vmfromdebianimage-) (*) 112 | - For images with no `cloud-init` support 113 | - [Get-OPNsenseImage](#get-opnsenseimage) 114 | - [New-VMFromIsoImage](#new-vmfromisoimage-) (*) 115 | - Other commands 116 | - [Download-VerifiedFile](#download-verifiedfile) 117 | - [Move-VMOffline](#move-vmoffline) 118 | 119 | **(*) Requires administrative privileges**. 120 | 121 | 122 | 123 | # For Windows VMs 124 | 125 | ## New-VMFromWindowsImage (*) 126 | 127 | ```powershell 128 | New-VMFromWindowsImage.ps1 [-SourcePath] <string> [-Edition] <string> [-VMName] <string> [-VHDXSizeBytes] <uint64> [-AdministratorPassword] <string> [-Version] <string> [-MemoryStartupBytes] <long> [[-VMProcessorCount] <long>] [[-VMSwitchName] <string>] [[-VMMacAddress] <string>] [[-Locale] <string>] [-EnableDynamicMemory] [<CommonParameters>] 129 | ``` 130 | 131 | Creates a Windows VM from an ISO image. 132 | 133 | For the `-Edition` parameter use `Get-WindowsImage -ImagePath <path-to-install.wim>` to see all available images. Or just use "1" for the first one. 134 | 135 | The `-Version` parameter is required to set the product key (required for a full unattended install). 136 | 137 | Returns the `VirtualMachine` created. 138 | 139 | **(*) Requires administrative privileges**. 140 | 141 | 142 | 143 | ## New-VHDXFromWindowsImage (*) 144 | 145 | ```powershell 146 | New-VHDXFromWindowsImage.ps1 [-SourcePath] <string> [-Edition] <string> [[-ComputerName] <string>] [[-VHDXPath] <string>] [[-VHDXSizeBytes] <uint64>] [[-AdministratorPassword] <string>] [-Version] <string> [[-Locale] <string>] [[-AddVirtioDrivers] <string>] [<CommonParameters>] 147 | ``` 148 | 149 | Creates a Windows VHDX from an ISO image. Similar to `New-VMFromWindowsImage` but without creating a VM. 150 | 151 | You can add [Windows VirtIO Drivers](https://pve.proxmox.com/wiki/Windows_VirtIO_Drivers) and the [QEMU Guest Agent](https://pve.proxmox.com/wiki/Qemu-guest-agent) with `-AddVirtioDrivers`. In this case you must provide the path of VirtIO ISO (see [`Get-VirtioImage`](#Get-VirtioImage)) to this parameter. This is useful if you wish to import the created VHDX in a KVM environment. 152 | 153 | Returns the path for the VHDX file created. 154 | 155 | **(*) Requires administrative privileges**. 156 | 157 | 158 | 159 | ## New-VMSession 160 | 161 | ```powershell 162 | New-VMSession.ps1 [-VMName] <string> [-AdministratorPassword] <string> [[-DomainName] <string>] [<CommonParameters>] 163 | ``` 164 | 165 | Creates a new `PSSession` into a VM. In case of error, keeps retrying until connected. Useful for wait until a VM is ready to accept commands. 166 | 167 | Returns the `PSSession` created. 168 | 169 | 170 | 171 | ## Set-NetIPAddressViaSession 172 | 173 | ```powershell 174 | Set-NetIPAddressViaSession.ps1 [-Session] <PSSession[]> [[-AdapterName] <string>] [-IPAddress] <string> [-PrefixLength] <byte> [-DefaultGateway] <string> [[-DnsAddresses] <string[]>] [[-NetworkCategory] <string>] [<CommonParameters>] 175 | ``` 176 | 177 | Sets IPv4 configuration for a Windows VM. 178 | 179 | 180 | 181 | ## Set-NetIPv6AddressViaSession 182 | 183 | ```powershell 184 | Set-NetIPv6AddressViaSession.ps1 [-Session] <PSSession[]> [[-AdapterName] <string>] [-IPAddress] <ipaddress> [-PrefixLength] <byte> [[-DnsAddresses] <string[]>] [<CommonParameters>] 185 | ``` 186 | 187 | Sets IPv6 configuration for a Windows VM. 188 | 189 | 190 | 191 | ## Get-VirtioImage 192 | 193 | ```powershell 194 | Get-VirtioImage.ps1 [[-OutputPath] <string>] [<CommonParameters>] 195 | ``` 196 | 197 | Downloads latest stable ISO image of [Windows VirtIO Drivers](https://pve.proxmox.com/wiki/Windows_VirtIO_Drivers). 198 | 199 | Use `-OutputPath` parameter to set download location. If not informed, the current folder will be used. 200 | 201 | Returns the path for downloaded file. 202 | 203 | 204 | 205 | ## Add-VirtioDrivers 206 | 207 | ```powershell 208 | Add-VirtioDrivers.ps1 [-VirtioIsoPath] <string> [-ImagePath] <string> [-Version] <string> [[-ImageIndex] <int>] [<CommonParameters>] 209 | ``` 210 | 211 | Adds [Windows VirtIO Drivers](https://pve.proxmox.com/wiki/Windows_VirtIO_Drivers) into a WIM or VHDX file. 212 | 213 | You must inform the path of VirtIO ISO with `-VirtioIsoPath`. You can download the latest image from [here](https://pve.proxmox.com/wiki/Windows_VirtIO_Drivers#Using_the_ISO). Or just use [`Get-VirtioImage.ps1`](#Get-VirtioImage). 214 | 215 | You must use `-ImagePath` to inform the path of file. 216 | 217 | You may use `-Version` to specify the Windows version of the image (recommended). This ensures that all appropriate drivers for the system are installed correctly. 218 | 219 | For WIM files you must also use `-ImageIndex` to inform the image index inside of WIM. For VHDX files the image index must be always `1` (the default). 220 | 221 | Please note that -- unlike the `-AddVirtioDrivers` option from `New-VHDXFromWindowsImage` -- this script cannot install the [QEMU Guest Agent](https://pve.proxmox.com/wiki/Qemu-guest-agent) in an existing `vhdx`, as its operations are limited to the offline image (cannot run the installer). 222 | 223 | 224 | 225 | ## Enable-RemoteManagementViaSession 226 | 227 | ```powershell 228 | Enable-RemoteManagementViaSession.ps1 [-Session] <PSSession[]> [<CommonParameters>] 229 | ``` 230 | 231 | Enables Powershell Remoting, CredSSP server authentication and sets WinRM firewall rule to `Any` remote address (default: `LocalSubnet`). 232 | 233 | 234 | 235 | # For Ubuntu VMs 236 | 237 | ## Get-UbuntuImage 238 | 239 | ```powershell 240 | Get-UbuntuImage.ps1 [[-OutputPath] <string>] [-Previous] [<CommonParameters>] 241 | ``` 242 | 243 | Downloads latest Ubuntu LTS cloud image and verify its integrity. 244 | 245 | Use `-OutputPath` parameter to set download location. If not informed, the current folder will be used. 246 | 247 | Use `-Previous` parameter to download the previous LTS image instead of the current LTS. 248 | 249 | Returns the path for downloaded file. 250 | 251 | 252 | 253 | ## New-VMFromUbuntuImage (*) 254 | 255 | ```powershell 256 | New-VMFromUbuntuImage.ps1 -SourcePath <string> -VMName <string> -RootPassword <string> [-FQDN <string>] [-VHDXSizeBytes <uint64>] [-MemoryStartupBytes <long>] [-EnableDynamicMemory] [-ProcessorCount <long>] [-SwitchName <string>] [-MacAddress <string>] [-IPAddress <string>] [-Gateway <string>] [-DnsAddresses <string[]>] [-InterfaceName <string>] [-VlanId <string>] [-SecondarySwitchName <string>] [-SecondaryMacAddress <string>] [-SecondaryIPAddress <string>] [-SecondaryInterfaceName <string>] [-SecondaryVlanId <string>] [-InstallDocker] [<CommonParameters>] 257 | 258 | New-VMFromUbuntuImage.ps1 -SourcePath <string> -VMName <string> -RootPublicKey <string> [-FQDN <string>] [-VHDXSizeBytes <uint64>] [-MemoryStartupBytes <long>] [-EnableDynamicMemory] [-ProcessorCount <long>] [-SwitchName <string>] [-MacAddress <string>] [-IPAddress <string>] [-Gateway <string>] [-DnsAddresses <string[]>] [-InterfaceName <string>] [-VlanId <string>] [-SecondarySwitchName <string>] [-SecondaryMacAddress <string>] [-SecondaryIPAddress <string>] [-SecondaryInterfaceName <string>] [-SecondaryVlanId <string>] [-InstallDocker] [<CommonParameters>] 259 | ``` 260 | 261 | Creates a Ubuntu VM from Ubuntu Cloud image. 262 | 263 | You must have [qemu-img](https://github.com/fdcastel/qemu-img-windows-x64) installed. If you have [chocolatey](https://chocolatey.org/) you can install it with: 264 | 265 | ``` 266 | choco install qemu-img -y 267 | ``` 268 | 269 | You can download Ubuntu cloud images from [here](https://cloud-images.ubuntu.com/releases/focal/release/) (get the `amd64.img` version). Or just use [`Get-UbuntuImage.ps1`](#Get-UbuntuImage). 270 | 271 | You must use `-RootPassword` to set a password or `-RootPublicKey` to set a public key for default `ubuntu` user. 272 | 273 | You may configure network using `-VlanId`, `-IPAddress`, `-Gateway` and `-DnsAddresses` options. `-IPAddress` must be in `address/prefix` format. If not specified the network will be configured via DHCP. 274 | 275 | You may rename interfaces with `-InterfaceName` and `-SecondaryInterfaceName`. This will set Hyper-V network adapter name and also set the interface name in Ubuntu. 276 | 277 | You may add a second network using `-SecondarySwitchName`. You may configure it with `-Secondary*` options. 278 | 279 | You may install Docker using `-InstallDocker` switch. 280 | 281 | Returns the `VirtualMachine` created. 282 | 283 | **(*) Requires administrative privileges**. 284 | 285 | 286 | 287 | ## Ubuntu: Example 288 | 289 | ```powershell 290 | # Create a VM with static IP configuration and ssh public key access 291 | $imgFile = .\Get-UbuntuImage.ps1 -Verbose 292 | $vmName = 'TstUbuntu' 293 | $fqdn = 'test.example.com' 294 | $rootPublicKey = Get-Content "$env:USERPROFILE\.ssh\id_rsa.pub" 295 | 296 | .\New-VMFromUbuntuImage.ps1 ` 297 | -SourcePath $imgFile ` 298 | -VMName $vmName ` 299 | -FQDN $fqdn ` 300 | -RootPublicKey $rootPublicKey ` 301 | -VHDXSizeBytes 60GB ` 302 | -MemoryStartupBytes 2GB ` 303 | -ProcessorCount 2 ` 304 | -IPAddress 10.10.1.196/16 ` 305 | -Gateway 10.10.1.250 ` 306 | -DnsAddresses '8.8.8.8','8.8.4.4' ` 307 | -Verbose 308 | 309 | # Your public key is installed. This should not ask you for a password. 310 | ssh ubuntu@10.10.1.196 311 | ``` 312 | 313 | 314 | 315 | # For Debian VMs 316 | 317 | ## Get-DebianImage 318 | 319 | ```powershell 320 | Get-DebianImage.ps1 [[-OutputPath] <string>] [-Previous] [<CommonParameters>] 321 | ``` 322 | 323 | Downloads latest Debian cloud image. 324 | 325 | Use `-OutputPath` parameter to set download location. If not informed, the current folder will be used. 326 | 327 | Use `-Previous` parameter to download the previous version instead of the current version. 328 | 329 | Returns the path for downloaded file. 330 | 331 | 332 | 333 | ## New-VMFromDebianImage (*) 334 | 335 | ```powershell 336 | New-VMFromDebianImage.ps1 -SourcePath <string> -VMName <string> -RootPassword <string> [-FQDN <string>] [-VHDXSizeBytes <uint64>] [-MemoryStartupBytes <long>] [-EnableDynamicMemory] [-ProcessorCount <long>] [-SwitchName <string>] [-MacAddress <string>] [-IPAddress <string>] [-Gateway <string>] [-DnsAddresses <string[]>] [-InterfaceName <string>] [-VlanId <string>] [-SecondarySwitchName <string>] [-SecondaryMacAddress <string>] [-SecondaryIPAddress <string>] [-SecondaryInterfaceName <string>] [-SecondaryVlanId <string>] [-InstallDocker] [<CommonParameters>] 337 | 338 | New-VMFromDebianImage.ps1 -SourcePath <string> -VMName <string> -RootPublicKey <string> [-FQDN <string>] [-VHDXSizeBytes <uint64>] [-MemoryStartupBytes <long>] [-EnableDynamicMemory] [-ProcessorCount <long>] [-SwitchName <string>] [-MacAddress <string>] [-IPAddress <string>] [-Gateway <string>] [-DnsAddresses <string[]>] [-InterfaceName <string>] [-VlanId <string>] [-SecondarySwitchName <string>] [-SecondaryMacAddress <string>] [-SecondaryIPAddress <string>] [-SecondaryInterfaceName <string>] [-SecondaryVlanId <string>] [-InstallDocker] [<CommonParameters>] 339 | ``` 340 | 341 | Creates a Debian VM from Debian Cloud image. 342 | 343 | You must have [qemu-img](https://github.com/fdcastel/qemu-img-windows-x64) installed. If you have [chocolatey](https://chocolatey.org/) you can install it with: 344 | 345 | ``` 346 | choco install qemu-img -y 347 | ``` 348 | 349 | You can download Debian cloud images from [here](https://cloud.debian.org/images/cloud/bullseye/daily) (get the `genericcloud-amd64 version`). Or just use [`Get-DebianImage.ps1`](#Get-DebianImage). 350 | 351 | You must use `-RootPassword` to set a password or `-RootPublicKey` to set a public key for default `debian` user. 352 | 353 | You may configure network using `-VlanId`, `-IPAddress`, `-Gateway` and `-DnsAddresses` options. `-IPAddress` must be in `address/prefix` format. If not specified the network will be configured via DHCP. 354 | 355 | You may rename interfaces with `-InterfaceName` and `-SecondaryInterfaceName`. This will set Hyper-V network adapter name and also set the interface name in Debian. 356 | 357 | You may add a second network using `-SecondarySwitchName`. You may configure it with `-Secondary*` options. 358 | 359 | You may install Docker using `-InstallDocker` switch. 360 | 361 | Returns the `VirtualMachine` created. 362 | 363 | **(*) Requires administrative privileges**. 364 | 365 | 366 | 367 | ## Debian: Example 368 | 369 | ```powershell 370 | # Create a VM with static IP configuration and ssh public key access 371 | $imgFile = .\Get-DebianImage.ps1 -Verbose 372 | $vmName = 'TstDebian' 373 | $fqdn = 'test.example.com' 374 | $rootPublicKey = Get-Content "$env:USERPROFILE\.ssh\id_rsa.pub" 375 | 376 | .\New-VMFromDebianImage.ps1 ` 377 | -SourcePath $imgFile ` 378 | -VMName $vmName ` 379 | -FQDN $fqdn ` 380 | -RootPublicKey $rootPublicKey ` 381 | -VHDXSizeBytes 60GB ` 382 | -MemoryStartupBytes 2GB ` 383 | -ProcessorCount 2 ` 384 | -IPAddress 10.10.1.197/16 ` 385 | -Gateway 10.10.1.250 ` 386 | -DnsAddresses '8.8.8.8','8.8.4.4' ` 387 | -Verbose 388 | 389 | # Your public key is installed. This should not ask you for a password. 390 | ssh debian@10.10.1.197 391 | ``` 392 | 393 | 394 | 395 | # For images with no `cloud-init` support 396 | 397 | ## Get-OPNsenseImage 398 | 399 | ```powershell 400 | Get-OPNsenseImage.ps1 [[-OutputPath] <string>] [<CommonParameters>] 401 | ``` 402 | 403 | Downloads latest OPNsense ISO image. 404 | 405 | Use `-OutputPath` parameter to set download location. If not informed, the current folder will be used. 406 | 407 | Returns the path for downloaded file. 408 | 409 | 410 | 411 | ## New-VMFromIsoImage (*) 412 | 413 | ```powershell 414 | New-VMFromIsoImage.ps1 [-IsoPath] <string> [-VMName] <string> [[-VHDXSizeBytes] <uint64>] [[-MemoryStartupBytes] <long>] [[-ProcessorCount] <long>] [[-SwitchName] <string>] [[-MacAddress] <string>] [[-InterfaceName] <string>] [[-VlanId] <string>] [[-SecondarySwitchName] <string>] [[-SecondaryMacAddress] <string>] [[-SecondaryInterfaceName] <string>] [[-SecondaryVlanId] <string>] [-EnableDynamicMemory] [-EnableSecureBoot] [<CommonParameters>] 415 | ``` 416 | 417 | Creates a VM and boot it from a ISO image. 418 | 419 | Returns the `VirtualMachine` created. 420 | 421 | After installation, remember to remove the ISO mounted drive with: 422 | 423 | ```powershell 424 | Get-VMDvdDrive -VMName 'vm-name' | Remove-VMDvdDrive 425 | ``` 426 | 427 | **(*) Requires administrative privileges**. 428 | 429 | 430 | 431 | ## OPNsense: Example 432 | 433 | The following example will create a OPNsense router and a Windows VM in a private network which will have internet access through OPNsense. 434 | 435 | It requires two Hyper-V Virtual Switches: 436 | 437 | - `SWITCH` (type: External), connected to a network with internet access and DHCP; and 438 | - `ISWITCH` (type: Internal), for the private netork. 439 | 440 | From OPNsense convention, the first network interface will be assigned as LAN. 441 | > **Note**: The default network address will be `192.168.1.1/24` with DHCP enabled. 442 | 443 | ```powershell 444 | $isoFile = .\Get-OPNsenseImage.ps1 -Verbose 445 | $vmName = 'TstOpnRouter' 446 | 447 | .\New-VMFromIsoImage.ps1 ` 448 | -IsoPath $isoFile ` 449 | -VMName $vmName ` 450 | -VHDXSizeBytes 60GB ` 451 | -MemoryStartupBytes 2GB ` 452 | -ProcessorCount 2 ` 453 | -SwitchName 'ISWITCH' ` 454 | -InterfaceName 'lan' ` 455 | -SecondarySwitchName 'SWITCH' ` 456 | -SecondaryInterfaceName 'wan' ` 457 | -Verbose 458 | 459 | # Windows Server 2022 image 460 | $isoFile = 'C:\Adm\SW_DVD9_Win_Server_STD_CORE_2022__64Bit_English_DC_STD_MLF_X22-74290.ISO' 461 | $vmName = 'TstOpnClient' 462 | $pass = 'u531@rg3pa55w0rd$!' 463 | 464 | .\New-VMFromWindowsImage.ps1 ` 465 | -SourcePath $isoFile ` 466 | -Edition 'Windows Server 2022 Standard (Desktop Experience)' ` 467 | -VMName $vmName ` 468 | -VHDXSizeBytes 60GB ` 469 | -AdministratorPassword $pass ` 470 | -Version 'Server2022Standard' ` 471 | -MemoryStartupBytes 4GB ` 472 | -VMProcessorCount 2 ` 473 | -VMSwitchName 'ISWITCH' 474 | ``` 475 | 476 | The Windows VM should get an internal IP address (from `192.168.1.x/24` range) via DHCP from OPNsense and it should have working internet access. 477 | 478 | Remember that OPNsense will be running in _live_ mode from ISO image. To install it logon via console with `installer` user and `opnsense` password. 479 | 480 | After the installation, remove the installation media with: 481 | 482 | ```powershell 483 | Get-VMDvdDrive -VMName 'TstOpnRouter' | Remove-VMDvdDrive 484 | ``` 485 | 486 | 487 | 488 | # Other commands 489 | 490 | ## Download-VerifiedFile 491 | 492 | ```powershell 493 | Download-VerifiedFile.ps1 [-Url] <string> [-ExpectedHash] <string> [[-TargetDirectory] <string>] [<CommonParameters>] 494 | ``` 495 | 496 | Downloads a file and validates its integrity through SHA256 hash verification. 497 | 498 | If the file is already present and the hashes match, the download is skipped. 499 | 500 | 501 | 502 | ## Move-VMOffline 503 | 504 | ```powershell 505 | Move-VMOffline.ps1 [-VMName] <string> [-DestinationHost] <string> [-CertificateThumbprint] <string> [<CommonParameters>] 506 | ``` 507 | 508 | Uses Hyper-V replica to move a VM between hosts not joined in a domain. 509 | --------------------------------------------------------------------------------