Files
BakNRet/tools/lab/New-BakNRetLab.ps1
T
Shuery 2937eb6652 chore: 记录改造前基线
改造开始前的完整状态,作为可回退的基点。此提交之后:Pester 175 项、零依赖套件 101 项全绿;PowerShell 5.1 尚不可用(源文件无 BOM)。

包含此前未提交的在制品:安全描述符套件、Hyper-V 实验环境(tools/lab)、agent 约定(AGENTS.md 与 docs/agents)。

.gitignore 增加 *.key / *.pfx:BackupConfig.psd1 的 PasswordFile 此前默认指向仓库内的 baknret.key,一次 git add -A 就会把口令提交进版本库。默认值在后续提交中改为空。
2026-09-26 21:46:55 +08:00

252 lines
13 KiB
PowerShell
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<#
.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()]
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 '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'