<# .SYNOPSIS 从零搭出 BakNRet 的 Hyper-V 隔离测试 VM(真机级:真 NTFS、真 ACL、真连接点、真重启)。 .DESCRIPTION 全流程无人值守、不需要点任何安装向导,也不需要 VM 的图形界面: 1. disk —— 挂载 Windows ISO,建 80 GB 动态 VHDX,按 UEFI 规范 GPT 分区, 用 DISM 把 install.wim 的指定版本展开进去,注入 7-Zip / PowerShell 7 / Pester / 供给脚本,写入 C:\Windows\Panther\unattend.xml,最后 bcdboot 写引导; 2. vm —— 建 Gen2 虚拟机(8 GB / 8 vCPU、Default Switch、"来宾服务接口"打开、 关闭安全启动以便离线注入的引导链可用),挂载系统盘并启动; 3. provision —— 等首次登录的供给脚本跑完,读回 VM 自报的真机事实,然后打检查点 clean-baseline(之后 Lab.ps1 -Verb reset 可秒回到干净状态)。 幂等:已存在的 VHDX / VM 会复用,除非显式加 -Recreate。 .PARAMETER ListImages 只打印 ISO 里的映像索引清单,不建任何东西。 .PARAMETER Stage all(默认)/ disk / vm / provision,可单独重跑某一段排查问题。 .EXAMPLE gsudo pwsh -NoProfile -File .\tools\lab\New-BakNRetLab.ps1 -ListImages .EXAMPLE gsudo pwsh -NoProfile -File .\tools\lab\New-BakNRetLab.ps1 #> [CmdletBinding()] [Diagnostics.CodeAnalysis.SuppressMessageAttribute('PSReviewUnusedParameter', '', Justification = '误判:-Recreate 是在遍历 VHDX 的 scriptblock 里用的,静态分析看不到那层使用。')] param( [ValidateSet('all', 'disk', 'vm', 'provision')][string]$Stage = 'all', [switch]$Recreate, [switch]$ListImages, [int]$ImageIndex = 0 ) $ErrorActionPreference = 'Stop' . (Join-Path $PSScriptRoot 'Lab-Common.ps1') $cfg = Get-LabConfig if ($ImageIndex -gt 0) { $cfg.ImageIndex = $ImageIndex } # --------------------------------------------------------------------------- # ISO 与映像清单 # --------------------------------------------------------------------------- function Get-IsoVolume { $di = Get-DiskImage -ImagePath $cfg.IsoPath -ErrorAction SilentlyContinue if (-not $di -or -not $di.Attached) { $di = Mount-DiskImage -ImagePath $cfg.IsoPath -PassThru } Start-Sleep -Milliseconds 1200 return $di } function Get-ImageList { param([Parameter(Mandatory)][string]$IsoLetter) $wim = @('install.wim', 'install.esd') | ForEach-Object { Join-Path "$IsoLetter`:\sources" $_ } | Where-Object { Test-Path -LiteralPath $_ } | Select-Object -First 1 if (-not $wim) { throw "ISO ($IsoLetter`:) 里找不到 sources\install.wim|esd" } $info = & dism.exe /English /Get-WimInfo /WimFile:"$wim" 2>&1 $list = @(); $cur = $null foreach ($line in $info) { if ($line -match '^Index\s*:\s*(\d+)') { if ($cur) { $list += $cur }; $cur = [ordered]@{ Index = [int]$Matches[1]; Name = ''; Size = '' } } elseif ($cur -and $line -match '^Name\s*:\s*(.+?)\s*$') { $cur.Name = $Matches[1] } elseif ($cur -and $line -match '^Size\s*:\s*(.+?)\s*$') { $cur.Size = $Matches[1] } } if ($cur) { $list += $cur } return [pscustomobject]@{ WimPath = $wim; Images = $list } } if ($ListImages) { Assert-LabElevated -Why '挂载 ISO 需要管理员' $di = Get-IsoVolume $letter = ($di | Get-Volume).DriveLetter $il = Get-ImageList -IsoLetter $letter Write-Host "映像文件:$($il.WimPath)" -ForegroundColor Cyan $il.Images | ForEach-Object { " [{0}] {1} {2}" -f $_.Index, $_.Name, $_.Size } return } # --------------------------------------------------------------------------- # 1. 系统盘 # --------------------------------------------------------------------------- function New-LabSystemDisk { Assert-LabElevated -Why '创建/分区 VHDX 与 DISM 展开映像' $espGuid = '{c12a7328-f81f-11d2-ba4b-00a0c93ec93b}' New-Item -ItemType Directory -Force -Path (Split-Path -Parent $cfg.VhdxPath) | Out-Null if ((Test-Path -LiteralPath $cfg.VhdxPath) -and $Recreate) { Write-LabLog "删除已有 VHDX:$($cfg.VhdxPath)" 'WARN' $mounted = Get-VHD -Path $cfg.VhdxPath -ErrorAction SilentlyContinue if ($mounted -and $mounted.Attached) { Dismount-VHD -Path $cfg.VhdxPath } Remove-Item -LiteralPath $cfg.VhdxPath -Force } if (-not (Test-Path -LiteralPath $cfg.VhdxPath)) { New-VHD -Path $cfg.VhdxPath -SizeBytes ($cfg.VhdxSizeGB * 1GB) -Dynamic | Out-Null Write-LabLog "已创建动态 VHDX($($cfg.VhdxSizeGB) GB):$($cfg.VhdxPath)" 'STEP' } $vhd = Mount-VHD -Path $cfg.VhdxPath -Passthru $disk = $vhd | Get-Disk if ($disk.PartitionStyle -eq 'RAW') { # Initialize-Disk 会顺手塞一个 MSR,先删掉,按 UEFI 规范自己建:ESP(300MB FAT32) + Windows(剩余 NTFS) Initialize-Disk -Number $disk.Number -PartitionStyle GPT -Confirm:$false | Out-Null Get-Partition -DiskNumber $disk.Number -ErrorAction SilentlyContinue | Where-Object { $_.Type -eq 'Reserved' } | ForEach-Object { Remove-Partition -DiskNumber $_.DiskNumber -PartitionNumber $_.PartitionNumber -Confirm:$false } $efi = New-Partition -DiskNumber $disk.Number -Size 300MB -GptType $espGuid -AssignDriveLetter Format-Volume -Partition $efi -FileSystem FAT32 -NewFileSystemLabel 'System' -Confirm:$false -Force | Out-Null $win = New-Partition -DiskNumber $disk.Number -UseMaximumSize -AssignDriveLetter Format-Volume -Partition $win -FileSystem NTFS -NewFileSystemLabel 'Windows' -Confirm:$false -Force | Out-Null Write-LabLog '已完成 GPT 分区(ESP 类型已按 EFI System Partition 建立)与格式化' 'STEP' } $efiPart = Get-Partition -DiskNumber $disk.Number | Where-Object GptType -EQ $espGuid $winPart = Get-Partition -DiskNumber $disk.Number | Where-Object { $_.GptType -eq '{ebd0a0a2-b9e5-4433-87c0-68b6b72699c7}' -and $_.Size -gt 1GB } if (-not $efiPart -or -not $winPart) { throw '分区布局不符合预期(ESP / Windows 分区没找到)' } $efiLetter = $efiPart.DriveLetter $winLetter = $winPart.DriveLetter if (-not $efiLetter) { Set-Partition -DiskNumber $disk.Number -PartitionNumber $efiPart.PartitionNumber -NewDriveLetter 'S'; $efiLetter = 'S' } if (-not $winLetter) { Set-Partition -DiskNumber $disk.Number -PartitionNumber $winPart.PartitionNumber -NewDriveLetter 'W'; $winLetter = 'W' } Write-LabLog "ESP = $efiLetter`:,Windows = $winLetter`:" 'STEP' # ---- 展开映像 ---- if (-not (Test-Path -LiteralPath "$winLetter`:\Windows\System32\ntoskrnl.exe")) { $di = Get-IsoVolume $isoLetter = ($di | Get-Volume).DriveLetter $il = Get-ImageList -IsoLetter $isoLetter $pick = $il.Images | Where-Object Index -EQ $cfg.ImageIndex if (-not $pick) { throw "ISO 里没有索引 $($cfg.ImageIndex);可用:$($il.Images.Index -join ', ')" } Write-LabLog "展开映像 [$($pick.Index)] $($pick.Name) -> $winLetter`:(需要十几分钟)" 'STEP' $scratch = Get-LabPath 'scratch' $out = Get-LabPath 'logs\dism-apply.out' $err = Get-LabPath 'logs\dism-apply.err' $proc = Start-Process -FilePath 'dism.exe' -NoNewWindow -Wait -PassThru -RedirectStandardOutput $out -RedirectStandardError $err ` -ArgumentList @('/English', '/Apply-Image', "/ImageFile:$($il.WimPath)", "/Index:$($pick.Index)", "/ApplyDir:$winLetter`:\", "/ScratchDir:$scratch") if ($proc.ExitCode -ne 0) { throw "DISM 展开失败,退出码 $($proc.ExitCode),见 $out / $err" } Write-LabLog '映像展开完成' 'STEP' } else { Write-LabLog '系统盘上已有 Windows,跳过展开' 'WARN' } # ---- 注入负载与无人值守应答文件 ---- Write-LabLog '注入 7-Zip / PowerShell 7 / Pester / 供给脚本' 'STEP' $payloadSrc = Join-Path $PSScriptRoot 'payload' $guestLab = Join-Path "$winLetter`:\" ($cfg.GuestLabPath.TrimStart('\')) foreach ($item in '7zip', 'pwsh', 'Pester', 'provision.ps1') { $src = Join-Path $payloadSrc $item $dst = Join-Path $guestLab ('payload\' + $item) if (Test-Path -LiteralPath $src) { $null = robocopy $src $dst /MIR /NFL /NDL /NJH /NJS /NP /R:1 /W:1 } else { Write-LabLog "负载缺失(跳过):$src" 'WARN' } } # 口令:随机生成,只留在宿主机 D:\VMs\BakNRet-Lab\state\credentials.json $password = New-LabPassword $credPath = Save-LabCredential -Password $password Write-LabLog "已生成 VM 凭据($credPath)" 'STEP' $unattendSrc = Get-Content -LiteralPath (Join-Path $payloadSrc 'unattend.xml') -Raw -Encoding UTF8 $unattendXml = $unattendSrc.Replace('__LABPASSWORD__', $password) $panther = Join-Path "$winLetter`:\" 'Windows\Panther' New-Item -ItemType Directory -Force -Path $panther | Out-Null [System.IO.File]::WriteAllText((Join-Path $panther 'unattend.xml'), $unattendXml, [System.Text.UTF8Encoding]::new($true)) Write-LabLog "已写入 $panther\unattend.xml" 'STEP' # ---- 引导 ---- Write-LabLog 'bcdboot 写 UEFI 引导' 'STEP' & bcdboot.exe "$winLetter`:\Windows" /s "$efiLetter`:" /f UEFI | ForEach-Object { Write-LabLog " $_" } if ($LASTEXITCODE -ne 0) { throw "bcdboot 失败,退出码 $LASTEXITCODE" } $bootMgr = Join-Path "$efiLetter`:\" 'EFI\Microsoft\Boot\bootmgfw.efi' if (-not (Test-Path -LiteralPath $bootMgr)) { throw "ESP 上没有 bootmgfw.efi:$bootMgr" } Write-LabLog "引导文件就位:$bootMgr" 'STEP' Dismount-VHD -Path $cfg.VhdxPath Write-LabLog '系统盘已完成并卸载' 'STEP' } # --------------------------------------------------------------------------- # 2. 虚拟机 # --------------------------------------------------------------------------- function New-LabVM { Assert-LabElevated -Why '创建/配置 Hyper-V 虚拟机' $vm = Get-LabVm if (-not $vm) { Write-LabLog "创建虚拟机 $($cfg.VmName)(Gen2 / $($cfg.MemoryStartupGB) GB / $($cfg.CpuCount) vCPU)" 'STEP' $vm = New-VM -Name $cfg.VmName -Generation 2 -MemoryStartupBytes ($cfg.MemoryStartupGB * 1GB) ` -VHDPath $cfg.VhdxPath -SwitchName $cfg.SwitchName Set-VMProcessor -VMName $cfg.VmName -Count $cfg.CpuCount Set-VMFirmware -VMName $cfg.VmName -EnableSecureBoot Off Set-VM -Name $cfg.VmName -AutomaticStopAction TurnOff -AutomaticStartAction Nothing # 集成服务名随系统语言变化(中文是「来宾服务接口」),按状态启用而不是按名字找 Get-VMIntegrationService -VMName $cfg.VmName | Where-Object { -not $_.Enabled } | ForEach-Object { Enable-VMIntegrationService -VMName $cfg.VmName -Name $_.Name } } else { Write-LabLog "虚拟机 $($cfg.VmName) 已存在(状态 $($vm.State))" 'WARN' if (-not (Get-VMHardDiskDrive -VMName $cfg.VmName | Where-Object Path -EQ $cfg.VhdxPath)) { Add-VMHardDiskDrive -VMName $cfg.VmName -Path $cfg.VhdxPath } } $vm = Get-LabVm if ($vm.State -ne 'Running') { Write-LabLog '启动虚拟机' 'STEP' Start-VM -Name $cfg.VmName if (-not (Wait-LabVMRunning -TimeoutSeconds 180)) { throw '虚拟机没有进入 Running' } } Write-LabLog "虚拟机状态:$((Get-LabVm).State)" 'STEP' } # --------------------------------------------------------------------------- # 3. 供给与检查点 # --------------------------------------------------------------------------- function Wait-LabProvision { Assert-LabElevated -Why 'PowerShell Direct 需要管理员' Write-LabLog '等待 VM 内供给脚本完成(首次启动要几分钟)' 'STEP' $sw = [Diagnostics.Stopwatch]::StartNew() while ($sw.Elapsed.TotalMinutes -lt 30) { if (Test-LabGuestReady) { Write-LabLog "供给完成,耗时 $([math]::Round($sw.Elapsed.TotalMinutes,1)) 分钟" 'STEP' $facts = Invoke-LabCommand -ScriptBlock { Get-Content -Encoding UTF8 'C:\BakNRet-Lab\state\provisioned.json' -Raw } Write-Host $facts return } Start-Sleep -Seconds 15 } throw '等待供给超时(30 分钟);用 Lab.ps1 -Verb console 到 VM 里看一眼 provision.log' } function New-LabCheckpoint { Assert-LabElevated -Why '创建 Hyper-V 检查点' $existing = Get-VMSnapshot -VMName $cfg.VmName -ErrorAction SilentlyContinue | Where-Object Name -EQ $cfg.CheckpointName if ($existing) { Write-LabLog "检查点 $($cfg.CheckpointName) 已存在,跳过" 'WARN'; return } Checkpoint-VM -Name $cfg.VmName -SnapshotName $cfg.CheckpointName Write-LabLog "已创建检查点 $($cfg.CheckpointName)(Lab.ps1 -Verb reset 可回到此状态)" 'STEP' } # --------------------------------------------------------------------------- # 主流程 # --------------------------------------------------------------------------- if ($Stage -in @('all', 'disk')) { New-LabSystemDisk } if ($Stage -in @('all', 'vm')) { New-LabVM } if ($Stage -in @('all', 'provision')) { Wait-LabProvision; New-LabCheckpoint } Write-LabLog '搭建流程结束' 'STEP'