Skip to content

Commit c96b263

Browse files
committed
Persist WireGuard DPAPI route fix
1 parent b922afe commit c96b263

2 files changed

Lines changed: 217 additions & 1 deletion

File tree

README.md

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -327,6 +327,8 @@ If you want to use RDP as the main UI, consider checking out [Upinel/BetterRDP](
327327

328328
If a full-tunnel VPN inside the VM blocks normal host-to-VM access, add a private Hyper-V management link.
329329

330+
Import the WireGuard VPN config inside the VM first, including the normal full-tunnel `AllowedIPs = 0.0.0.0/0` setting from the customer config. After the tunnel is imported and can run, execute the management script from the host.
331+
330332
```powershell
331333
Set-ExecutionPolicy -Scope Process Bypass; .\configure-workstation-management.ps1 --config config\windows.json
332334
```

configure-workstation-management.ps1

Lines changed: 215 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -242,10 +242,213 @@ function ConfigureGuestManagement($cfg, $credential, $adapterMac, $adapterName,
242242

243243
function 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

Comments
 (0)