改造开始前的完整状态,作为可回退的基点。此提交之后: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 就会把口令提交进版本库。默认值在后续提交中改为空。
252 lines
13 KiB
PowerShell
252 lines
13 KiB
PowerShell
<#
|
||
.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' |