Skip to content

Instantly share code, notes, and snippets.

@DJStompZone
Created October 20, 2025 02:52
Show Gist options
  • Select an option

  • Save DJStompZone/79774c0cbf21a878d1ea3da00e8dfb03 to your computer and use it in GitHub Desktop.

Select an option

Save DJStompZone/79774c0cbf21a878d1ea3da00e8dfb03 to your computer and use it in GitHub Desktop.
Build Hyper-V NAT Homelab
<#
.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