Created
October 20, 2025 02:52
-
-
Save DJStompZone/79774c0cbf21a878d1ea3da00e8dfb03 to your computer and use it in GitHub Desktop.
Build Hyper-V NAT Homelab
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| <# | |
| .SYNOPSIS | |
| Build a safe Hyper-V NAT lab + Ubuntu VM without unaliving your host network. | |
| .DESCRIPTION | |
| - Creates an internal host-only Hyper-V switch and a Windows NAT so VMs get outbound internet without touching your physical NIC. | |
| - Assigns the host a gateway IP on the internal vSwitch. | |
| - Creates or reuses a Generation 2 VM, VHDX, and attaches an Ubuntu Server ISO, or whatever ISO ya got. | |
| - Sets Secure Boot template correctly for Linux or disables it (your call via -EnableSecureBoot). | |
| - Wires the VM to the NAT switch and sets DVD as first boot device | |
| - Adds scoped firewall exceptions for SSH from the lab subnet (optional). | |
| - Includes a cleanup function to tear it all down safely if you change your mind. Which, let's be honest, you probably will. | |
| - Can enable the Hyper-V feature automatically and reboot if missing (via -EnableHyperV). | |
| .PARAMETER SwitchName | |
| Name of the internal vSwitch. Default: NATSwitch | |
| .PARAMETER Subnet | |
| IPv4 subnet in CIDR notation. Default: 192.168.200.0/24 | |
| .PARAMETER Gateway | |
| IPv4 address assigned to host’s vEthernet interface on the internal switch. Default: 192.168.200.1 | |
| .PARAMETER VMName | |
| Name of the VM. Default: frp-vm | |
| .PARAMETER VhdPath | |
| Path to the VM’s VHDX. Default: C:\HyperV\frp-vm.vhdx | |
| .PARAMETER VhdSizeGB | |
| Initial size of the VHDX in gigabytes. Default: 20. | |
| .PARAMETER ISOPath | |
| Path to Ubuntu Server ISO. Example: H:\isos\ubuntu-24.04.03-live-server-amd64.iso | |
| .PARAMETER MemoryGB | |
| Startup RAM in GB (Dynamic Memory stays on). Default: 2 | |
| .PARAMETER CpuCount | |
| vCPU count. Default: 2 | |
| .PARAMETER EnableSecureBoot | |
| If set, enables Secure Boot with Microsoft UEFI CA template (Ubuntu-friendly). If not set, disables Secure Boot. | |
| .PARAMETER OpenHostFirewall | |
| If set, creates Windows Firewall rules scoping SSH (22/tcp) to the lab subnet. | |
| .PARAMETER EnableHyperV | |
| If set, checks and enables the Hyper-V feature if missing, then reboots to finish setup. | |
| .EXAMPLE | |
| .\Build-HyperV-NATLab.ps1 -ISOPath "H:\isos\ubuntu-24.04.03-live-server-amd64.iso" -EnableHyperV -OpenHostFirewall | |
| .NOTES | |
| Run as Administrator. Requires Hyper-V feature enabled. Will not touch your physical NIC. Thats kinda like, the whole point. | |
| #> | |
| [CmdletBinding()] | |
| param( | |
| [string]$SwitchName = "NATSwitch", | |
| [string]$Subnet = "192.168.200.0/24", | |
| [string]$Gateway = "192.168.200.1", | |
| [string]$VMName = "frp-vm", | |
| [string]$VhdPath = "C:\HyperV\frp-vm.vhdx", | |
| [int]$VhdSizeGB = 20, | |
| [Parameter(Mandatory = $true)][string]$ISOPath, | |
| [int]$MemoryGB = 2, | |
| [int]$CpuCount = 2, | |
| [switch]$EnableSecureBoot, | |
| [switch]$OpenHostFirewall, | |
| [switch]$EnableHyperV | |
| ) | |
| Set-StrictMode -Version Latest | |
| $ErrorActionPreference = "Stop" | |
| function Assert-Admin { | |
| if (-not ([Security.Principal.WindowsPrincipal][Security.Principal.WindowsIdentity]::GetCurrent()).IsInRole([Security.Principal.WindowsBuiltinRole]::Administrator)) { | |
| throw "Please run this script as Administrator. We are doing network sorcery that Windows does not allow scrubs to conjure." | |
| } | |
| } | |
| function Ensure-HyperV { | |
| <# | |
| .SYNOPSIS | |
| Enables Hyper-V if it isn't already active. | |
| .DESCRIPTION | |
| Checks for Microsoft-Hyper-V-All, enables it if missing, and warns that a reboot is required. | |
| #> | |
| $hv = Get-WindowsOptionalFeature -Online -FeatureName Microsoft-Hyper-V-All | |
| if ($hv.State -ne "Enabled") { | |
| Write-Host "# Hyper-V is not enabled. We can fix that, but it requires a reboot." -ForegroundColor Yellow | |
| $confirm = Read-Host "Enable Hyper-V and reboot now? (Y/N)" | |
| if ($confirm -match '^[Yy]') { | |
| Write-Host "# Enabling Hyper-V features. Windows is about to graft a hypervisor into the kernel." -ForegroundColor Cyan | |
| Enable-WindowsOptionalFeature -Online -FeatureName Microsoft-Hyper-V -All -NoRestart | |
| Write-Host "# Rebooting in 10 seconds so Windows can finish gluing drivers to the kernel. Save your work." -ForegroundColor Yellow | |
| Start-Sleep -Seconds 10 | |
| Restart-Computer | |
| exit | |
| } else { | |
| throw "Hyper-V not enabled and user declined automatic enable. Idk what to tell ya, amigo." | |
| } | |
| } else { | |
| Write-Host "# Hyper-V already enabled. Cool beans." -ForegroundColor Green | |
| } | |
| } | |
| function Assert-Feature { | |
| $hv = Get-WindowsOptionalFeature -Online -FeatureName Microsoft-Hyper-V-All | |
| if ($hv.State -ne "Enabled") { | |
| throw "Hyper-V is not enabled. Re-run with -EnableHyperV or enable Hyper-V in Windows Features and reboot." | |
| } | |
| } | |
| function Get-VEthernetAlias { | |
| return "vEthernet ($SwitchName)" | |
| } | |
| function Ensure-InternalSwitch { | |
| $sw = Get-VMSwitch -Name $SwitchName -ErrorAction SilentlyContinue | |
| if ($null -eq $sw) { | |
| Write-Host "# Creating internal switch so we do not bork up your real NIC" -ForegroundColor Cyan | |
| New-VMSwitch -Name $SwitchName -SwitchType Internal | Out-Null | |
| } else { | |
| Write-Host "# Internal switch already exists; yay idempotency" -ForegroundColor DarkCyan | |
| } | |
| } | |
| function Ensure-GatewayIP { | |
| $ifAlias = Get-VEthernetAlias | |
| $if = Get-NetAdapter -InterfaceAlias $ifAlias -ErrorAction SilentlyContinue | |
| if ($null -eq $if) { throw "Expected host adapter '$ifAlias' not found. Hyper-V should have created it. Try disabling and re-enabling the switch or rebooting." } | |
| $existing = Get-NetIPAddress -InterfaceAlias $ifAlias -AddressFamily IPv4 -ErrorAction SilentlyContinue | Where-Object { $_.IPAddress -eq $Gateway } | |
| if ($null -eq $existing) { | |
| Write-Host "# Assigning the host a shiny new gateway IP on the internal switch. This will be the VM default route." -ForegroundColor Cyan | |
| New-NetIPAddress -IPAddress $Gateway -PrefixLength ($Subnet.Split('/')[1]) -InterfaceAlias $ifAlias | Out-Null | |
| } else { | |
| Write-Host "# Host gateway IP already assigned; moving on before we get clingy" -ForegroundColor DarkCyan | |
| } | |
| } | |
| function Ensure-NAT { | |
| $existingNat = Get-NetNat -ErrorAction SilentlyContinue | Where-Object { $_.InternalIPInterfaceAddressPrefix -eq $Subnet } | |
| if ($null -eq $existingNat) { | |
| Write-Host "# Setting up Windows NAT like a polite little router that does not ask questions" -ForegroundColor Cyan | |
| New-NetNat -Name "NATNetwork" -InternalIPInterfaceAddressPrefix $Subnet | Out-Null | |
| } else { | |
| Write-Host "# NAT already present for $Subnet; not touching it" -ForegroundColor DarkCyan | |
| } | |
| } | |
| function Ensure-Directories { | |
| $vhdDir = Split-Path $VhdPath -Parent | |
| if (-not (Test-Path $vhdDir)) { New-Item -ItemType Directory -Path $vhdDir | Out-Null } | |
| if (-not (Test-Path $ISOPath)) { throw "ISO not found at '$ISOPath'. Point me at your Ubuntu ISO or I will start making one in MS Paint." } | |
| } | |
| function Ensure-VM { | |
| $vm = Get-VM -Name $VMName -ErrorAction SilentlyContinue | |
| if ($null -eq $vm) { | |
| Write-Host "# Creating Generation 2 VM (UEFI) because it is 2025 and we have standards" -ForegroundColor Cyan | |
| New-VM -Name $VMName -Generation 2 -MemoryStartupBytes ($MemoryGB * 1GB) -SwitchName $SwitchName | Out-Null | |
| Set-VM -Name $VMName -DynamicMemory -MemoryMinimumBytes 512MB -MemoryMaximumBytes ([Math]::Max($MemoryGB,4) * 1GB) | Out-Null | |
| Set-VMProcessor -VMName $VMName -Count $CpuCount | Out-Null | |
| if (-not (Test-Path $VhdPath)) { | |
| Write-Host "# Creating a dynamically expanding VHDX because we respect your SSD" -ForegroundColor Cyan | |
| New-VHD -Path $VhdPath -Dynamic -SizeBytes ($VhdSizeGB * 1GB) | Out-Null | |
| } else { | |
| Write-Host "# Reusing existing VHDX at $VhdPath; we are not wasteful" -ForegroundColor DarkCyan | |
| } | |
| Add-VMHardDiskDrive -VMName $VMName -Path $VhdPath | Out-Null | |
| Add-VMDvdDrive -VMName $VMName -Path $ISOPath | Out-Null | |
| if ($EnableSecureBoot.IsPresent) { | |
| Write-Host "# Enabling Secure Boot with Microsoft UEFI CA (Ubuntu-approved, unlike my opinions)" -ForegroundColor Cyan | |
| Set-VMFirmware -VMName $VMName -EnableSecureBoot On -SecureBootTemplate "MicrosoftUEFICertificateAuthority" | |
| } else { | |
| Write-Host "# Disabling Secure Boot to keep the installer from having a panic attack" -ForegroundColor Yellow | |
| Set-VMFirmware -VMName $VMName -EnableSecureBoot Off | |
| } | |
| $dvd = Get-VMDvdDrive -VMName $VMName | |
| Set-VMFirmware -VMName $VMName -FirstBootDevice $dvd | Out-Null | |
| } else { | |
| Write-Host "# VM '$VMName' already exists; verifying attachments and settings" -ForegroundColor DarkCyan | |
| $adapters = Get-VMNetworkAdapter -VMName $VMName | |
| if ($adapters.Count -eq 0 -or $adapters[0].SwitchName -ne $SwitchName) { | |
| if ($adapters) { $adapters | Remove-VMNetworkAdapter -Confirm:$false | Out-Null } | |
| Add-VMNetworkAdapter -VMName $VMName -SwitchName $SwitchName | Out-Null | |
| } | |
| if (-not (Get-VMHardDiskDrive -VMName $VMName -ErrorAction SilentlyContinue | Where-Object { $_.Path -eq $VhdPath })) { | |
| if (-not (Test-Path $VhdPath)) { New-VHD -Path $VhdPath -Dynamic -SizeBytes ($VhdSizeGB * 1GB) | Out-Null } | |
| Add-VMHardDiskDrive -VMName $VMName -Path $VhdPath | Out-Null | |
| } | |
| $dvd = Get-VMDvdDrive -VMName $VMName -ErrorAction SilentlyContinue | |
| if ($null -eq $dvd) { Add-VMDvdDrive -VMName $VMName -Path $ISOPath | Out-Null } else { Set-VMDvdDrive -VMName $VMName -Path $ISOPath | Out-Null } | |
| if ($EnableSecureBoot.IsPresent) { | |
| Set-VMFirmware -VMName $VMName -EnableSecureBoot On -SecureBootTemplate "MicrosoftUEFICertificateAuthority" | Out-Null | |
| } else { | |
| Set-VMFirmware -VMName $VMName -EnableSecureBoot Off | Out-Null | |
| } | |
| $dvd = Get-VMDvdDrive -VMName $VMName | |
| Set-VMFirmware -VMName $VMName -FirstBootDevice $dvd | Out-Null | |
| } | |
| $na = Get-VMNetworkAdapter -VMName $VMName | |
| if ($na.Name -ne "nat0") { Rename-VMNetworkAdapter -VMName $VMName -NewName "nat0" | Out-Null } | |
| } | |
| function Ensure-FirewallRules { | |
| if (-not $OpenHostFirewall.IsPresent) { return } | |
| Write-Host "# Adding scoped firewall rules so you can SSH to the VM without inviting the whole internet" -ForegroundColor Cyan | |
| $rule1 = Get-NetFirewallRule -DisplayName "Allow SSH from $Subnet" -ErrorAction SilentlyContinue | |
| if ($null -eq $rule1) { | |
| New-NetFirewallRule -DisplayName "Allow SSH from $Subnet" -Direction Inbound -Action Allow -Protocol TCP -LocalPort 22 -RemoteAddress $Subnet | Out-Null | |
| } | |
| $rule2 = Get-NetFirewallRule -DisplayName "Allow Hyper-V Mgmt (marker)" -ErrorAction SilentlyContinue | |
| if ($null -eq $rule2) { | |
| New-NetFirewallRule -DisplayName "Allow Hyper-V Mgmt (marker)" -Direction Inbound -Action Allow -Protocol TCP -LocalPort 2179 -Profile Private | Out-Null | |
| } | |
| } | |
| function Start-Installer { | |
| Write-Host "# Spinning up the VM; the Ubuntu installer should appear. If you see PXE, I fight a NIC." -ForegroundColor Green | |
| Start-VM -Name $VMName | Out-Null | |
| Start-Sleep -Seconds 2 | |
| vmconnect localhost $VMName | |
| Write-Host "# In the Ubuntu installer, set a STATIC IP because Windows NAT does not run DHCP." -ForegroundColor Yellow | |
| Write-Host " Subnet: $($Subnet)" -ForegroundColor Yellow | |
| $base = $Gateway.Substring(0, $Gateway.LastIndexOf('.') + 1) | |
| Write-Host " Address: ${base}10" -ForegroundColor Yellow | |
| Write-Host " Gateway: $Gateway" -ForegroundColor Yellow | |
| Write-Host " DNS: 1.1.1.1, 8.8.8.8" -ForegroundColor Yellow | |
| Write-Host "# After install completes, STOP the VM and UNMOUNT the ISO so it boots from disk:" -ForegroundColor Cyan | |
| Write-Host " Stop-VM -Name $VMName -Force; Set-VMDvdDrive -VMName $VMName -Path `$null; Start-VM -Name $VMName" -ForegroundColor Cyan | |
| } | |
| function Remove-NATLab { | |
| <# | |
| .SYNOPSIS | |
| Danger button, but not stupid: tears down VM and NAT lab in dependency order. | |
| #> | |
| param([switch]$Force) | |
| $vm = Get-VM -Name $VMName -ErrorAction SilentlyContinue | |
| if ($vm) { | |
| if ($vm.State -ne "Off") { Stop-VM -Name $VMName -Force } | |
| Remove-VM -Name $VMName -Force | |
| } | |
| $nat = Get-NetNat -ErrorAction SilentlyContinue | Where-Object { $_.InternalIPInterfaceAddressPrefix -eq $Subnet } | |
| if ($nat) { Remove-NetNat -Name $nat.Name -Confirm:$false } | |
| $ifAlias = Get-VEthernetAlias | |
| $ip = Get-NetIPAddress -InterfaceAlias $ifAlias -AddressFamily IPv4 -ErrorAction SilentlyContinue | Where-Object { $_.IPAddress -eq $Gateway } | |
| if ($ip) { Remove-NetIPAddress -InputObject $ip -Confirm:$false } | |
| $sw = Get-VMSwitch -Name $SwitchName -ErrorAction SilentlyContinue | |
| if ($sw) { Remove-VMSwitch -Name $SwitchName -Force } | |
| if (Test-Path $VhdPath -and $Force) { Remove-Item $VhdPath -Force } | |
| Write-Host "# Lab removed. Chaos dispelled. Internet intact." -ForegroundColor Green | |
| } | |
| Assert-Admin | |
| if ($EnableHyperV) { | |
| Ensure-HyperV | |
| } else { | |
| Assert-Feature | |
| } | |
| Ensure-Directories | |
| Ensure-InternalSwitch | |
| Ensure-GatewayIP | |
| Ensure-NAT | |
| Ensure-VM | |
| Ensure-FirewallRules | |
| Start-Installer |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment