@@ -242,10 +242,213 @@ function ConfigureGuestManagement($cfg, $credential, $adapterMac, $adapterName,
242242
243243function EnsureGuestWireGuardAllowsManagement ($cfg , $credential ) {
244244 Invoke-Command - VMName $cfg.vmName - Credential $credential - ScriptBlock {
245+ $ErrorActionPreference = " Stop"
246+
245247 function GuestLog ($message ) {
246248 Write-Host (" [vpn] {0}" -f $message )
247249 }
248250
251+ function TunnelNameFromService ($serviceName ) {
252+ if ($serviceName -match ' ^WireGuardTunnel\$(.+)$' ) { return $Matches [1 ] }
253+ return $serviceName
254+ }
255+
256+ function QuotePowerShellString ($value ) {
257+ return " '" + ([string ]$value ).Replace(" '" , " ''" ) + " '"
258+ }
259+
260+ function InvokeSystemScript ($scriptText ) {
261+ $taskName = " WorkstationVM-WireGuardDpapi-$ ( [guid ]::NewGuid().ToString(" N" )) "
262+ $resultDir = Join-Path $env: ProgramData " WorkstationVM"
263+ New-Item - ItemType Directory - Force - Path $resultDir | Out-Null
264+ $resultPath = Join-Path $resultDir " $taskName .json"
265+ $scriptText = " `$ ResultPath = $ ( QuotePowerShellString $resultPath ) `r`n $scriptText "
266+ $encoded = [Convert ]::ToBase64String([Text.Encoding ]::Unicode.GetBytes($scriptText ))
267+ $action = New-ScheduledTaskAction - Execute " powershell.exe" - Argument " -NoProfile -ExecutionPolicy Bypass -EncodedCommand $encoded "
268+
269+ try {
270+ Register-ScheduledTask - TaskName $taskName - Action $action - User " SYSTEM" - RunLevel Highest - Force | Out-Null
271+ Start-ScheduledTask - TaskName $taskName
272+ $deadline = (Get-Date ).AddSeconds(60 )
273+ do {
274+ Start-Sleep - Milliseconds 500
275+ $task = Get-ScheduledTask - TaskName $taskName
276+ } while ($task.State -ne " Ready" -and (Get-Date ) -lt $deadline )
277+
278+ if ($task.State -ne " Ready" ) {
279+ throw " Timed out waiting for SYSTEM WireGuard config update task."
280+ }
281+
282+ $info = Get-ScheduledTaskInfo - TaskName $taskName
283+ if (-not (Test-Path - LiteralPath $resultPath )) {
284+ throw " SYSTEM WireGuard config update task did not write a result."
285+ }
286+
287+ $result = Get-Content - Raw - LiteralPath $resultPath | ConvertFrom-Json
288+ if ($result.Error ) {
289+ throw $result.Error
290+ }
291+ if ($info.LastTaskResult -ne 0 ) {
292+ throw " SYSTEM WireGuard config update task failed with code $ ( $info.LastTaskResult ) ."
293+ }
294+
295+ return $result
296+ } finally {
297+ Unregister-ScheduledTask - TaskName $taskName - Confirm:$false - ErrorAction SilentlyContinue
298+ Remove-Item - LiteralPath $resultPath - Force - ErrorAction SilentlyContinue
299+ if (Test-Path - LiteralPath $resultDir ) {
300+ $resultDirEntries = @ (Get-ChildItem - LiteralPath $resultDir - Force - ErrorAction SilentlyContinue)
301+ if ($resultDirEntries.Count -eq 0 ) {
302+ Remove-Item - LiteralPath $resultDir - Force - ErrorAction SilentlyContinue
303+ }
304+ }
305+ }
306+ }
307+
308+ function UpdateDpapiConfigAsSystem ($serviceName , $tunnelName , $configPath ) {
309+ $script = @'
310+ $ErrorActionPreference = "Stop"
311+ $ServiceName = __SERVICE_NAME__
312+ $TunnelName = __TUNNEL_NAME__
313+ $ConfigPath = __CONFIG_PATH__
314+
315+ function Write-Result($value) {
316+ $value | ConvertTo-Json -Compress | Set-Content -LiteralPath $ResultPath -Encoding UTF8
317+ }
318+
319+ Add-Type -TypeDefinition @"
320+ using System;
321+ using System.Runtime.InteropServices;
322+ public static class WireGuardDpapiNative {
323+ [StructLayout(LayoutKind.Sequential, CharSet=CharSet.Unicode)] public struct DATA_BLOB { public int cbData; public IntPtr pbData; }
324+ [DllImport("crypt32.dll", SetLastError=true, CharSet=CharSet.Unicode)] public static extern bool CryptUnprotectData(ref DATA_BLOB pDataIn, out IntPtr ppszDataDescr, IntPtr pOptionalEntropy, IntPtr pvReserved, IntPtr pPromptStruct, int dwFlags, out DATA_BLOB pDataOut);
325+ [DllImport("crypt32.dll", SetLastError=true, CharSet=CharSet.Unicode)] public static extern bool CryptProtectData(ref DATA_BLOB pDataIn, string szDataDescr, IntPtr pOptionalEntropy, IntPtr pvReserved, IntPtr pPromptStruct, int dwFlags, out DATA_BLOB pDataOut);
326+ [DllImport("kernel32.dll", SetLastError=true)] public static extern IntPtr LocalFree(IntPtr hMem);
327+ }
328+ "@
329+
330+ function New-Blob([byte[]]$bytes) {
331+ $blob = New-Object WireGuardDpapiNative+DATA_BLOB
332+ $blob.cbData = $bytes.Length
333+ if ($bytes.Length -gt 0) {
334+ $blob.pbData = [Runtime.InteropServices.Marshal]::AllocHGlobal($bytes.Length)
335+ [Runtime.InteropServices.Marshal]::Copy($bytes, 0, $blob.pbData, $bytes.Length)
336+ }
337+ return $blob
338+ }
339+
340+ function Win32Error {
341+ return ([ComponentModel.Win32Exception][Runtime.InteropServices.Marshal]::GetLastWin32Error()).Message
342+ }
343+
344+ function UnprotectBytes([byte[]]$bytes, [ref]$description) {
345+ $inBlob = New-Blob $bytes
346+ $outBlob = New-Object WireGuardDpapiNative+DATA_BLOB
347+ $descriptionPtr = [IntPtr]::Zero
348+ try {
349+ $ok = [WireGuardDpapiNative]::CryptUnprotectData([ref]$inBlob, [ref]$descriptionPtr, [IntPtr]::Zero, [IntPtr]::Zero, [IntPtr]::Zero, 1, [ref]$outBlob)
350+ if (-not $ok) { throw "Unable to decrypt WireGuard DPAPI config: $(Win32Error)" }
351+ $description.Value = [Runtime.InteropServices.Marshal]::PtrToStringUni($descriptionPtr)
352+ $plain = New-Object byte[] $outBlob.cbData
353+ [Runtime.InteropServices.Marshal]::Copy($outBlob.pbData, $plain, 0, $outBlob.cbData)
354+ return ,$plain
355+ } finally {
356+ if ($inBlob.pbData -ne [IntPtr]::Zero) { [Runtime.InteropServices.Marshal]::FreeHGlobal($inBlob.pbData) }
357+ if ($outBlob.pbData -ne [IntPtr]::Zero) { [WireGuardDpapiNative]::LocalFree($outBlob.pbData) | Out-Null }
358+ if ($descriptionPtr -ne [IntPtr]::Zero) { [WireGuardDpapiNative]::LocalFree($descriptionPtr) | Out-Null }
359+ }
360+ }
361+
362+ function ProtectBytes([byte[]]$bytes, [string]$description) {
363+ $inBlob = New-Blob $bytes
364+ $outBlob = New-Object WireGuardDpapiNative+DATA_BLOB
365+ try {
366+ $ok = [WireGuardDpapiNative]::CryptProtectData([ref]$inBlob, $description, [IntPtr]::Zero, [IntPtr]::Zero, [IntPtr]::Zero, 1, [ref]$outBlob)
367+ if (-not $ok) { throw "Unable to encrypt WireGuard DPAPI config: $(Win32Error)" }
368+ $encrypted = New-Object byte[] $outBlob.cbData
369+ [Runtime.InteropServices.Marshal]::Copy($outBlob.pbData, $encrypted, 0, $outBlob.cbData)
370+ return ,$encrypted
371+ } finally {
372+ if ($inBlob.pbData -ne [IntPtr]::Zero) { [Runtime.InteropServices.Marshal]::FreeHGlobal($inBlob.pbData) }
373+ if ($outBlob.pbData -ne [IntPtr]::Zero) { [WireGuardDpapiNative]::LocalFree($outBlob.pbData) | Out-Null }
374+ }
375+ }
376+
377+ try {
378+ $encrypted = [IO.File]::ReadAllBytes($ConfigPath)
379+ $description = ""
380+ $plain = UnprotectBytes $encrypted ([ref]$description)
381+ if ($description -ne $TunnelName) {
382+ throw "WireGuard DPAPI config description '$description' does not match tunnel '$TunnelName'."
383+ }
384+
385+ $text = [Text.Encoding]::UTF8.GetString($plain)
386+ $lineEnding = if ($text.Contains("`r`n")) { "`r`n" } else { "`n" }
387+ $lines = $text -split "\r?\n"
388+ $changed = $false
389+ $updated = foreach ($line in $lines) {
390+ if ($line -match '^(\s*AllowedIPs\s*=\s*)(.+)$') {
391+ $prefix = $Matches[1]
392+ $values = @($Matches[2].Split(",") | ForEach-Object { $_.Trim() } | Where-Object { $_ })
393+ if ($values -contains "0.0.0.0/0") {
394+ $values = @($values | Where-Object { $_ -ne "0.0.0.0/0" })
395+ if ($values -notcontains "0.0.0.0/1") { $values += "0.0.0.0/1" }
396+ if ($values -notcontains "128.0.0.0/1") { $values += "128.0.0.0/1" }
397+ $changed = $true
398+ "$prefix$($values -join ', ')"
399+ } else {
400+ $line
401+ }
402+ } else {
403+ $line
404+ }
405+ }
406+
407+ if (-not $changed) {
408+ Write-Result ([pscustomobject]@{ Changed = $false; Restarted = $false })
409+ exit 0
410+ }
411+
412+ $backup = "$ConfigPath.management-backup"
413+ $backupCreated = $false
414+ if (-not (Test-Path -LiteralPath $backup)) {
415+ Copy-Item -LiteralPath $ConfigPath -Destination $backup -Force
416+ $backupCreated = $true
417+ }
418+
419+ $updatedText = $updated -join $lineEnding
420+ $updatedBytes = [Text.Encoding]::UTF8.GetBytes($updatedText)
421+ $updatedEncrypted = ProtectBytes $updatedBytes $TunnelName
422+
423+ $service = Get-Service -Name $ServiceName -ErrorAction Stop
424+ $serviceWasRunning = $service.Status -eq [ServiceProcess.ServiceControllerStatus]::Running
425+ if ($serviceWasRunning) {
426+ Stop-Service -Name $ServiceName -Force -ErrorAction Stop
427+ (Get-Service -Name $ServiceName).WaitForStatus([ServiceProcess.ServiceControllerStatus]::Stopped, [TimeSpan]::FromSeconds(30))
428+ }
429+
430+ [IO.File]::WriteAllBytes($ConfigPath, $updatedEncrypted)
431+
432+ if ($serviceWasRunning) {
433+ Start-Service -Name $ServiceName -ErrorAction Stop
434+ (Get-Service -Name $ServiceName).WaitForStatus([ServiceProcess.ServiceControllerStatus]::Running, [TimeSpan]::FromSeconds(30))
435+ }
436+
437+ Write-Result ([pscustomobject]@{ Changed = $true; Restarted = $serviceWasRunning; BackupCreated = $backupCreated })
438+ } catch {
439+ if ($serviceWasRunning) {
440+ try { Start-Service -Name $ServiceName -ErrorAction SilentlyContinue } catch {}
441+ }
442+ Write-Result ([pscustomobject]@{ Error = $_.Exception.Message })
443+ exit 1
444+ }
445+ '@
446+ $script = $script.Replace (" __SERVICE_NAME__" , (QuotePowerShellString $serviceName )).
447+ Replace(" __TUNNEL_NAME__" , (QuotePowerShellString $tunnelName )).
448+ Replace(" __CONFIG_PATH__" , (QuotePowerShellString $configPath ))
449+ return (InvokeSystemScript $script )
450+ }
451+
249452 $services = @ (Get-CimInstance Win32_Service |
250453 Where-Object { $_.Name -like " WireGuardTunnel$*" -and $_.State -eq " Running" })
251454 if ($services.Count -eq 0 ) {
@@ -260,12 +463,23 @@ function EnsureGuestWireGuardAllowsManagement($cfg, $credential) {
260463 }
261464
262465 $configPath = $Matches [1 ].Trim().Trim(' "' )
466+ $tunnelName = TunnelNameFromService $service.Name
263467 if (-not (Test-Path - LiteralPath $configPath )) {
264468 GuestLog " Config path for $ ( $service.Name ) is not accessible"
265469 continue
266470 }
267471
268- $content = Get-Content - LiteralPath $configPath
472+ if ($configPath -like " *.conf.dpapi" ) {
473+ $result = UpdateDpapiConfigAsSystem $service.Name $tunnelName $configPath
474+ if ($result.Changed ) {
475+ GuestLog " Converted $ ( $service.Name ) .conf.dpapi from exact 0.0.0.0/0 to split default routes"
476+ } else {
477+ GuestLog " $ ( $service.Name ) already avoids exact 0.0.0.0/0"
478+ }
479+ continue
480+ }
481+
482+ $content = Get-Content - LiteralPath $configPath - ErrorAction Stop
269483 $changed = $false
270484 $updated = foreach ($line in $content ) {
271485 if ($line -match ' ^\s*AllowedIPs\s*=\s*(.+)$' ) {
0 commit comments