Skip to content

Instantly share code, notes, and snippets.

@tcartwright
Last active September 4, 2026 18:25
Show Gist options
  • Select an option

  • Save tcartwright/937202e2cad64321eee0f0db61b736f1 to your computer and use it in GitHub Desktop.

Select an option

Save tcartwright/937202e2cad64321eee0f0db61b736f1 to your computer and use it in GitHub Desktop.
POWERSHELL: Configures Windows so VPN connections survive sleep (Modern Standby).
#Requires -RunAsAdministrator
#Requires -Version 5.1
<#
.SYNOPSIS
Undoes the changes recorded in a Set-VpnSleepSettings.ps1 JSON changelog.
.DESCRIPTION
Reads a changelog, takes every row marked 'Changed', and replays it backwards
in reverse order. Each row carries a machine-readable Revert descriptor
written at the time of the change, so this script restores the actual
previous values rather than guessing at defaults.
Handles six kinds of change:
Registry Restores the previous value, or deletes the value if it
did not exist before. DWord, String, MultiString, Binary.
FirewallSetting Restores a global IPsec setting (MaxSAIdleTimeSeconds).
PowerScheme Restores the previous powercfg index for that scheme,
subgroup, setting and rail, then re-applies the active
scheme.
NetAdapterPM Restores one Get/Set-NetAdapterPowerManagement property.
Batched per adapter so each one resets at most once.
NetAdapterAdvanced Restores one driver advanced property (RegistryValue).
VpnConnection Restores IdleDisconnectSeconds or RememberCredential.
PbkBackup Restores rasphone.pbk wholesale from the .bak file taken
before the edit.
Safe to re-run: a second pass finds everything already back and reports
'Already reverted'.
RUN THIS WITH -WhatIf FIRST.
.PARAMETER ChangelogPath
Path to a VpnSleepSettings-*.json file. If omitted, the newest changelog in
-LogPath is used.
.PARAMETER LogPath
Directory to search for the newest changelog when -ChangelogPath is omitted.
Default: the script's own directory.
.PARAMETER Only
Revert only these kinds of change. Default: all of them. Useful when you want
the power settings back but intend to keep, say, the VPN idle timeout.
.PARAMETER Exclude
Revert everything except these kinds.
.PARAMETER SkipAdapters
Do not revert anything that touches a network adapter. Adapter changes reset
the NIC, which drops live VPN sessions.
.PARAMETER Force
Do not prompt before reverting.
.EXAMPLE
.\Revert-VpnSleepSettings.ps1 -WhatIf
Show what reverting the newest changelog would do. Always start here.
.EXAMPLE
.\Revert-VpnSleepSettings.ps1
Revert the newest changelog, with a confirmation prompt.
.EXAMPLE
.\Revert-VpnSleepSettings.ps1 -ChangelogPath .\VpnSleepSettings-20260904-091500.json -Force
Revert a specific run without prompting.
.EXAMPLE
.\Revert-VpnSleepSettings.ps1 -Only PowerScheme, Registry
Put the power and registry settings back but leave the VPN profile,
rasphone.pbk and the adapters as they are.
.EXAMPLE
.\Revert-VpnSleepSettings.ps1 -SkipAdapters
Revert everything that does not bounce a NIC. Safe while connected.
.NOTES
Things this script cannot undo, by nature:
* A power-scheme value whose previous state was "hidden or absent" is
recorded as null. There is no index to write back, so those rows are
reported as 'Cannot revert' and left alone. If you want the standby
network setting back to Windows-managed rather than Enabled, set it to 2
by hand.
* Credentials cached because RememberCredential was turned on. Setting it
back to False stops future caching; clear anything already saved from
the connection's own properties.
* Always On rows revert the registry values, but if you also ticked
"Connect automatically" in Settings, untick it there. The UI is
authoritative.
* A rasphone.pbk restore is wholesale. If you edited the phonebook by hand
between running the two scripts, those edits go too. The .bak file is
left in place either way.
Version: 1.0
Requires: Windows 10 1607+ / Windows 11, PowerShell 5.1+
#>
[CmdletBinding(SupportsShouldProcess)]
param(
[string]$ChangelogPath,
[string]$LogPath = $PSScriptRoot,
[ValidateSet('Registry', 'PowerScheme', 'NetAdapterPM', 'NetAdapterAdvanced', 'VpnConnection', 'PbkBackup', 'FirewallSetting')]
[string[]]$Only,
[ValidateSet('Registry', 'PowerScheme', 'NetAdapterPM', 'NetAdapterAdvanced', 'VpnConnection', 'PbkBackup', 'FirewallSetting')]
[string[]]$Exclude,
[switch]$SkipAdapters,
[switch]$Force,
# Do not clear the screen on start. Use when you are capturing output.
[switch]$NoClear
)
Set-StrictMode -Version Latest
$ErrorActionPreference = 'Stop'
# Start from a clean screen. Guarded: Clear-Host is meaningless, and can throw
# or emit stray escape codes, when output is redirected or there is no console.
if (-not $NoClear -and [Environment]::UserInteractive) {
try { Clear-Host } catch { }
}
# =============================================================================
# Locate and load the changelog
# =============================================================================
if (-not $ChangelogPath) {
if (-not $LogPath -or -not (Test-Path $LogPath)) {
throw "No -ChangelogPath given and -LogPath '$LogPath' does not exist."
}
$newest = Get-ChildItem -LiteralPath $LogPath -Filter 'VpnSleepSettings-*.json' -File -ErrorAction SilentlyContinue |
Sort-Object LastWriteTime -Descending | Select-Object -First 1
if (-not $newest) {
throw "No VpnSleepSettings-*.json changelog found in '$LogPath'. Pass -ChangelogPath explicitly."
}
$ChangelogPath = $newest.FullName
Write-Host "Using newest changelog: $ChangelogPath" -ForegroundColor DarkGray
}
if (-not (Test-Path -LiteralPath $ChangelogPath)) {
throw "Changelog not found: $ChangelogPath"
}
try {
$log = Get-Content -LiteralPath $ChangelogPath -Raw | ConvertFrom-Json
}
catch {
throw "Could not parse '$ChangelogPath' as JSON: $($_.Exception.Message)"
}
foreach ($required in 'Results', 'Timestamp') {
if ($log.PSObject.Properties.Name -notcontains $required) {
throw "'$ChangelogPath' does not look like a Set-VpnSleepSettings changelog (missing '$required')."
}
}
if (($log.PSObject.Properties.Name -contains 'WhatIf') -and $log.WhatIf) {
Write-Warning 'This changelog came from a -WhatIf run, so nothing was actually changed.'
Write-Warning 'There is nothing to revert. Exiting.'
return
}
$logComputer = '(unknown)'
if ($log.PSObject.Properties.Name -contains 'Computer') { $logComputer = $log.Computer }
if ($logComputer -ne '(unknown)' -and $logComputer -ne $env:COMPUTERNAME) {
Write-Warning "This changelog was written on '$logComputer' but you are on '$env:COMPUTERNAME'."
if (-not $Force) {
throw 'Refusing to revert a changelog from a different machine. Pass -Force to override.'
}
}
# =============================================================================
# Select the rows to revert
# =============================================================================
$changed = @($log.Results | Where-Object { $_.Action -eq 'Changed' })
if (-not $changed.Count) {
Write-Host 'This changelog records no changes. Nothing to revert.' -ForegroundColor Green
return
}
# Reverse order: later changes undone first.
[array]::Reverse($changed)
$adapterKinds = @('NetAdapterPM', 'NetAdapterAdvanced')
$queue = [System.Collections.Generic.List[object]]::new()
$skipped = [System.Collections.Generic.List[object]]::new()
foreach ($row in $changed) {
$hasRevert = ($row.PSObject.Properties.Name -contains 'Revert') -and $null -ne $row.Revert
if (-not $hasRevert) {
$skipped.Add([pscustomobject]@{ Setting = $row.Setting; Scope = $row.Scope; Reason = 'No revert data recorded' })
continue
}
$kind = $row.Revert.Kind
if ($Only -and ($Only -notcontains $kind)) {
$skipped.Add([pscustomobject]@{ Setting = $row.Setting; Scope = $row.Scope; Reason = "Filtered out (-Only)" })
continue
}
if ($Exclude -and ($Exclude -contains $kind)) {
$skipped.Add([pscustomobject]@{ Setting = $row.Setting; Scope = $row.Scope; Reason = "Filtered out (-Exclude)" })
continue
}
if ($SkipAdapters -and ($adapterKinds -contains $kind)) {
$skipped.Add([pscustomobject]@{ Setting = $row.Setting; Scope = $row.Scope; Reason = 'Adapter change (-SkipAdapters)' })
continue
}
$queue.Add($row)
}
Write-Host ''
Write-Host '--- Revert plan --------------------------------------------------------' -ForegroundColor Cyan
Write-Host " Changelog: $ChangelogPath"
Write-Host " Written: $($log.Timestamp)"
Write-Host " Changes: $($changed.Count) recorded, $($queue.Count) to revert, $($skipped.Count) skipped"
Write-Host ''
if ($queue.Count) {
$queue | Select-Object `
@{ n = 'Setting'; e = { $_.Setting } },
@{ n = 'Scope'; e = { $_.Scope } },
@{ n = 'Kind'; e = { $_.Revert.Kind } },
@{ n = 'RestoreTo'; e = {
if ($_.PSObject.Properties.Name -contains 'Revert' -and
$_.Revert.PSObject.Properties.Name -contains 'Value') {
if ($null -eq $_.Revert.Value) { '(delete / absent)' } else { "$($_.Revert.Value)" }
}
else { 'from backup' }
} } | Format-Table -AutoSize
}
if ($skipped.Count) {
Write-Host 'Skipped:' -ForegroundColor DarkGray
$skipped | Format-Table -AutoSize
}
if (-not $queue.Count) {
Write-Host 'Nothing left to revert after filtering.' -ForegroundColor Yellow
return
}
# One confirmation for the whole plan, rather than one per item.
if (-not $Force -and -not $WhatIfPreference) {
$answer = Read-Host "Revert $($queue.Count) change(s)? [y/N]"
if ($answer -notmatch '^(y|yes)$') {
Write-Host 'Aborted. Nothing changed.' -ForegroundColor Yellow
return
}
}
# =============================================================================
# Revert handlers
# =============================================================================
$outcomes = [System.Collections.Generic.List[object]]::new()
function Add-Outcome {
param(
[Parameter(Mandatory)][string]$Setting,
[Parameter(Mandatory)][string]$Scope,
[Parameter(Mandatory)]
[ValidateSet('Reverted', 'Already reverted', 'Cannot revert', 'Skipped', 'Failed')]
[string]$Status,
[string]$Detail = ''
)
$outcomes.Add([pscustomobject]@{
Setting = $Setting
Scope = $Scope
Status = $Status
Detail = $Detail
})
}
function Revert-RegistryChange {
param($Row)
$r = $Row.Revert
$path = $r.Path
$name = $r.Name
$type = 'DWord'
if ($r.PSObject.Properties.Name -contains 'Type' -and $r.Type) { $type = $r.Type }
$existed = $false
if ($r.PSObject.Properties.Name -contains 'Existed') { $existed = [bool]$r.Existed }
if (-not $existed) {
# The value did not exist before; remove it.
if (-not (Test-Path $path)) {
Add-Outcome -Setting $Row.Setting -Scope $Row.Scope -Status 'Already reverted' -Detail 'Key absent'
return
}
$current = Get-ItemProperty -Path $path -Name $name -ErrorAction SilentlyContinue
if (-not $current) {
Add-Outcome -Setting $Row.Setting -Scope $Row.Scope -Status 'Already reverted' -Detail 'Value absent'
return
}
if ($PSCmdlet.ShouldProcess("$path\$name", 'Remove value')) {
Remove-ItemProperty -Path $path -Name $name -Force
Add-Outcome -Setting $Row.Setting -Scope $Row.Scope -Status 'Reverted' -Detail 'Value removed'
}
else {
Add-Outcome -Setting $Row.Setting -Scope $Row.Scope -Status 'Skipped' -Detail 'WhatIf'
}
return
}
$value = $r.Value
# JSON round-trips MultiString and Binary as arrays; make sure they still
# are, and that Binary is a real byte array rather than Int64s.
if ($type -eq 'MultiString') { $value = @($value) }
if ($type -eq 'Binary') { $value = [byte[]]@($value) }
if (-not (Test-Path $path)) {
if ($PSCmdlet.ShouldProcess("$path\$name", "Restore -> $value")) {
New-Item -Path $path -Force | Out-Null
Set-ItemProperty -Path $path -Name $name -Value $value -Type $type
Add-Outcome -Setting $Row.Setting -Scope $Row.Scope -Status 'Reverted' -Detail "Restored to $value"
}
else {
Add-Outcome -Setting $Row.Setting -Scope $Row.Scope -Status 'Skipped' -Detail 'WhatIf'
}
return
}
$current = Get-ItemProperty -Path $path -Name $name -ErrorAction SilentlyContinue
if ($current) {
$same = $false
if ($type -eq 'MultiString' -or $type -eq 'Binary') {
$same = ((@($current.$name) -join ',') -eq (@($value) -join ','))
}
else {
$same = ($current.$name -eq $value)
}
if ($same) {
Add-Outcome -Setting $Row.Setting -Scope $Row.Scope -Status 'Already reverted' -Detail "Already $value"
return
}
}
if ($PSCmdlet.ShouldProcess("$path\$name", "Restore -> $value")) {
Set-ItemProperty -Path $path -Name $name -Value $value -Type $type
Add-Outcome -Setting $Row.Setting -Scope $Row.Scope -Status 'Reverted' -Detail "Restored to $value"
}
else {
Add-Outcome -Setting $Row.Setting -Scope $Row.Scope -Status 'Skipped' -Detail 'WhatIf'
}
}
function Revert-PowerSchemeChange {
param($Row)
$r = $Row.Revert
if ($null -eq $r.Value) {
Add-Outcome -Setting $Row.Setting -Scope $Row.Scope -Status 'Cannot revert' `
-Detail 'Previous value was hidden or absent; no index to write back'
return $false
}
$verb = '/setacvalueindex'
if ($r.Rail -eq 'DC') { $verb = '/setdcvalueindex' }
if (-not $PSCmdlet.ShouldProcess("$($r.Scheme) ($($r.Rail))", "Restore -> $($r.Value)")) {
Add-Outcome -Setting $Row.Setting -Scope $Row.Scope -Status 'Skipped' -Detail 'WhatIf'
return $false
}
powercfg $verb $r.Scheme $r.SubGroup $r.Setting $r.Value | Out-Null
if ($LASTEXITCODE -ne 0) {
Add-Outcome -Setting $Row.Setting -Scope $Row.Scope -Status 'Failed' -Detail "powercfg exit $LASTEXITCODE"
return $false
}
Add-Outcome -Setting $Row.Setting -Scope $Row.Scope -Status 'Reverted' -Detail "Restored to $($r.Value)"
return $true
}
function Revert-NetAdapterAdvancedChange {
param($Row)
$r = $Row.Revert
$prop = Get-NetAdapterAdvancedProperty -Name $r.Adapter -AllProperties -ErrorAction SilentlyContinue |
Where-Object { $_.RegistryKeyword -eq $r.Keyword } | Select-Object -First 1
if (-not $prop) {
Add-Outcome -Setting $Row.Setting -Scope $Row.Scope -Status 'Cannot revert' `
-Detail 'Adapter or keyword no longer present'
return $false
}
if ("$($prop.RegistryValue)" -eq "$($r.Value)") {
Add-Outcome -Setting $Row.Setting -Scope $Row.Scope -Status 'Already reverted' -Detail "Already $($r.Value)"
return $false
}
if (-not $PSCmdlet.ShouldProcess($r.Adapter, "$($r.Keyword) -> $($r.Value) (resets adapter)")) {
Add-Outcome -Setting $Row.Setting -Scope $Row.Scope -Status 'Skipped' -Detail 'WhatIf'
return $false
}
try {
Set-NetAdapterAdvancedProperty -Name $r.Adapter -RegistryKeyword $r.Keyword `
-RegistryValue "$($r.Value)" -ErrorAction Stop
Add-Outcome -Setting $Row.Setting -Scope $Row.Scope -Status 'Reverted' -Detail "Restored to $($r.Value)"
return $true
}
catch {
Add-Outcome -Setting $Row.Setting -Scope $Row.Scope -Status 'Failed' -Detail $_.Exception.Message
return $false
}
}
function Revert-VpnConnectionChange {
param($Row)
$r = $Row.Revert
# Do not call this $args -- that is an automatic variable.
$vpnArgs = @{ Name = $r.Name; Force = $true }
if ($r.AllUser) { $vpnArgs['AllUserConnection'] = $true }
$vpn = $null
if ($r.AllUser) { $vpn = Get-VpnConnection -Name $r.Name -AllUserConnection -ErrorAction SilentlyContinue }
else { $vpn = Get-VpnConnection -Name $r.Name -ErrorAction SilentlyContinue }
if (-not $vpn) {
Add-Outcome -Setting $Row.Setting -Scope $Row.Scope -Status 'Cannot revert' -Detail 'Connection no longer exists'
return
}
$prop = $r.Property
if ($vpn.PSObject.Properties.Name -notcontains $prop) {
Add-Outcome -Setting $Row.Setting -Scope $Row.Scope -Status 'Cannot revert' -Detail "No '$prop' property"
return
}
if ($prop -eq 'IdleDisconnectSeconds') {
$want = [uint32]$r.Value
if ($vpn.IdleDisconnectSeconds -eq $want) {
Add-Outcome -Setting $Row.Setting -Scope $Row.Scope -Status 'Already reverted' -Detail "Already $want"
return
}
if (-not $PSCmdlet.ShouldProcess($Row.Scope, "IdleDisconnectSeconds -> $want")) {
Add-Outcome -Setting $Row.Setting -Scope $Row.Scope -Status 'Skipped' -Detail 'WhatIf'
return
}
Set-VpnConnection @vpnArgs -IdleDisconnectSeconds $want
Add-Outcome -Setting $Row.Setting -Scope $Row.Scope -Status 'Reverted' -Detail "Restored to $want"
return
}
if ($prop -eq 'RememberCredential') {
$want = [bool]$r.Value
if ([bool]$vpn.RememberCredential -eq $want) {
Add-Outcome -Setting $Row.Setting -Scope $Row.Scope -Status 'Already reverted' -Detail "Already $want"
return
}
if (-not $PSCmdlet.ShouldProcess($Row.Scope, "RememberCredential -> $want")) {
Add-Outcome -Setting $Row.Setting -Scope $Row.Scope -Status 'Skipped' -Detail 'WhatIf'
return
}
Set-VpnConnection @vpnArgs -RememberCredential $want
Add-Outcome -Setting $Row.Setting -Scope $Row.Scope -Status 'Reverted' `
-Detail "Restored to $want (already-cached credentials are not cleared)"
return
}
Add-Outcome -Setting $Row.Setting -Scope $Row.Scope -Status 'Cannot revert' -Detail "Unhandled property '$prop'"
}
function Revert-FirewallSettingChange {
param($Row)
$r = $Row.Revert
if ($r.Property -ne 'MaxSAIdleTimeSeconds') {
Add-Outcome -Setting $Row.Setting -Scope $Row.Scope -Status 'Cannot revert' -Detail "Unhandled property '$($r.Property)'"
return
}
try {
$current = (Get-NetFirewallSetting -PolicyStore ActiveStore -ErrorAction Stop).MaxSAIdleTimeSeconds
if ("$current" -eq "$($r.Value)") {
Add-Outcome -Setting $Row.Setting -Scope $Row.Scope -Status 'Already reverted' -Detail "Already $($r.Value)"
return
}
if (-not $PSCmdlet.ShouldProcess('Global IPsec settings', "MaxSAIdleTimeSeconds -> $($r.Value)")) {
Add-Outcome -Setting $Row.Setting -Scope $Row.Scope -Status 'Skipped' -Detail 'WhatIf'
return
}
Set-NetFirewallSetting -MaxSAIdleTimeSeconds $r.Value -ErrorAction Stop
Add-Outcome -Setting $Row.Setting -Scope $Row.Scope -Status 'Reverted' -Detail "Restored to $($r.Value)"
}
catch {
Add-Outcome -Setting $Row.Setting -Scope $Row.Scope -Status 'Failed' -Detail $_.Exception.Message
}
}
function Revert-PbkBackupChange {
param($Row, [hashtable]$DoneBackups)
$r = $Row.Revert
# Several rows share one backup; restore it once.
$key = "$($r.Path)|$($r.Backup)"
if ($DoneBackups.ContainsKey($key)) {
Add-Outcome -Setting $Row.Setting -Scope $Row.Scope -Status 'Already reverted' -Detail 'Covered by phonebook restore'
return
}
if (-not (Test-Path -LiteralPath $r.Backup)) {
Add-Outcome -Setting $Row.Setting -Scope $Row.Scope -Status 'Cannot revert' `
-Detail "Backup missing: $($r.Backup)"
return
}
if (-not $PSCmdlet.ShouldProcess($r.Path, "Restore phonebook from $($r.Backup)")) {
Add-Outcome -Setting $Row.Setting -Scope $Row.Scope -Status 'Skipped' -Detail 'WhatIf'
return
}
# Keep a copy of the current state before overwriting it, so this is itself
# undoable.
$preRevert = "$($r.Path).pre-revert-$(Get-Date -Format 'yyyyMMdd-HHmmss').bak"
Copy-Item -LiteralPath $r.Path -Destination $preRevert -Force -ErrorAction SilentlyContinue
Copy-Item -LiteralPath $r.Backup -Destination $r.Path -Force
$DoneBackups[$key] = $true
Add-Outcome -Setting $Row.Setting -Scope $Row.Scope -Status 'Reverted' `
-Detail "Phonebook restored (pre-revert copy: $preRevert)"
}
# =============================================================================
# Execute
# =============================================================================
Write-Host ''
Write-Host '--- Reverting ----------------------------------------------------------' -ForegroundColor Cyan
$powerSchemeTouched = $false
$adapterWasReset = $false
$doneBackups = @{}
# NetAdapterPM changes are collected per adapter so each NIC is written (and
# therefore reset) at most once, instead of once per property.
$pmPlan = @{}
foreach ($row in $queue) {
$kind = $row.Revert.Kind
try {
switch ($kind) {
'Registry' {
Revert-RegistryChange -Row $row
}
'PowerScheme' {
if (Revert-PowerSchemeChange -Row $row) { $powerSchemeTouched = $true }
}
'NetAdapterAdvanced' {
if (Revert-NetAdapterAdvancedChange -Row $row) { $adapterWasReset = $true }
}
'VpnConnection' {
Revert-VpnConnectionChange -Row $row
}
'PbkBackup' {
Revert-PbkBackupChange -Row $row -DoneBackups $doneBackups
}
'FirewallSetting' {
Revert-FirewallSettingChange -Row $row
}
'NetAdapterPM' {
$adapter = $row.Revert.Adapter
if (-not $pmPlan.ContainsKey($adapter)) {
$pmPlan[$adapter] = [System.Collections.Generic.List[object]]::new()
}
$pmPlan[$adapter].Add($row)
}
default {
Add-Outcome -Setting $row.Setting -Scope $row.Scope -Status 'Cannot revert' -Detail "Unknown kind '$kind'"
}
}
}
catch {
Add-Outcome -Setting $row.Setting -Scope $row.Scope -Status 'Failed' -Detail $_.Exception.Message
}
}
# Now apply the batched adapter power-management reverts.
foreach ($adapter in $pmPlan.Keys) {
$pm = Get-NetAdapterPowerManagement -Name $adapter -ErrorAction SilentlyContinue
if (-not $pm) {
foreach ($row in $pmPlan[$adapter]) {
Add-Outcome -Setting $row.Setting -Scope $row.Scope -Status 'Cannot revert' -Detail 'Adapter no longer present'
}
continue
}
$available = $pm.PSObject.Properties.Name
$dirty = $false
$applied = [System.Collections.Generic.List[object]]::new()
foreach ($row in $pmPlan[$adapter]) {
$prop = $row.Revert.Property
$want = "$($row.Revert.Value)"
if ($available -notcontains $prop) {
Add-Outcome -Setting $row.Setting -Scope $row.Scope -Status 'Cannot revert' -Detail "No '$prop' property"
continue
}
if ("$($pm.$prop)" -eq $want) {
Add-Outcome -Setting $row.Setting -Scope $row.Scope -Status 'Already reverted' -Detail "Already $want"
continue
}
if (-not $PSCmdlet.ShouldProcess($adapter, "$prop -> $want (resets adapter)")) {
Add-Outcome -Setting $row.Setting -Scope $row.Scope -Status 'Skipped' -Detail 'WhatIf'
continue
}
$pm.$prop = $want
$applied.Add([pscustomobject]@{ Row = $row; Property = $prop; Value = $want })
$dirty = $true
}
if (-not $dirty) { continue }
try {
Set-NetAdapterPowerManagement -InputObject $pm -ErrorAction Stop
$adapterWasReset = $true
foreach ($a in $applied) {
Add-Outcome -Setting $a.Row.Setting -Scope $a.Row.Scope -Status 'Reverted' -Detail "Restored to $($a.Value)"
}
}
catch {
foreach ($a in $applied) {
Add-Outcome -Setting $a.Row.Setting -Scope $a.Row.Scope -Status 'Failed' -Detail $_.Exception.Message
}
}
}
# powercfg writes only take effect on the active scheme once it is re-applied.
if ($powerSchemeTouched -and $PSCmdlet.ShouldProcess('Active power scheme', 'Re-apply')) {
powercfg /setactive SCHEME_CURRENT | Out-Null
}
# =============================================================================
# Report
# =============================================================================
Write-Host ''
$outcomes | Format-Table -AutoSize
$counts = $outcomes | Group-Object Status | Sort-Object Name
Write-Host '--- Summary ------------------------------------------------------------' -ForegroundColor Cyan
foreach ($c in $counts) {
$colour = 'Gray'
switch ($c.Name) {
'Reverted' { $colour = 'Green' }
'Already reverted' { $colour = 'DarkGray' }
'Cannot revert' { $colour = 'Yellow' }
'Failed' { $colour = 'Red' }
'Skipped' { $colour = 'DarkGray' }
}
Write-Host (" {0,-18} {1}" -f $c.Name, $c.Count) -ForegroundColor $colour
}
if ($adapterWasReset) {
Write-Host ''
Write-Host 'A network adapter was reset - reconnect any VPN that dropped.' -ForegroundColor Yellow
}
$cannot = @($outcomes | Where-Object { $_.Status -eq 'Cannot revert' })
if ($cannot.Count) {
Write-Host ''
Write-Host 'Some items could not be reverted automatically:' -ForegroundColor Yellow
$cannot | Select-Object Setting, Scope, Detail | Format-Table -AutoSize
}
$failed = @($outcomes | Where-Object { $_.Status -eq 'Failed' })
if ($failed.Count) {
Write-Host 'Some items failed. Details above. The changelog is unmodified, so you' -ForegroundColor Red
Write-Host 'can safely re-run this script after fixing the cause.' -ForegroundColor Red
}
Write-Host ''
Write-Host 'Manual follow-ups this script cannot do for you:' -ForegroundColor DarkGray
Write-Host ' * If you ticked "Connect automatically" (Always On) in Settings, untick it'
Write-Host ' there. The UI is authoritative over the registry values.'
Write-Host ' * Credentials already cached on a connection are not cleared by setting'
Write-Host ' RememberCredential back to False. Clear them in the connection properties.'
Write-Host ' * Power settings whose previous state was hidden or absent have no index to'
Write-Host ' write back. To put standby networking back to Windows-managed, set it to 2.'
Write-Host ''
#Requires -RunAsAdministrator
#Requires -Version 5.1
<#
.SYNOPSIS
Configures Windows so built-in (RAS) VPN connections survive lock, idle, and
Modern Standby sleep. Version 3 -- no scheduled task, no installed script.
.DESCRIPTION
Idempotent. Reads current state, changes only what differs, and reports
'Changed' vs 'Already set' per item. Safe to re-run; a no-op second run will
not bounce the network ada#Requires -RunAsAdministrator
#Requires -Version 5.1
<#
.SYNOPSIS
Undoes the changes recorded in a Set-VpnSleepSettings.ps1 JSON changelog.
.DESCRIPTION
Reads a changelog, takes every row marked 'Changed', and replays it backwards
in reverse order. Each row carries a machine-readable Revert descriptor
written at the time of the change, so this script restores the actual
previous values rather than guessing at defaults.
Handles six kinds of change:
Registry Restores the previous value, or deletes the value if it
did not exist before. DWord, String, MultiString, Binary.
FirewallSetting Restores a global IPsec setting (MaxSAIdleTimeSeconds).
PowerScheme Restores the previous powercfg index for that scheme,
subgroup, setting and rail, then re-applies the active
scheme.
NetAdapterPM Restores one Get/Set-NetAdapterPowerManagement property.
Batched per adapter so each one resets at most once.
NetAdapterAdvanced Restores one driver advanced property (RegistryValue).
VpnConnection Restores IdleDisconnectSeconds or RememberCredential.
PbkBackup Restores rasphone.pbk wholesale from the .bak file taken
before the edit.
Safe to re-run: a second pass finds everything already back and reports
'Already reverted'.
RUN THIS WITH -WhatIf FIRST.
.PARAMETER ChangelogPath
Path to a VpnSleepSettings-*.json file. If omitted, the newest changelog in
-LogPath is used.
.PARAMETER LogPath
Directory to search for the newest changelog when -ChangelogPath is omitted.
Default: the script's own directory.
.PARAMETER Only
Revert only these kinds of change. Default: all of them. Useful when you want
the power settings back but intend to keep, say, the VPN idle timeout.
.PARAMETER Exclude
Revert everything except these kinds.
.PARAMETER SkipAdapters
Do not revert anything that touches a network adapter. Adapter changes reset
the NIC, which drops live VPN sessions.
.PARAMETER Force
Do not prompt before reverting.
.EXAMPLE
.\Revert-VpnSleepSettings.ps1 -WhatIf
Show what reverting the newest changelog would do. Always start here.
.EXAMPLE
.\Revert-VpnSleepSettings.ps1
Revert the newest changelog, with a confirmation prompt.
.EXAMPLE
.\Revert-VpnSleepSettings.ps1 -ChangelogPath .\VpnSleepSettings-20260904-091500.json -Force
Revert a specific run without prompting.
.EXAMPLE
.\Revert-VpnSleepSettings.ps1 -Only PowerScheme, Registry
Put the power and registry settings back but leave the VPN profile,
rasphone.pbk and the adapters as they are.
.EXAMPLE
.\Revert-VpnSleepSettings.ps1 -SkipAdapters
Revert everything that does not bounce a NIC. Safe while connected.
.NOTES
Things this script cannot undo, by nature:
* A power-scheme value whose previous state was "hidden or absent" is
recorded as null. There is no index to write back, so those rows are
reported as 'Cannot revert' and left alone. If you want the standby
network setting back to Windows-managed rather than Enabled, set it to 2
by hand.
* Credentials cached because RememberCredential was turned on. Setting it
back to False stops future caching; clear anything already saved from
the connection's own properties.
* Always On rows revert the registry values, but if you also ticked
"Connect automatically" in Settings, untick it there. The UI is
authoritative.
* A rasphone.pbk restore is wholesale. If you edited the phonebook by hand
between running the two scripts, those edits go too. The .bak file is
left in place either way.
Version: 1.0
Requires: Windows 10 1607+ / Windows 11, PowerShell 5.1+
#>
[CmdletBinding(SupportsShouldProcess)]
param(
[string]$ChangelogPath,
[string]$LogPath = $PSScriptRoot,
[ValidateSet('Registry', 'PowerScheme', 'NetAdapterPM', 'NetAdapterAdvanced', 'VpnConnection', 'PbkBackup', 'FirewallSetting')]
[string[]]$Only,
[ValidateSet('Registry', 'PowerScheme', 'NetAdapterPM', 'NetAdapterAdvanced', 'VpnConnection', 'PbkBackup', 'FirewallSetting')]
[string[]]$Exclude,
[switch]$SkipAdapters,
[switch]$Force,
# Do not clear the screen on start. Use when you are capturing output.
[switch]$NoClear
)
Set-StrictMode -Version Latest
$ErrorActionPreference = 'Stop'
# Start from a clean screen. Guarded: Clear-Host is meaningless, and can throw
# or emit stray escape codes, when output is redirected or there is no console.
if (-not $NoClear -and [Environment]::UserInteractive) {
try { Clear-Host } catch { }
}
# =============================================================================
# Locate and load the changelog
# =============================================================================
if (-not $ChangelogPath) {
if (-not $LogPath -or -not (Test-Path $LogPath)) {
throw "No -ChangelogPath given and -LogPath '$LogPath' does not exist."
}
$newest = Get-ChildItem -LiteralPath $LogPath -Filter 'VpnSleepSettings-*.json' -File -ErrorAction SilentlyContinue |
Sort-Object LastWriteTime -Descending | Select-Object -First 1
if (-not $newest) {
throw "No VpnSleepSettings-*.json changelog found in '$LogPath'. Pass -ChangelogPath explicitly."
}
$ChangelogPath = $newest.FullName
Write-Host "Using newest changelog: $ChangelogPath" -ForegroundColor DarkGray
}
if (-not (Test-Path -LiteralPath $ChangelogPath)) {
throw "Changelog not found: $ChangelogPath"
}
try {
$log = Get-Content -LiteralPath $ChangelogPath -Raw | ConvertFrom-Json
}
catch {
throw "Could not parse '$ChangelogPath' as JSON: $($_.Exception.Message)"
}
foreach ($required in 'Results', 'Timestamp') {
if ($log.PSObject.Properties.Name -notcontains $required) {
throw "'$ChangelogPath' does not look like a Set-VpnSleepSettings changelog (missing '$required')."
}
}
if (($log.PSObject.Properties.Name -contains 'WhatIf') -and $log.WhatIf) {
Write-Warning 'This changelog came from a -WhatIf run, so nothing was actually changed.'
Write-Warning 'There is nothing to revert. Exiting.'
return
}
$logComputer = '(unknown)'
if ($log.PSObject.Properties.Name -contains 'Computer') { $logComputer = $log.Computer }
if ($logComputer -ne '(unknown)' -and $logComputer -ne $env:COMPUTERNAME) {
Write-Warning "This changelog was written on '$logComputer' but you are on '$env:COMPUTERNAME'."
if (-not $Force) {
throw 'Refusing to revert a changelog from a different machine. Pass -Force to override.'
}
}
# =============================================================================
# Select the rows to revert
# =============================================================================
$changed = @($log.Results | Where-Object { $_.Action -eq 'Changed' })
if (-not $changed.Count) {
Write-Host 'This changelog records no changes. Nothing to revert.' -ForegroundColor Green
return
}
# Reverse order: later changes undone first.
[array]::Reverse($changed)
$adapterKinds = @('NetAdapterPM', 'NetAdapterAdvanced')
$queue = [System.Collections.Generic.List[object]]::new()
$skipped = [System.Collections.Generic.List[object]]::new()
foreach ($row in $changed) {
$hasRevert = ($row.PSObject.Properties.Name -contains 'Revert') -and $null -ne $row.Revert
if (-not $hasRevert) {
$skipped.Add([pscustomobject]@{ Setting = $row.Setting; Scope = $row.Scope; Reason = 'No revert data recorded' })
continue
}
$kind = $row.Revert.Kind
if ($Only -and ($Only -notcontains $kind)) {
$skipped.Add([pscustomobject]@{ Setting = $row.Setting; Scope = $row.Scope; Reason = "Filtered out (-Only)" })
continue
}
if ($Exclude -and ($Exclude -contains $kind)) {
$skipped.Add([pscustomobject]@{ Setting = $row.Setting; Scope = $row.Scope; Reason = "Filtered out (-Exclude)" })
continue
}
if ($SkipAdapters -and ($adapterKinds -contains $kind)) {
$skipped.Add([pscustomobject]@{ Setting = $row.Setting; Scope = $row.Scope; Reason = 'Adapter change (-SkipAdapters)' })
continue
}
$queue.Add($row)
}
Write-Host ''
Write-Host '--- Revert plan --------------------------------------------------------' -ForegroundColor Cyan
Write-Host " Changelog: $ChangelogPath"
Write-Host " Written: $($log.Timestamp)"
Write-Host " Changes: $($changed.Count) recorded, $($queue.Count) to revert, $($skipped.Count) skipped"
Write-Host ''
if ($queue.Count) {
$queue | Select-Object `
@{ n = 'Setting'; e = { $_.Setting } },
@{ n = 'Scope'; e = { $_.Scope } },
@{ n = 'Kind'; e = { $_.Revert.Kind } },
@{ n = 'RestoreTo'; e = {
if ($_.PSObject.Properties.Name -contains 'Revert' -and
$_.Revert.PSObject.Properties.Name -contains 'Value') {
if ($null -eq $_.Revert.Value) { '(delete / absent)' } else { "$($_.Revert.Value)" }
}
else { 'from backup' }
} } | Format-Table -AutoSize
}
if ($skipped.Count) {
Write-Host 'Skipped:' -ForegroundColor DarkGray
$skipped | Format-Table -AutoSize
}
if (-not $queue.Count) {
Write-Host 'Nothing left to revert after filtering.' -ForegroundColor Yellow
return
}
# One confirmation for the whole plan, rather than one per item.
if (-not $Force -and -not $WhatIfPreference) {
$answer = Read-Host "Revert $($queue.Count) change(s)? [y/N]"
if ($answer -notmatch '^(y|yes)$') {
Write-Host 'Aborted. Nothing changed.' -ForegroundColor Yellow
return
}
}
# =============================================================================
# Revert handlers
# =============================================================================
$outcomes = [System.Collections.Generic.List[object]]::new()
function Add-Outcome {
param(
[Parameter(Mandatory)][string]$Setting,
[Parameter(Mandatory)][string]$Scope,
[Parameter(Mandatory)]
[ValidateSet('Reverted', 'Already reverted', 'Cannot revert', 'Skipped', 'Failed')]
[string]$Status,
[string]$Detail = ''
)
$outcomes.Add([pscustomobject]@{
Setting = $Setting
Scope = $Scope
Status = $Status
Detail = $Detail
})
}
function Revert-RegistryChange {
param($Row)
$r = $Row.Revert
$path = $r.Path
$name = $r.Name
$type = 'DWord'
if ($r.PSObject.Properties.Name -contains 'Type' -and $r.Type) { $type = $r.Type }
$existed = $false
if ($r.PSObject.Properties.Name -contains 'Existed') { $existed = [bool]$r.Existed }
if (-not $existed) {
# The value did not exist before; remove it.
if (-not (Test-Path $path)) {
Add-Outcome -Setting $Row.Setting -Scope $Row.Scope -Status 'Already reverted' -Detail 'Key absent'
return
}
$current = Get-ItemProperty -Path $path -Name $name -ErrorAction SilentlyContinue
if (-not $current) {
Add-Outcome -Setting $Row.Setting -Scope $Row.Scope -Status 'Already reverted' -Detail 'Value absent'
return
}
if ($PSCmdlet.ShouldProcess("$path\$name", 'Remove value')) {
Remove-ItemProperty -Path $path -Name $name -Force
Add-Outcome -Setting $Row.Setting -Scope $Row.Scope -Status 'Reverted' -Detail 'Value removed'
}
else {
Add-Outcome -Setting $Row.Setting -Scope $Row.Scope -Status 'Skipped' -Detail 'WhatIf'
}
return
}
$value = $r.Value
# JSON round-trips MultiString and Binary as arrays; make sure they still
# are, and that Binary is a real byte array rather than Int64s.
if ($type -eq 'MultiString') { $value = @($value) }
if ($type -eq 'Binary') { $value = [byte[]]@($value) }
if (-not (Test-Path $path)) {
if ($PSCmdlet.ShouldProcess("$path\$name", "Restore -> $value")) {
New-Item -Path $path -Force | Out-Null
Set-ItemProperty -Path $path -Name $name -Value $value -Type $type
Add-Outcome -Setting $Row.Setting -Scope $Row.Scope -Status 'Reverted' -Detail "Restored to $value"
}
else {
Add-Outcome -Setting $Row.Setting -Scope $Row.Scope -Status 'Skipped' -Detail 'WhatIf'
}
return
}
$current = Get-ItemProperty -Path $path -Name $name -ErrorAction SilentlyContinue
if ($current) {
$same = $false
if ($type -eq 'MultiString' -or $type -eq 'Binary') {
$same = ((@($current.$name) -join ',') -eq (@($value) -join ','))
}
else {
$same = ($current.$name -eq $value)
}
if ($same) {
Add-Outcome -Setting $Row.Setting -Scope $Row.Scope -Status 'Already reverted' -Detail "Already $value"
return
}
}
if ($PSCmdlet.ShouldProcess("$path\$name", "Restore -> $value")) {
Set-ItemProperty -Path $path -Name $name -Value $value -Type $type
Add-Outcome -Setting $Row.Setting -Scope $Row.Scope -Status 'Reverted' -Detail "Restored to $value"
}
else {
Add-Outcome -Setting $Row.Setting -Scope $Row.Scope -Status 'Skipped' -Detail 'WhatIf'
}
}
function Revert-PowerSchemeChange {
param($Row)
$r = $Row.Revert
if ($null -eq $r.Value) {
Add-Outcome -Setting $Row.Setting -Scope $Row.Scope -Status 'Cannot revert' `
-Detail 'Previous value was hidden or absent; no index to write back'
return $false
}
$verb = '/setacvalueindex'
if ($r.Rail -eq 'DC') { $verb = '/setdcvalueindex' }
if (-not $PSCmdlet.ShouldProcess("$($r.Scheme) ($($r.Rail))", "Restore -> $($r.Value)")) {
Add-Outcome -Setting $Row.Setting -Scope $Row.Scope -Status 'Skipped' -Detail 'WhatIf'
return $false
}
powercfg $verb $r.Scheme $r.SubGroup $r.Setting $r.Value | Out-Null
if ($LASTEXITCODE -ne 0) {
Add-Outcome -Setting $Row.Setting -Scope $Row.Scope -Status 'Failed' -Detail "powercfg exit $LASTEXITCODE"
return $false
}
Add-Outcome -Setting $Row.Setting -Scope $Row.Scope -Status 'Reverted' -Detail "Restored to $($r.Value)"
return $true
}
function Revert-NetAdapterAdvancedChange {
param($Row)
$r = $Row.Revert
$prop = Get-NetAdapterAdvancedProperty -Name $r.Adapter -AllProperties -ErrorAction SilentlyContinue |
Where-Object { $_.RegistryKeyword -eq $r.Keyword } | Select-Object -First 1
if (-not $prop) {
Add-Outcome -Setting $Row.Setting -Scope $Row.Scope -Status 'Cannot revert' `
-Detail 'Adapter or keyword no longer present'
return $false
}
if ("$($prop.RegistryValue)" -eq "$($r.Value)") {
Add-Outcome -Setting $Row.Setting -Scope $Row.Scope -Status 'Already reverted' -Detail "Already $($r.Value)"
return $false
}
if (-not $PSCmdlet.ShouldProcess($r.Adapter, "$($r.Keyword) -> $($r.Value) (resets adapter)")) {
Add-Outcome -Setting $Row.Setting -Scope $Row.Scope -Status 'Skipped' -Detail 'WhatIf'
return $false
}
try {
Set-NetAdapterAdvancedProperty -Name $r.Adapter -RegistryKeyword $r.Keyword `
-RegistryValue "$($r.Value)" -ErrorAction Stop
Add-Outcome -Setting $Row.Setting -Scope $Row.Scope -Status 'Reverted' -Detail "Restored to $($r.Value)"
return $true
}
catch {
Add-Outcome -Setting $Row.Setting -Scope $Row.Scope -Status 'Failed' -Detail $_.Exception.Message
return $false
}
}
function Revert-VpnConnectionChange {
param($Row)
$r = $Row.Revert
# Do not call this $args -- that is an automatic variable.
$vpnArgs = @{ Name = $r.Name; Force = $true }
if ($r.AllUser) { $vpnArgs['AllUserConnection'] = $true }
$vpn = $null
if ($r.AllUser) { $vpn = Get-VpnConnection -Name $r.Name -AllUserConnection -ErrorAction SilentlyContinue }
else { $vpn = Get-VpnConnection -Name $r.Name -ErrorAction SilentlyContinue }
if (-not $vpn) {
Add-Outcome -Setting $Row.Setting -Scope $Row.Scope -Status 'Cannot revert' -Detail 'Connection no longer exists'
return
}
$prop = $r.Property
if ($vpn.PSObject.Properties.Name -notcontains $prop) {
Add-Outcome -Setting $Row.Setting -Scope $Row.Scope -Status 'Cannot revert' -Detail "No '$prop' property"
return
}
if ($prop -eq 'IdleDisconnectSeconds') {
$want = [uint32]$r.Value
if ($vpn.IdleDisconnectSeconds -eq $want) {
Add-Outcome -Setting $Row.Setting -Scope $Row.Scope -Status 'Already reverted' -Detail "Already $want"
return
}
if (-not $PSCmdlet.ShouldProcess($Row.Scope, "IdleDisconnectSeconds -> $want")) {
Add-Outcome -Setting $Row.Setting -Scope $Row.Scope -Status 'Skipped' -Detail 'WhatIf'
return
}
Set-VpnConnection @vpnArgs -IdleDisconnectSeconds $want
Add-Outcome -Setting $Row.Setting -Scope $Row.Scope -Status 'Reverted' -Detail "Restored to $want"
return
}
if ($prop -eq 'RememberCredential') {
$want = [bool]$r.Value
if ([bool]$vpn.RememberCredential -eq $want) {
Add-Outcome -Setting $Row.Setting -Scope $Row.Scope -Status 'Already reverted' -Detail "Already $want"
return
}
if (-not $PSCmdlet.ShouldProcess($Row.Scope, "RememberCredential -> $want")) {
Add-Outcome -Setting $Row.Setting -Scope $Row.Scope -Status 'Skipped' -Detail 'WhatIf'
return
}
Set-VpnConnection @vpnArgs -RememberCredential $want
Add-Outcome -Setting $Row.Setting -Scope $Row.Scope -Status 'Reverted' `
-Detail "Restored to $want (already-cached credentials are not cleared)"
return
}
Add-Outcome -Setting $Row.Setting -Scope $Row.Scope -Status 'Cannot revert' -Detail "Unhandled property '$prop'"
}
function Revert-FirewallSettingChange {
param($Row)
$r = $Row.Revert
if ($r.Property -ne 'MaxSAIdleTimeSeconds') {
Add-Outcome -Setting $Row.Setting -Scope $Row.Scope -Status 'Cannot revert' -Detail "Unhandled property '$($r.Property)'"
return
}
try {
$current = (Get-NetFirewallSetting -PolicyStore ActiveStore -ErrorAction Stop).MaxSAIdleTimeSeconds
if ("$current" -eq "$($r.Value)") {
Add-Outcome -Setting $Row.Setting -Scope $Row.Scope -Status 'Already reverted' -Detail "Already $($r.Value)"
return
}
if (-not $PSCmdlet.ShouldProcess('Global IPsec settings', "MaxSAIdleTimeSeconds -> $($r.Value)")) {
Add-Outcome -Setting $Row.Setting -Scope $Row.Scope -Status 'Skipped' -Detail 'WhatIf'
return
}
Set-NetFirewallSetting -MaxSAIdleTimeSeconds $r.Value -ErrorAction Stop
Add-Outcome -Setting $Row.Setting -Scope $Row.Scope -Status 'Reverted' -Detail "Restored to $($r.Value)"
}
catch {
Add-Outcome -Setting $Row.Setting -Scope $Row.Scope -Status 'Failed' -Detail $_.Exception.Message
}
}
function Revert-PbkBackupChange {
param($Row, [hashtable]$DoneBackups)
$r = $Row.Revert
# Several rows share one backup; restore it once.
$key = "$($r.Path)|$($r.Backup)"
if ($DoneBackups.ContainsKey($key)) {
Add-Outcome -Setting $Row.Setting -Scope $Row.Scope -Status 'Already reverted' -Detail 'Covered by phonebook restore'
return
}
if (-not (Test-Path -LiteralPath $r.Backup)) {
Add-Outcome -Setting $Row.Setting -Scope $Row.Scope -Status 'Cannot revert' `
-Detail "Backup missing: $($r.Backup)"
return
}
if (-not $PSCmdlet.ShouldProcess($r.Path, "Restore phonebook from $($r.Backup)")) {
Add-Outcome -Setting $Row.Setting -Scope $Row.Scope -Status 'Skipped' -Detail 'WhatIf'
return
}
# Keep a copy of the current state before overwriting it, so this is itself
# undoable.
$preRevert = "$($r.Path).pre-revert-$(Get-Date -Format 'yyyyMMdd-HHmmss').bak"
Copy-Item -LiteralPath $r.Path -Destination $preRevert -Force -ErrorAction SilentlyContinue
Copy-Item -LiteralPath $r.Backup -Destination $r.Path -Force
$DoneBackups[$key] = $true
Add-Outcome -Setting $Row.Setting -Scope $Row.Scope -Status 'Reverted' `
-Detail "Phonebook restored (pre-revert copy: $preRevert)"
}
# =============================================================================
# Execute
# =============================================================================
Write-Host ''
Write-Host '--- Reverting ----------------------------------------------------------' -ForegroundColor Cyan
$powerSchemeTouched = $false
$adapterWasReset = $false
$doneBackups = @{}
# NetAdapterPM changes are collected per adapter so each NIC is written (and
# therefore reset) at most once, instead of once per property.
$pmPlan = @{}
foreach ($row in $queue) {
$kind = $row.Revert.Kind
try {
switch ($kind) {
'Registry' {
Revert-RegistryChange -Row $row
}
'PowerScheme' {
if (Revert-PowerSchemeChange -Row $row) { $powerSchemeTouched = $true }
}
'NetAdapterAdvanced' {
if (Revert-NetAdapterAdvancedChange -Row $row) { $adapterWasReset = $true }
}
'VpnConnection' {
Revert-VpnConnectionChange -Row $row
}
'PbkBackup' {
Revert-PbkBackupChange -Row $row -DoneBackups $doneBackups
}
'FirewallSetting' {
Revert-FirewallSettingChange -Row $row
}
'NetAdapterPM' {
$adapter = $row.Revert.Adapter
if (-not $pmPlan.ContainsKey($adapter)) {
$pmPlan[$adapter] = [System.Collections.Generic.List[object]]::new()
}
$pmPlan[$adapter].Add($row)
}
default {
Add-Outcome -Setting $row.Setting -Scope $row.Scope -Status 'Cannot revert' -Detail "Unknown kind '$kind'"
}
}
}
catch {
Add-Outcome -Setting $row.Setting -Scope $row.Scope -Status 'Failed' -Detail $_.Exception.Message
}
}
# Now apply the batched adapter power-management reverts.
foreach ($adapter in $pmPlan.Keys) {
$pm = Get-NetAdapterPowerManagement -Name $adapter -ErrorAction SilentlyContinue
if (-not $pm) {
foreach ($row in $pmPlan[$adapter]) {
Add-Outcome -Setting $row.Setting -Scope $row.Scope -Status 'Cannot revert' -Detail 'Adapter no longer present'
}
continue
}
$available = $pm.PSObject.Properties.Name
$dirty = $false
$applied = [System.Collections.Generic.List[object]]::new()
foreach ($row in $pmPlan[$adapter]) {
$prop = $row.Revert.Property
$want = "$($row.Revert.Value)"
if ($available -notcontains $prop) {
Add-Outcome -Setting $row.Setting -Scope $row.Scope -Status 'Cannot revert' -Detail "No '$prop' property"
continue
}
if ("$($pm.$prop)" -eq $want) {
Add-Outcome -Setting $row.Setting -Scope $row.Scope -Status 'Already reverted' -Detail "Already $want"
continue
}
if (-not $PSCmdlet.ShouldProcess($adapter, "$prop -> $want (resets adapter)")) {
Add-Outcome -Setting $row.Setting -Scope $row.Scope -Status 'Skipped' -Detail 'WhatIf'
continue
}
$pm.$prop = $want
$applied.Add([pscustomobject]@{ Row = $row; Property = $prop; Value = $want })
$dirty = $true
}
if (-not $dirty) { continue }
try {
Set-NetAdapterPowerManagement -InputObject $pm -ErrorAction Stop
$adapterWasReset = $true
foreach ($a in $applied) {
Add-Outcome -Setting $a.Row.Setting -Scope $a.Row.Scope -Status 'Reverted' -Detail "Restored to $($a.Value)"
}
}
catch {
foreach ($a in $applied) {
Add-Outcome -Setting $a.Row.Setting -Scope $a.Row.Scope -Status 'Failed' -Detail $_.Exception.Message
}
}
}
# powercfg writes only take effect on the active scheme once it is re-applied.
if ($powerSchemeTouched -and $PSCmdlet.ShouldProcess('Active power scheme', 'Re-apply')) {
powercfg /setactive SCHEME_CURRENT | Out-Null
}
# =============================================================================
# Report
# =============================================================================
Write-Host ''
$outcomes | Format-Table -AutoSize
$counts = $outcomes | Group-Object Status | Sort-Object Name
Write-Host '--- Summary ------------------------------------------------------------' -ForegroundColor Cyan
foreach ($c in $counts) {
$colour = 'Gray'
switch ($c.Name) {
'Reverted' { $colour = 'Green' }
'Already reverted' { $colour = 'DarkGray' }
'Cannot revert' { $colour = 'Yellow' }
'Failed' { $colour = 'Red' }
'Skipped' { $colour = 'DarkGray' }
}
Write-Host (" {0,-18} {1}" -f $c.Name, $c.Count) -ForegroundColor $colour
}
if ($adapterWasReset) {
Write-Host ''
Write-Host 'A network adapter was reset - reconnect any VPN that dropped.' -ForegroundColor Yellow
}
$cannot = @($outcomes | Where-Object { $_.Status -eq 'Cannot revert' })
if ($cannot.Count) {
Write-Host ''
Write-Host 'Some items could not be reverted automatically:' -ForegroundColor Yellow
$cannot | Select-Object Setting, Scope, Detail | Format-Table -AutoSize
}
$failed = @($outcomes | Where-Object { $_.Status -eq 'Failed' })
if ($failed.Count) {
Write-Host 'Some items failed. Details above. The changelog is unmodified, so you' -ForegroundColor Red
Write-Host 'can safely re-run this script after fixing the cause.' -ForegroundColor Red
}
Write-Host ''
Write-Host 'Manual follow-ups this script cannot do for you:' -ForegroundColor DarkGray
Write-Host ' * If you ticked "Connect automatically" (#Requires -RunAsAdministrator
#Requires -Version 5.1
<#
.SYNOPSIS
Configures Windows so built-in (RAS) VPN connections survive lock, idle, and
Modern Standby sleep. Version 3 -- no scheduled task, no installed script.
.DESCRIPTION
Idempotent. Reads current state, changes only what differs, and reports
'Changed' vs 'Already set' per item. Safe to re-run; a no-op second run will
not bounce the network adapters.
Everything this script does is a Windows setting. Nothing is installed and
nothing is left running. Every change is written to a JSON changelog that
Revert-VpnSleepSettings.ps1 can replay backwards.
WHAT IT SETS
1. Networking connectivity in Standby -> Enable, on every power scheme,
both AC and DC, plus the Group Policy value so a plan change or GP
refresh does not silently revert it.
2. Wireless Adapter Power Saving Mode -> Maximum Performance.
3. USB selective suspend -> off (matters when the NIC is in a dock).
4. NIC "Allow the computer to turn off this device" -> off, and wake
arming on (magic packet, pattern match, ARP + NS offload) so the NIC
holds its link and address in standby rather than going dark.
5. NIC driver power-saving properties -> off (Green Ethernet, EEE, ULP,
Wi-Fi PowerSaveMode, MIMO power save). This is the layer that actually
kills the link; the Device Manager checkbox in 4 does not cover it.
6. RasMan KeepRasConnections -> 1, so the tunnel survives logoff.
7. IKEv2 MOBIKE network outage tolerance -> 1800s in rasphone.pbk. This is
what lets a tunnel ride out the network gap that sleep creates.
8. VPN IdleDisconnectSeconds -> 14400 (4 hours) and credential caching on.
Items 7 and 8, and the redial keys, are all rasphone.pbk settings and are
applied to EVERY VPN profile by default. Narrow that with -VpnName or
-ConnectedOnly if you need to.
9. TCP KeepAliveTime -> 10 min, so sessions running INSIDE the tunnel
(RDP, SSH, SQL) do not die during a long idle window.
10. Always On (RasMan auto-trigger) on ONE profile -- the connected VPN by
default, or whichever you pick when that is ambiguous. On by default;
turn it off with -SkipAlwaysOn. See ALWAYS ON below. Unlike everything
above, Windows permits this on exactly one profile per machine.
WHAT IT DOES NOT DO
* No scheduled task. No installed script. No background process.
* Does not touch hibernate, Fast Startup, or hybrid sleep -- reports only.
No VPN tunnel survives hibernate or a Fast Startup shutdown; that is a
protocol reality, not a setting.
* Does not stop the machine from sleeping.
.PARAMETER IdleDisconnectSeconds
RAS client idle timeout, in seconds. Default 14400 (4 hours). 0 disables idle
disconnect entirely. This is a phonebook key, so it is applied to every VPN
profile by default; see -ConnectedOnly. Read NOTES before trusting it:
necessary, not sufficient.
.PARAMETER NetworkOutageTime
Seconds of network outage an IKEv2 tunnel tolerates via MOBIKE before tearing
down. Default 1800 (30 min), which is the documented maximum. 0 leaves it alone.
.PARAMETER VpnName
Configure only these connection names. Connected state is ignored -- naming
a profile is taken as knowing what you want.
.PARAMETER ConnectedOnly
Narrow the phonebook changes to the VPN connected right now, instead of all
of them.
HOW THE TARGET LIST IS CHOSEN:
1. -VpnName given -> exactly those profiles.
2. -ConnectedOnly given -> the connected VPN. If none is connected you get
a numbered picker, or nothing changes when the
session cannot prompt.
3. Default -> EVERY VPN profile.
Default 3 is deliberate. Every per-connection setting this script writes is
a rasphone.pbk key, IdleDisconnectSeconds included -- Set-VpnConnection just
writes that into the same phonebook. Phonebook keys are inert configuration:
they cost nothing on a disconnected profile, and setting them everywhere
means a VPN you dial next month is already correct. Narrowing by default
would silently leave your other profiles wrong.
Connected state still matters, but only for Always On, which Windows permits
on exactly one profile. That target is resolved separately and this switch
does not affect it -- see -SkipAlwaysOn.
.PARAMETER Silent
Never prompt. Aliased as -NoPrompt. For unattended or scheduled runs: where
the script would put a choice to the user it warns and takes the documented
non-interactive path instead. Suppresses both the VPN picker and the
Always On picker.
.PARAMETER AlwaysOnVpnName
Enable Always On / auto-trigger for this connection by writing the RasMan
auto-trigger values. Opt-in on purpose -- ticking "Connect automatically" in
Settings does the same thing with no registry edit, and is the supported
route. Without this parameter the script only reports the current state.
.PARAMETER SkipAlwaysOn
Do not configure Always On. Report its current state and move on.
Always On is ON by default. Unlike the phonebook settings it cannot be
applied to every profile -- Windows permits exactly one Always On profile
per machine -- so the script resolves a single target, strongest signal
first:
1. The only currently CONNECTED VPN.
2. The profile that already holds the Always On slot (refresh, not move).
3. The only VPN connection on the machine.
4. Otherwise: a numbered picker, so you choose. Under -Silent or with no
console it warns and leaves Always On alone.
There is deliberately no automatic tie-break past rung 3: arming the wrong
profile silently disarms the right one. When rung 1 does displace an
existing holder, the script says which profile it displaced.
Note there is no such thing as a "currently selected" VPN to read. Windows
keeps no documented default-entry or last-used pointer for VPN profiles;
rasphone.pbk has no default-entry concept and the Settings app persists no
selection. Connected state is the only real signal available.
Always On needs RasMan restarted to take effect -- see -RestartRasMan, or
reboot.
.PARAMETER TcpKeepAliveMinutes
Sets HKLM TCP KeepAliveTime. Default 10. 0 leaves it alone. Machine-wide.
.PARAMETER SetUnattendedSleepTimeout
Seconds for "System unattended sleep timeout" (Windows default 120).
0 means leave alone. Only relevant on S3 machines.
.PARAMETER AllowWirelessPowerSavingOnBattery
Leave Wireless Adapter Power Saving Mode alone on DC. Forcing Maximum
Performance on battery is a real battery cost.
.PARAMETER SkipPolicyKey
Do not write the HKLM\SOFTWARE\Policies standby-networking value.
.PARAMETER SkipVpnIdle
Leave IdleDisconnectSeconds and credential caching alone.
.PARAMETER SkipRasphone
Leave rasphone.pbk alone. You lose MOBIKE / NetworkOutageTime, which is most
of the sleep survival story.
.PARAMETER SkipAdapterPowerManagement
Leave NIC power management and wake arming alone. Use this if you cannot
tolerate the brief adapter reset that changing it causes (it drops live VPN
sessions).
.PARAMETER SkipAdapterAdvancedProperties
Leave NIC driver advanced properties alone. Also causes adapter resets.
.PARAMETER LogPath
Directory for the JSON changelog. Default: the script's own directory.
Keep this file -- Revert-VpnSleepSettings.ps1 needs it.
.EXAMPLE
.\Set-VpnSleepSettings.ps1 -WhatIf
Report what would change, and what this machine is actually capable of,
without changing anything. Run this first.
.EXAMPLE
.\Set-VpnSleepSettings.ps1
Configures EVERY VPN profile: 4-hour idle timeout, 8-hour sleep tolerance,
credential caching, redial. No task installed.
.EXAMPLE
.\Set-VpnSleepSettings.ps1 -ConnectedOnly
Same, but only for the VPN connected right now.
.EXAMPLE
.\Set-VpnSleepSettings.ps1 -AlwaysOnVpnName 'Corp VPN'
Same, plus enable native auto-reconnect for 'Corp VPN'.
.EXAMPLE
.\Set-VpnSleepSettings.ps1 -RestartRasMan
The normal run, plus restart RasMan so Always On takes effect immediately
instead of at the next reboot.
.EXAMPLE
.\Set-VpnSleepSettings.ps1 -SkipAlwaysOn
Phonebook settings on every VPN, but leave Always On exactly as it is.
.EXAMPLE
.\Set-VpnSleepSettings.ps1 -SkipAdapterPowerManagement -SkipAdapterAdvancedProperties
Everything that does not reset a network adapter. Safe while connected.
.NOTES
Requires elevation. Parses English powercfg output.
RECONNECT WITHOUT A WATCHDOG -- WHAT ACTUALLY WORKS ON EVERY CONNECTION
There are two native reconnect mechanisms and they are not equivalent.
1. AUTO-REDIAL (rasphone.pbk) -- works on ALL connections.
RedialOnLinkFailure, RedialAttempts and RedialSeconds are per-profile
phonebook settings. This script sets them on every connection it finds.
They are absent from the modern Settings app; the phonebook is where they
live. Worth checking ncpa.cpl > connection > Properties > Options, which
historically exposes the same redial fields.
Caveat, stated plainly: redial is best-effort. It fires on link failure.
It is not a guarantee, and reports of it not firing in every scenario are
common. It is still the right first move because it costs nothing and
covers every profile.
2. ALWAYS ON (RasMan auto-trigger) -- ONE connection per machine, only.
Reconnects on user sign-in, network change, and device screen on.
Microsoft's documentation is explicit: with multiple Always On profiles
"only one profile, and therefore only one user, is able to use the Always
On triggers." AutoTriggerProfileEntryName is a single REG_SZ, not a list.
So this cannot be applied to all your VPNs. Pick your most important one
and pass -AlwaysOnVpnName.
It is also not in the normal UI for hand-made profiles. Always On is set
through the VPNv2 CSP / ProfileXML or Add-VpnConnection -AlwaysOn at
creation time. Set-VpnConnection has no -AlwaysOn parameter. The "Let apps
automatically use this VPN connection" checkbox in Settings only chooses
which already-Always-On profile is active; it does not add Always On to a
profile that lacks it. If you went looking for a checkbox and could not
find one, that is why.
Microsoft also notes auto-trigger breaks if Folder Redirection for AppData
is enabled, since that relocates rasphone.pbk.
Both mechanisms reconnect; neither prevents the drop. You will still see a
brief gap on wake. Both need saved credentials, which is what section 8's
credential caching is for.
WHY IdleDisconnectSeconds IS NOT ENOUGH
Three timers outrank the RAS client idle timer and none are settable here:
1. The IPsec quick-mode SA idle timeout is hard-coded in vpnike.dll
(roughly 5 minutes) and is NOT influenced by IdleDisconnectSeconds.
Microsoft has confirmed this. The client keeps showing "Connected"
while the server has already processed a Delete SA.
2. Server side wins. RRAS Set-VpnServerConfiguration -IdleDisconnectSeconds,
the NPS network policy Idle-Timeout / Session-Timeout, or your
appliance's own config will cut you off regardless of anything here.
3. NAT and firewall state for UDP 4500 between you and the gateway
typically expires in 30 seconds to 5 minutes.
Correcting something I would otherwise have implied: THERE IS NO NATIVE
KEEPALIVE ON THE WINDOWS VPN CLIENT.
* IKEv2 DPD is not implemented on this code path at all. MS-IKEE
Appendix A, product behaviour note <33>: "Dead Peer Detection is not
implemented on Windows 8 and later for IKEv2-based VPN (that is, VPN
Reconnect)." DPD on Windows is IKEv1 and server-to-server only. Do not
expect liveness probes to hold your tunnel open.
* The NAT-T keepalive on UDP 4500 IS sent, every 20 seconds, fixed and
not configurable (MS-IKEE note <14>). Good news: item 3 above is
therefore handled for you. But it is not IPsec-protected data, so it
does NOT reset the quick-mode SA idle timer. That is precisely how you
end up with a live NAT binding and a dead child SA at 300 seconds.
* Item 1 is documented in MS-IKEE 3.5.2 (5 minutes, or 1 minute with the
NLBS_PRESENT vendor ID) and is not reachable from any published knob.
So with no scheduled task, item 1 is your binding constraint and section 5b
is a long shot at it. If a silent multi-hour idle window drops you, the fix
is traffic, and the script-free options are:
* Map a network drive over the tunnel. SMB2 sends its own echo requests
and keeps the path warm for free. This is the best of them.
* Raise the idle timeout on the server (netsh ras set ikev2connection
idletimeout) or in the NPS network policy.
* Enable DPD / keepalives on the VPN gateway so the SERVER probes you.
Server-to-server DPD is implemented even though the client side is not.
See the "outside this script" list the script prints when it finishes.
WHAT SURVIVES WHAT
Lock (workstation locked, user still logged on) survives, reliably
Modern Standby within NetworkOutageTime usually survives
Modern Standby beyond NetworkOutageTime drops; Always On re-dials
S3 sleep drops; the NIC is off
Hibernate (S4) drops, always
Fast Startup shutdown drops, always
Note the second row now reads "within NetworkOutageTime" rather than
"under 30 minutes". The 1800-second ceiling repeated all over the internet
is one MVP script's ValidateSet, not a platform limit -- the VPNv2 CSP
documents 0 to 4294967295 seconds and Microsoft recommends up to 28800
(8 hours). This script defaults to 28800, so an overnight sleep is now
inside the window rather than outside it. The trade-off is that a genuinely
dead tunnel also lingers that long before the stack is told.
Check your hardware with: powercfg /a
"Standby (S0 Low Power Idle) Network Connected" is the good outcome. If you
only have "Standby (S3)", the standby-networking settings here cannot help
and Always On's reconnect is your whole strategy.
REVERT
Use Revert-VpnSleepSettings.ps1 against the JSON changelog this script writes.
Version: 3.0
Requires: Windows 10 1607+ / Windows 11, PowerShell 5.1+
#>
[CmdletBinding(SupportsShouldProcess)]
param(
[ValidateRange(0, 4294967295)]
[uint32]$IdleDisconnectSeconds = 14400,
# The VPNv2 CSP documents this as range 0-4294967295 seconds, with Microsoft
# RECOMMENDING 0-28800 (8 hours). The 1800 ceiling widely repeated online
# comes from one MVP script's own ValidateSet, not from the platform.
# Default here is 28800 -- 8 hours of outage tolerance, which is what makes
# an overnight sleep survivable rather than a lunch break.
[ValidateRange(0, 28800)]
[int]$NetworkOutageTime = 28800,
# Attempt to raise the global IPsec SA idle time. See notes: this is the WFP
# global default and may not reach the RasMan tunnel policy, but it is
# documented, cheap, and the only sanctioned lever anywhere near the
# hard-coded 300s quick-mode timer. Range 300-3600. 0 = leave alone.
[ValidateRange(0, 3600)]
[int]$MaxSAIdleTimeSeconds = 3600,
# Auto-trigger changes need RasMan restarted. Restart-Service does not work
# on it; the only reliable way is killing its PID, which takes down the
# shared svchost -k netsvcs group with it. Opt-in for that reason.
[switch]$RestartRasMan,
[ValidateRange(0, 99)]
[int]$RedialAttempts = 99,
[ValidateRange(1, 600)]
[int]$RedialSeconds = 15,
[switch]$SkipRedial,
[string[]]$VpnName,
# Narrow the phonebook changes to the VPN connected right now. Off by
# default: phonebook keys are inert on a disconnected profile, so there is
# no reason not to set them everywhere.
[switch]$ConnectedOnly,
[string]$AlwaysOnVpnName,
# Do not configure Always On at all -- report its current state and move on.
# Always On is ON by default: the script resolves a target itself (connected
# VPN first, then the current holder, then a lone connection) and asks when
# that is ambiguous.
[switch]$SkipAlwaysOn,
# Never prompt for anything. Use for unattended or scheduled runs: when a
# choice would otherwise be put to the user, the script warns and takes the
# documented non-interactive path instead.
[Alias('NoPrompt')]
[switch]$Silent,
# Do not clear the screen on start. Use when you are capturing output or
# running this from another script.
[switch]$NoClear,
[ValidateRange(0, 1440)]
[int]$TcpKeepAliveMinutes = 10,
[ValidateRange(0, 86400)]
[int]$SetUnattendedSleepTimeout = 0,
[switch]$AllowWirelessPowerSavingOnBattery,
[switch]$SkipPolicyKey,
[switch]$SkipVpnIdle,
[switch]$SkipRasphone,
[switch]$SkipAdapterPowerManagement,
[switch]$SkipAdapterAdvancedProperties,
[string]$LogPath = $PSScriptRoot
)
Set-StrictMode -Version Latest
$ErrorActionPreference = 'Stop'
# Start from a clean screen so the results table is the only thing on it.
# Guarded: Clear-Host is meaningless, and can throw or emit stray escape codes,
# when output is redirected or the host has no real console.
if (-not $NoClear -and [Environment]::UserInteractive) {
try { Clear-Host } catch { }
}
# --- Identifiers -------------------------------------------------------------
$StandbyNetworkSetting = 'F15576E8-98B7-4186-B944-EAFA664402D9' # 0=Disable 1=Enable 2=Managed by Windows
$SleepSubGroup = '238C9FA8-0AAD-41ED-83F4-97BE242C8F20' # SUB_SLEEP
$UnattendedSleepTimeout = '7BC4A2F9-D8FC-4469-B07B-33EB785AACA0'
$WirelessSubGroup = '19CBB8FA-5279-450E-9FAC-8A3D5FEDD0C1'
$WirelessPowerSaving = '12BBEBE6-58D6-4636-95BB-3217EF867C1A' # 0=Max Perf .. 3=Max Power Saving
$UsbSubGroup = '2A737441-1930-4402-8D77-B2BEBBA308A3' # SUB_USB
$UsbSelectiveSuspend = '48E6B7A6-50F5-4782-A5D4-53BB8F07E226' # 0=Disabled
$RasManParameters = 'HKLM:\SYSTEM\CurrentControlSet\Services\RasMan\Parameters'
$RasManConfig = 'HKLM:\SYSTEM\CurrentControlSet\Services\RasMan\Config'
$TcpipParameters = 'HKLM:\SYSTEM\CurrentControlSet\Services\Tcpip\Parameters'
$PowerPolicyRoot = 'HKLM:\SOFTWARE\Policies\Microsoft\Power\PowerSettings'
$PowerControl = 'HKLM:\SYSTEM\CurrentControlSet\Control\Power'
$AllUserPbk = 'C:\ProgramData\Microsoft\Network\Connections\Pbk\rasphone.pbk'
$UserPbk = Join-Path $env:APPDATA 'Microsoft\Network\Connections\Pbk\rasphone.pbk'
# NIC *driver* advanced properties that break standby connectivity, keyed by the
# driver registry keyword. Value is the target RegistryValue. Only applied when
# the adapter actually exposes the keyword.
#
# The NDIS standardized keywords (*WakeOnMagicPacket, *PMARPOffload,
# *SelectiveSuspend and friends) are deliberately NOT here -- section 4 sets
# those through Get/Set-NetAdapterPowerManagement, which writes the same
# keywords. Listing them twice would just reset the adapter twice.
$AdapterKeywords = [ordered]@{
'EnableGreenEthernet' = '0' # Realtek / Intel
'GigaLite' = '0' # Realtek
'EnableDynamicPowerGating' = '0' # Realtek
'AdvancedEEE' = '0' # Intel
'EEELinkAdvertisement' = '0' # Intel
'*EEE' = '0' # generic NDIS, no PM-object equivalent
'ULPMode' = '0' # Intel Ultra Low Power
'ReduceSpeedOnPowerDown' = '0'
'PowerSavingMode' = '0'
'PowerSaveMode' = '0' # Intel Wi-Fi: 0 = No power saving
'MIMOPowerSaveMode' = '0' # 0 = No SMPS
'AutoPowerSaveModeEnabled' = '0'
}
$results = [System.Collections.Generic.List[object]]::new()
function Add-Result {
<#
Records one item for the console table and the JSON changelog.
-Revert carries the machine-readable instructions the revert script needs.
It is only meaningful on 'Changed' rows. Shape depends on Kind:
Registry Path, Name, Type (DWord|String|MultiString), Value, Existed
PowerScheme Scheme, SubGroup, Setting, Rail, Value
NetAdapterPM Adapter, Property, Value
NetAdapterAdvanced Adapter, Keyword, Value
VpnConnection Name, AllUser, Property, Value
PbkBackup Path, Backup
#>
param(
[Parameter(Mandatory)][string]$Setting,
[Parameter(Mandatory)][string]$Scope,
[AllowNull()]$Previous,
[AllowNull()]$Desired,
[Parameter(Mandatory)]
[ValidateSet('Changed', 'Already set', 'Skipped', 'Unsupported', 'Info')]
[string]$Action,
[hashtable]$Revert
)
$prev = '-'
if ($null -ne $Previous -and "$Previous" -ne '') { $prev = "$Previous" }
$want = '-'
if ($null -ne $Desired -and "$Desired" -ne '') { $want = "$Desired" }
$revertPayload = $null
if ($Revert) { $revertPayload = [pscustomobject]$Revert }
$results.Add([pscustomobject]@{
Setting = $Setting
Scope = $Scope
Previous = $prev
Desired = $want
Action = $Action
Revert = $revertPayload
})
}
# =============================================================================
# Preflight: what is this machine actually capable of?
# =============================================================================
Write-Host ''
Write-Host '--- Machine capability -------------------------------------------------' -ForegroundColor Cyan
# powercfg /a prints an "available" block and then a "not available" block.
# Matching the whole output would happily find S3 in the *unavailable* list, so
# split first and only look at what is actually available.
$sleepStates = powercfg /a 2>$null | Out-String
$availableBlock = ($sleepStates -split 'The following sleep states are not available')[0]
$hasS0Network = $availableBlock -match 'S0 Low Power Idle[^\)]*Network Connected'
$hasS0 = $availableBlock -match 'S0 Low Power Idle'
$hasS3 = $availableBlock -match 'Standby \(S3\)'
if ($hasS0Network) {
Write-Host ' Modern Standby with network: supported. A tunnel can survive sleep.' -ForegroundColor Green
Add-Result -Setting 'Sleep capability' -Scope 'Machine' -Previous 'S0 Low Power Idle Network Connected' -Desired $null -Action 'Info'
}
elseif ($hasS0) {
Write-Host ' Modern Standby: supported, but network not listed. Driver dependent.' -ForegroundColor Yellow
Add-Result -Setting 'Sleep capability' -Scope 'Machine' -Previous 'S0 Low Power Idle (no network)' -Desired $null -Action 'Info'
}
elseif ($hasS3) {
Write-Host ' S3 sleep only. No tunnel survives sleep here; the NIC powers off.' -ForegroundColor Red
Add-Result -Setting 'Sleep capability' -Scope 'Machine' -Previous 'S3 only' -Desired $null -Action 'Info'
}
else {
Write-Host ' Sleep capability could not be determined from powercfg /a.' -ForegroundColor Yellow
Add-Result -Setting 'Sleep capability' -Scope 'Machine' -Previous 'Unknown' -Desired $null -Action 'Info'
}
# Report hibernate and Fast Startup; do not change them.
$hibernateOn = $false
try {
$power = Get-ItemProperty $PowerControl -ErrorAction Stop
$powerValues = $power.PSObject.Properties.Name
if (($powerValues -contains 'HibernateEnabled') -and $null -ne $power.HibernateEnabled) {
$hibernateOn = ($power.HibernateEnabled -eq 1)
}
elseif ($powerValues -contains 'HibernateEnabledDefault') {
$hibernateOn = ($power.HibernateEnabledDefault -eq 1)
}
$fastStartup = $false
if ($powerValues -contains 'HiberbootEnabled') {
$fastStartup = ($power.HiberbootEnabled -eq 1)
}
if ($hibernateOn) {
Write-Host ' Hibernate is enabled (left alone). No VPN survives hibernate.' -ForegroundColor DarkGray
}
$hibText = 'Disabled'
if ($hibernateOn) { $hibText = 'Enabled' }
Add-Result -Setting 'Hibernate' -Scope 'Machine' -Previous $hibText -Desired 'unchanged' -Action 'Info'
if ($fastStartup) {
Write-Host ' Fast Startup is enabled (left alone). A shutdown is really a hibernate.' -ForegroundColor DarkGray
}
$fsText = 'Disabled'
if ($fastStartup) { $fsText = 'Enabled' }
Add-Result -Setting 'Fast Startup' -Scope 'Machine' -Previous $fsText -Desired 'unchanged' -Action 'Info'
}
catch {
Add-Result -Setting 'Hibernate / Fast Startup' -Scope 'Machine' -Previous 'unreadable' -Desired $null -Action 'Info'
}
Write-Host ''
# =============================================================================
# Helpers
# =============================================================================
function Get-PowerSchemeGuid {
$out = powercfg /list
($out | Select-String -Pattern 'GUID:\s*([0-9a-fA-F-]{36})' |
ForEach-Object { $_.Matches[0].Groups[1].Value }) | Select-Object -Unique
}
function Get-PowerSettingIndex {
<# Returns @{AC=<int>; DC=<int>} or $null when the setting is hidden/absent. #>
param(
[Parameter(Mandatory)][string]$Scheme,
[Parameter(Mandatory)][string]$SubGroup,
[Parameter(Mandatory)][string]$Setting
)
$out = powercfg /query $Scheme $SubGroup $Setting 2>$null
if ($LASTEXITCODE -ne 0 -or -not $out) { return $null }
$parse = {
param($rail)
$m = @($out | Select-String -Pattern "Current $rail Power Setting Index:\s*0x([0-9a-fA-F]+)")
if ($m.Count) { [Convert]::ToInt64($m[0].Matches[0].Groups[1].Value, 16) } else { $null }
}
$ac = & $parse 'AC'
$dc = & $parse 'DC'
if ($null -eq $ac -and $null -eq $dc) { return $null }
@{ AC = $ac; DC = $dc }
}
function Set-PowerSettingIndex {
<# Sets the named rails only where they differ. Returns $true if anything changed. #>
param(
[Parameter(Mandatory)][string]$Label,
[Parameter(Mandatory)][string]$Scheme,
[Parameter(Mandatory)][string]$SubGroup,
[Parameter(Mandatory)][string]$Setting,
[Parameter(Mandatory)][int]$Value,
[ValidateSet('AC', 'DC', 'Both')][string]$Rails = 'Both'
)
$current = Get-PowerSettingIndex -Scheme $Scheme -SubGroup $SubGroup -Setting $Setting
$changed = $false
$targets = @('AC', 'DC')
if ($Rails -eq 'AC') { $targets = @('AC') }
elseif ($Rails -eq 'DC') { $targets = @('DC') }
foreach ($rail in $targets) {
$previous = $null
if ($current) { $previous = $current[$rail] }
$scope = "$Scheme ($rail)"
if ($previous -eq $Value) {
Add-Result -Setting $Label -Scope $scope -Previous $previous -Desired $Value -Action 'Already set'
continue
}
if (-not $PSCmdlet.ShouldProcess($scope, "$Label -> $Value")) {
Add-Result -Setting $Label -Scope $scope -Previous $previous -Desired $Value -Action 'Skipped'
continue
}
$verb = '/setacvalueindex'
if ($rail -eq 'DC') { $verb = '/setdcvalueindex' }
powercfg $verb $Scheme $SubGroup $Setting $Value | Out-Null
if ($LASTEXITCODE -ne 0) {
Add-Result -Setting $Label -Scope $scope -Previous $previous -Desired $Value -Action 'Unsupported'
continue
}
Add-Result -Setting $Label -Scope $scope -Previous $previous -Desired $Value -Action 'Changed' -Revert @{
Kind = 'PowerScheme'
Scheme = $Scheme
SubGroup = $SubGroup
Setting = $Setting
Rail = $rail
Value = $previous
}
$changed = $true
}
return $changed
}
function Set-RegistryValue {
<# Idempotent registry write with reporting and revert capture. #>
param(
[Parameter(Mandatory)][string]$Label,
[Parameter(Mandatory)][string]$Scope,
[Parameter(Mandatory)][string]$Path,
[Parameter(Mandatory)][string]$Name,
[Parameter(Mandatory)]$Value,
[ValidateSet('DWord', 'String', 'MultiString', 'Binary')][string]$Type = 'DWord'
)
$previous = $null
$existed = $false
if (Test-Path $Path) {
$item = Get-ItemProperty -Path $Path -Name $Name -ErrorAction SilentlyContinue
if ($item) {
$previous = $item.$Name
$existed = $true
}
}
$same = $false
if ($existed) {
if ($Type -eq 'MultiString' -or $Type -eq 'Binary') {
$same = ((@($previous) -join ',') -eq (@($Value) -join ','))
}
else {
$same = ($previous -eq $Value)
}
}
if ($same) {
Add-Result -Setting $Label -Scope $Scope -Previous $previous -Desired $Value -Action 'Already set'
return $false
}
if (-not $PSCmdlet.ShouldProcess("$Path\$Name", "$Label -> $Value")) {
Add-Result -Setting $Label -Scope $Scope -Previous $previous -Desired $Value -Action 'Skipped'
return $false
}
if (-not (Test-Path $Path)) { New-Item -Path $Path -Force | Out-Null }
Set-ItemProperty -Path $Path -Name $Name -Value $Value -Type $Type
$prevDisplay = $previous
if (-not $existed) { $prevDisplay = '(absent)' }
Add-Result -Setting $Label -Scope $Scope -Previous $prevDisplay -Desired $Value -Action 'Changed' -Revert @{
Kind = 'Registry'
Path = $Path
Name = $Name
Type = $Type
Value = $previous
Existed = $existed
}
return $true
}
# =============================================================================
# 1. Unhide "Networking connectivity in Standby"
# powercfg cannot read or write a hidden setting, so this must run first.
# =============================================================================
$attributesKey = "HKLM:\SYSTEM\CurrentControlSet\Control\Power\PowerSettings\$StandbyNetworkSetting"
if (Test-Path $attributesKey) {
Set-RegistryValue -Label 'Standby network setting visibility' -Scope 'Machine' `
-Path $attributesKey -Name 'Attributes' -Value 2 | Out-Null
}
else {
Add-Result -Setting 'Standby network setting visibility' -Scope 'Machine' -Previous $null -Desired 'Visible' -Action 'Unsupported'
}
# =============================================================================
# 2. Power scheme values, every scheme, both rails
# =============================================================================
$schemeChanged = $false
foreach ($scheme in Get-PowerSchemeGuid) {
$schemeChanged = (Set-PowerSettingIndex -Label 'Networking connectivity in Standby' `
-Scheme $scheme -SubGroup SUB_NONE -Setting $StandbyNetworkSetting -Value 1) -or $schemeChanged
$wirelessRails = 'Both'
if ($AllowWirelessPowerSavingOnBattery) { $wirelessRails = 'AC' }
$schemeChanged = (Set-PowerSettingIndex -Label 'Wireless Power Saving Mode' `
-Scheme $scheme -SubGroup $WirelessSubGroup -Setting $WirelessPowerSaving -Value 0 -Rails $wirelessRails) -or $schemeChanged
$schemeChanged = (Set-PowerSettingIndex -Label 'USB selective suspend' `
-Scheme $scheme -SubGroup $UsbSubGroup -Setting $UsbSelectiveSuspend -Value 0) -or $schemeChanged
if ($SetUnattendedSleepTimeout -gt 0) {
$schemeChanged = (Set-PowerSettingIndex -Label 'System unattended sleep timeout' `
-Scheme $scheme -SubGroup $SleepSubGroup -Setting $UnattendedSleepTimeout -Value $SetUnattendedSleepTimeout) -or $schemeChanged
}
}
if ($AllowWirelessPowerSavingOnBattery) {
Add-Result -Setting 'Wireless Power Saving Mode' -Scope 'All schemes (DC)' -Previous $null -Desired 'unchanged' -Action 'Skipped'
}
# powercfg writes take effect on the active scheme only after it is re-applied.
if ($schemeChanged -and $PSCmdlet.ShouldProcess('Active power scheme', 'Re-apply')) {
powercfg /setactive SCHEME_CURRENT | Out-Null
}
# =============================================================================
# 3. Group Policy key for standby networking
# The per-scheme value above is reverted by GP refresh and by power-plan
# changes. The Policies value is what makes it stick.
# =============================================================================
if ($SkipPolicyKey) {
Add-Result -Setting 'GP standby networking' -Scope 'Machine' -Previous $null -Desired 1 -Action 'Skipped'
}
else {
$policyKey = Join-Path $PowerPolicyRoot $StandbyNetworkSetting.ToLower()
Set-RegistryValue -Label 'GP standby networking (AC)' -Scope 'Machine' -Path $policyKey -Name 'ACSettingIndex' -Value 1 | Out-Null
Set-RegistryValue -Label 'GP standby networking (DC)' -Scope 'Machine' -Path $policyKey -Name 'DCSettingIndex' -Value 1 | Out-Null
}
# =============================================================================
# 4. RasMan: keep VPN across logoff
# =============================================================================
Set-RegistryValue -Label 'RasMan KeepRasConnections' -Scope 'Machine' `
-Path $RasManParameters -Name 'KeepRasConnections' -Value 1 | Out-Null
# =============================================================================
# 5. TCP keepalive
# Default is 7,200,000 ms (2 hours). Over a long idle window, sessions inside
# the tunnel die even when the tunnel itself is fine.
# =============================================================================
if ($TcpKeepAliveMinutes -le 0) {
Add-Result -Setting 'TCP KeepAliveTime' -Scope 'Machine' -Previous $null -Desired 'unchanged' -Action 'Skipped'
}
else {
Set-RegistryValue -Label 'TCP KeepAliveTime (ms)' -Scope 'Machine' `
-Path $TcpipParameters -Name 'KeepAliveTime' -Value ($TcpKeepAliveMinutes * 60000) | Out-Null
}
# =============================================================================
# 5b. Global IPsec SA idle time
# A long shot, but a documented and cheap one.
#
# The killer for idle tunnels is the quick-mode (child) SA idle timer.
# Microsoft documents the default in MS-IKEE 3.5.2: 5 minutes, or 1 minute
# when the peer advertises the NLBS_PRESENT vendor ID. On expiry the host
# deletes all SAD state for that SA -- which is exactly the "client still
# says Connected, server already sent Delete SA" symptom.
#
# That value lives in IPSEC_TUNNEL_POLICY2.saIdleTimeout, supplied
# programmatically by vpnike.dll inside RasMan. It is hard-coded there and
# overridable only by registry values Microsoft has never published. Do not
# believe RasMan\Parameters\IdleDisconnectSeconds -- that one circulates
# widely and was refuted in the very thread it came from.
#
# Set-NetFirewallSetting -MaxSAIdleTimeSeconds is the WFP GLOBAL default
# (range 300-3600, default 300). Because a per-policy saIdleTimeout beats a
# global default, this probably does not reach the RasMan tunnel policy.
# But it is documented, reversible, and the only sanctioned lever in the
# area, so it is worth setting and measuring.
# =============================================================================
if ($MaxSAIdleTimeSeconds -le 0) {
Add-Result -Setting 'IPsec MaxSAIdleTimeSeconds' -Scope 'Machine' -Previous $null -Desired 'unchanged' -Action 'Skipped'
}
else {
try {
$currentSaIdle = (Get-NetFirewallSetting -PolicyStore ActiveStore -ErrorAction Stop).MaxSAIdleTimeSeconds
if ("$currentSaIdle" -eq "$MaxSAIdleTimeSeconds") {
Add-Result -Setting 'IPsec MaxSAIdleTimeSeconds' -Scope 'Machine' -Previous $currentSaIdle -Desired $MaxSAIdleTimeSeconds -Action 'Already set'
}
elseif (-not $PSCmdlet.ShouldProcess('Global IPsec settings', "MaxSAIdleTimeSeconds -> $MaxSAIdleTimeSeconds")) {
Add-Result -Setting 'IPsec MaxSAIdleTimeSeconds' -Scope 'Machine' -Previous $currentSaIdle -Desired $MaxSAIdleTimeSeconds -Action 'Skipped'
}
else {
Set-NetFirewallSetting -MaxSAIdleTimeSeconds $MaxSAIdleTimeSeconds -ErrorAction Stop
Add-Result -Setting 'IPsec MaxSAIdleTimeSeconds' -Scope 'Machine' -Previous $currentSaIdle -Desired $MaxSAIdleTimeSeconds -Action 'Changed' -Revert @{
Kind = 'FirewallSetting'
Property = 'MaxSAIdleTimeSeconds'
Value = $currentSaIdle
}
}
}
catch {
Add-Result -Setting 'IPsec MaxSAIdleTimeSeconds' -Scope 'Machine' -Previous 'unreadable' -Desired $MaxSAIdleTimeSeconds -Action 'Unsupported'
}
}
# =============================================================================
# 6. NIC power management and wake arming
# Changing these resets the adapter, which drops live VPN sessions. Every
# change is conditional on the current value actually differing, which is
# what keeps re-runs quiet.
# =============================================================================
$adapterWasReset = $false
if ($SkipAdapterPowerManagement) {
Add-Result -Setting 'NIC power management' -Scope 'All physical adapters' -Previous $null -Desired $null -Action 'Skipped'
}
else {
# 'Not Present' adapters are stale profiles with no backing device; querying
# them just fills $Error.
$adapters = @(Get-NetAdapter -Physical -ErrorAction SilentlyContinue |
Where-Object { $_.Status -ne 'Not Present' })
$pmTargets = [ordered]@{
'AllowComputerToTurnOffDevice' = 'Disabled' # the Device Manager checkbox
'DeviceSleepOnDisconnect' = 'Disabled' # do not power down on link loss
'SelectiveSuspend' = 'Disabled' # USB / dock NICs
'WakeOnMagicPacket' = 'Enabled' # keeps the NIC armed in standby
'WakeOnPattern' = 'Enabled'
'ArpOffload' = 'Enabled' # NIC answers ARP while the CPU sleeps
'NSOffload' = 'Enabled' # same, IPv6 neighbor solicitation
}
foreach ($adapter in $adapters) {
$pm = Get-NetAdapterPowerManagement -Name $adapter.Name -ErrorAction SilentlyContinue
if (-not $pm) {
Add-Result -Setting 'NIC power management' -Scope $adapter.Name -Previous $null -Desired $null -Action 'Unsupported'
continue
}
$available = $pm.PSObject.Properties.Name
$dirty = $false
foreach ($prop in $pmTargets.Keys) {
$want = $pmTargets[$prop]
if ($available -notcontains $prop) {
Add-Result -Setting "NIC $prop" -Scope $adapter.Name -Previous $null -Desired $want -Action 'Unsupported'
continue
}
$previous = "$($pm.$prop)"
if ($previous -eq 'Unsupported' -or $previous -eq '') {
Add-Result -Setting "NIC $prop" -Scope $adapter.Name -Previous $previous -Desired $want -Action 'Unsupported'
continue
}
if ($previous -eq $want) {
Add-Result -Setting "NIC $prop" -Scope $adapter.Name -Previous $previous -Desired $want -Action 'Already set'
continue
}
if (-not $PSCmdlet.ShouldProcess($adapter.Name, "$prop -> $want (resets adapter)")) {
Add-Result -Setting "NIC $prop" -Scope $adapter.Name -Previous $previous -Desired $want -Action 'Skipped'
continue
}
# The cmdlet has no per-property parameters; you mutate the object
# and pipe it back via -InputObject.
$pm.$prop = $want
Add-Result -Setting "NIC $prop" -Scope $adapter.Name -Previous $previous -Desired $want -Action 'Changed' -Revert @{
Kind = 'NetAdapterPM'
Adapter = $adapter.Name
Property = $prop
Value = $previous
}
$dirty = $true
}
if ($dirty) {
try {
Set-NetAdapterPowerManagement -InputObject $pm -ErrorAction Stop
$adapterWasReset = $true
}
catch {
Write-Warning "Could not apply power management to '$($adapter.Name)': $($_.Exception.Message)"
}
}
}
}
# =============================================================================
# 7. NIC driver advanced properties
# This is the layer that actually kills the link in standby. The generic
# "Allow the computer to turn off this device" shim above does not cover it.
# =============================================================================
if ($SkipAdapterAdvancedProperties) {
Add-Result -Setting 'NIC advanced properties' -Scope 'All physical adapters' -Previous $null -Desired $null -Action 'Skipped'
}
else {
$adapters = @(Get-NetAdapter -Physical -ErrorAction SilentlyContinue |
Where-Object { $_.Status -ne 'Not Present' })
foreach ($adapter in $adapters) {
$props = @(Get-NetAdapterAdvancedProperty -Name $adapter.Name -AllProperties -ErrorAction SilentlyContinue)
if (-not $props.Count) { continue }
foreach ($keyword in $AdapterKeywords.Keys) {
$want = $AdapterKeywords[$keyword]
$prop = $props | Where-Object { $_.RegistryKeyword -eq $keyword } | Select-Object -First 1
if (-not $prop) { continue } # driver does not expose it; nothing to report
$previous = "$($prop.RegistryValue)"
# Do not fight a driver that only accepts certain values.
$valid = $null
if ($prop.PSObject.Properties.Name -contains 'ValidRegistryValues') {
$valid = $prop.ValidRegistryValues
}
if ($valid -and @($valid).Count -and (@($valid) -notcontains $want)) {
Add-Result -Setting "NIC $keyword" -Scope $adapter.Name -Previous $previous -Desired $want -Action 'Unsupported'
continue
}
if ($previous -eq $want) {
Add-Result -Setting "NIC $keyword" -Scope $adapter.Name -Previous $previous -Desired $want -Action 'Already set'
continue
}
if (-not $PSCmdlet.ShouldProcess($adapter.Name, "$keyword -> $want (resets adapter)")) {
Add-Result -Setting "NIC $keyword" -Scope $adapter.Name -Previous $previous -Desired $want -Action 'Skipped'
continue
}
try {
Set-NetAdapterAdvancedProperty -Name $adapter.Name -RegistryKeyword $keyword `
-RegistryValue $want -ErrorAction Stop
Add-Result -Setting "NIC $keyword" -Scope $adapter.Name -Previous $previous -Desired $want -Action 'Changed' -Revert @{
Kind = 'NetAdapterAdvanced'
Adapter = $adapter.Name
Keyword = $keyword
Value = $previous
}
$adapterWasReset = $true
}
catch {
Add-Result -Setting "NIC $keyword" -Scope $adapter.Name -Previous $previous -Desired $want -Action 'Unsupported'
}
}
}
}
# =============================================================================
# 8. VPN connections: idle timeout and credential caching
# Must run BEFORE the rasphone.pbk edits: Set-VpnConnection rewrites the same
# file and would clobber them.
# =============================================================================
function Test-CanPrompt {
<# A human is present and we are allowed to bother them. #>
return ([Environment]::UserInteractive -and -not $Silent -and -not $WhatIfPreference)
}
function Select-VpnInteractively {
<#
Multi-select numbered picker. Accepts a single number, a comma or space
separated list, 'A' for all, or 0/Enter for none.
Re-prompts on anything else rather than accepting it. Any invalid token
rejects the WHOLE answer -- silently keeping the good half of "2,9" and
dropping the rest would leave the user believing they picked two.
#>
param(
[Parameter(Mandatory)][array]$Candidates,
[string]$Prompt = 'Which VPN(s) should I configure?',
[int]$MaxAttempts = 5
)
$max = $Candidates.Count
Write-Host ''
Write-Host $Prompt -ForegroundColor Cyan
Write-Host ''
$i = 0
foreach ($c in $Candidates) {
$i++
$scopeTag = 'this user'
if ($c.AllUserConnection) { $scopeTag = 'all users' }
Write-Host (" {0}. {1,-30} {2,-14} {3,-9} {4}" -f `
$i, $c.Name, $c.ConnectionStatus, $c.TunnelType, $scopeTag)
}
Write-Host ' A. All of them'
Write-Host ' 0. None - exit without changing any VPN profile'
Write-Host ''
for ($attempt = 1; $attempt -le $MaxAttempts; $attempt++) {
$answer = "$(Read-Host "Choose 1-$max, a comma-separated list, A, or 0")".Trim()
if ($answer -eq '' -or $answer -eq '0') { return @() }
if ($answer -match '^(a|all)$') { return @($Candidates) }
$picked = [System.Collections.Generic.List[object]]::new()
$bad = [System.Collections.Generic.List[string]]::new()
foreach ($token in @($answer -split '[,\s]+' | Where-Object { $_ })) {
$idx = 0
if ([int]::TryParse($token, [ref]$idx) -and $idx -ge 1 -and $idx -le $max) {
$c = $Candidates[$idx - 1]
$dupe = $picked | Where-Object {
$_.Name -eq $c.Name -and $_.AllUserConnection -eq $c.AllUserConnection
}
if (-not $dupe) { $picked.Add($c) }
}
else {
$bad.Add($token)
}
}
if ($bad.Count) {
Write-Host (" Not valid: {0}. Enter numbers 1-{1}, A, or 0." -f ($bad -join ', '), $max) -ForegroundColor Yellow
continue
}
if (-not $picked.Count) {
Write-Host " Nothing recognised. Enter numbers 1-$max, A, or 0." -ForegroundColor Yellow
continue
}
return @($picked)
}
Write-Warning "No valid choice after $MaxAttempts attempts. Treating that as 'none'."
return @()
}
function Select-SingleVpnInteractively {
<#
Single-select numbered picker, for settings Windows permits on exactly
one profile. Returns one connection object, or $null for skip.
Re-prompts on invalid input.
#>
param(
[Parameter(Mandatory)][array]$Candidates,
[AllowNull()][string]$CurrentHolder,
[string]$Prompt = 'Which VPN should get Always On?',
[int]$MaxAttempts = 5
)
$max = $Candidates.Count
Write-Host ''
Write-Host $Prompt -ForegroundColor Cyan
Write-Host ' Only one connection can hold it.' -ForegroundColor DarkGray
Write-Host ''
$i = 0
foreach ($c in $Candidates) {
$i++
$marks = @()
if ($c.ConnectionStatus -eq 'Connected') { $marks += 'connected' }
if ($CurrentHolder -and $c.Name -eq $CurrentHolder) { $marks += 'current holder' }
$suffix = ''
if ($marks.Count) { $suffix = " <- $($marks -join ', ')" }
Write-Host (" {0}. {1}{2}" -f $i, $c.Name, $suffix)
}
Write-Host ' 0. Skip - leave Always On alone'
Write-Host ''
for ($attempt = 1; $attempt -le $MaxAttempts; $attempt++) {
$answer = "$(Read-Host "Choose 0-$max")".Trim()
if ($answer -eq '' -or $answer -eq '0') { return $null }
$idx = 0
if ([int]::TryParse($answer, [ref]$idx) -and $idx -ge 1 -and $idx -le $max) {
return $Candidates[$idx - 1]
}
Write-Host " '$answer' is not a choice. Enter 1-$max, or 0 to skip." -ForegroundColor Yellow
}
Write-Warning "No valid choice after $MaxAttempts attempts. Always On left unchanged."
return $null
}
function Get-TargetVpnConnection {
$all = @()
$all += @(Get-VpnConnection -ErrorAction SilentlyContinue)
$all += @(Get-VpnConnection -AllUserConnection -ErrorAction SilentlyContinue)
# A connection can appear in both lists on some systems. De-dupe on
# name + scope so we do not process it twice.
$seen = @{}
$unique = foreach ($c in $all) {
$key = "$($c.Name)|$($c.AllUserConnection)"
if ($seen.ContainsKey($key)) { continue }
$seen[$key] = $true
$c
}
$selected = @($unique)
if (-not $selected.Count) {
$script:VpnScopeNote = 'no VPN profiles found'
return @()
}
# 1. Explicit names win outright; connected state is irrelevant.
if ($VpnName) {
$script:VpnScopeNote = 'named only (-VpnName)'
return @($selected | Where-Object { $VpnName -contains $_.Name })
}
# 2. Opt-in narrowing to the live tunnel only.
if ($ConnectedOnly) {
$live = @($selected | Where-Object { $_.ConnectionStatus -eq 'Connected' })
if ($live.Count) {
$script:VpnScopeNote = 'connected only (-ConnectedOnly)'
return $live
}
if (Test-CanPrompt) {
Write-Host ''
Write-Warning '-ConnectedOnly was specified but no VPN is connected.'
$chosen = Select-VpnInteractively -Candidates $selected `
-Prompt 'Which VPN(s) should I configure instead?'
if ($chosen.Count) {
$script:VpnScopeNote = "$($chosen.Count) chosen interactively"
return $chosen
}
Write-Warning 'Nothing selected. No VPN profile will be changed.'
$script:VpnScopeNote = 'none selected'
return @()
}
Write-Warning '-ConnectedOnly was specified but no VPN is connected, and this'
Write-Warning 'session cannot prompt. No VPN profile was changed.'
$script:VpnScopeNote = 'connected only -- none connected'
return @()
}
# 3. Default: every profile.
#
# Everything this script sets per connection is a rasphone.pbk key --
# IdleDisconnectSeconds included, since Set-VpnConnection just writes it
# into the same phonebook. Phonebook keys are inert configuration: they
# cost nothing on a disconnected profile and mean a VPN you dial next
# month is already correct. So there is no reason to narrow this, and
# narrowing it would silently leave other profiles wrong.
#
# Connected state still matters, but only for Always On, which Windows
# permits on exactly one profile. That target is resolved separately.
$script:VpnScopeNote = 'all VPN profiles'
return $selected
}
# Set by Get-TargetVpnConnection so the banner can say how the list was chosen.
$VpnScopeNote = 'all VPN profiles'
$connections = Get-TargetVpnConnection
# Always show what was found. Everything below acts on exactly this list, so it
# should never be a mystery which profiles were touched.
if ($connections.Count) {
Write-Host "--- Will configure: $VpnScopeNote " -ForegroundColor Cyan -NoNewline
Write-Host ('-' * [Math]::Max(1, 52 - $VpnScopeNote.Length)) -ForegroundColor Cyan
$n = 0
foreach ($c in $connections) {
$n++
$scopeTag = 'this user'
if ($c.AllUserConnection) { $scopeTag = 'all users' }
Write-Host (" {0}. {1,-30} {2,-14} {3,-9} {4}" -f `
$n, $c.Name, $c.ConnectionStatus, $c.TunnelType, $scopeTag)
}
Write-Host ''
}
if (-not $connections.Count) {
Write-Warning 'No built-in Windows VPN connections found. Sections 8-10 have nothing to do.'
Write-Warning 'If you use AnyConnect, GlobalProtect, FortiClient, OpenVPN or WireGuard,'
Write-Warning 'their timeouts live in the client -- nothing in this script reaches them.'
Add-Result -Setting 'VPN connections' -Scope 'Machine' -Previous 'none found' -Desired $null -Action 'Info'
}
if ($SkipVpnIdle) {
Add-Result -Setting 'VPN IdleDisconnectSeconds' -Scope 'All connections' -Previous $null -Desired $IdleDisconnectSeconds -Action 'Skipped'
}
else {
foreach ($vpn in $connections) {
$allUserArgs = @{}
$scope = $vpn.Name
if ($vpn.AllUserConnection) {
$allUserArgs = @{ AllUserConnection = $true }
$scope = "$($vpn.Name) (all users)"
}
# Idle timeout
if ($vpn.IdleDisconnectSeconds -eq $IdleDisconnectSeconds) {
Add-Result -Setting 'VPN IdleDisconnectSeconds' -Scope $scope -Previous $vpn.IdleDisconnectSeconds -Desired $IdleDisconnectSeconds -Action 'Already set'
}
elseif (-not $PSCmdlet.ShouldProcess($scope, "IdleDisconnectSeconds -> $IdleDisconnectSeconds")) {
Add-Result -Setting 'VPN IdleDisconnectSeconds' -Scope $scope -Previous $vpn.IdleDisconnectSeconds -Desired $IdleDisconnectSeconds -Action 'Skipped'
}
else {
Set-VpnConnection -Name $vpn.Name -IdleDisconnectSeconds $IdleDisconnectSeconds -Force @allUserArgs
Add-Result -Setting 'VPN IdleDisconnectSeconds' -Scope $scope -Previous $vpn.IdleDisconnectSeconds -Desired $IdleDisconnectSeconds -Action 'Changed' -Revert @{
Kind = 'VpnConnection'
Name = $vpn.Name
AllUser = [bool]$vpn.AllUserConnection
Property = 'IdleDisconnectSeconds'
Value = $vpn.IdleDisconnectSeconds
}
}
# Credential caching -- Always On cannot reconnect without it.
if ($vpn.RememberCredential) {
Add-Result -Setting 'VPN RememberCredential' -Scope $scope -Previous $vpn.RememberCredential -Desired $true -Action 'Already set'
}
elseif (-not $PSCmdlet.ShouldProcess($scope, 'RememberCredential -> True')) {
Add-Result -Setting 'VPN RememberCredential' -Scope $scope -Previous $vpn.RememberCredential -Desired $true -Action 'Skipped'
}
else {
Set-VpnConnection -Name $vpn.Name -RememberCredential $true -Force @allUserArgs
Add-Result -Setting 'VPN RememberCredential' -Scope $scope -Previous $vpn.RememberCredential -Desired $true -Action 'Changed' -Revert @{
Kind = 'VpnConnection'
Name = $vpn.Name
AllUser = [bool]$vpn.AllUserConnection
Property = 'RememberCredential'
Value = [bool]$vpn.RememberCredential
}
}
}
}
# =============================================================================
# 9. rasphone.pbk: IKEv2 MOBIKE outage tolerance
# The most important single setting for surviving sleep. MOBIKE is what lets
# an IKEv2 tunnel ride out the network gap sleep creates. Without it,
# "networking in standby" gains you almost nothing.
# DisableMobility and NetworkOutageTime are mutually exclusive: mobility must
# be ON (0) for an outage time to mean anything.
# =============================================================================
function Set-PbkSetting {
<#
Line-based rasphone.pbk editor. Replaces the value if the key exists in
the target profile, appends it inside the profile if it does not.
Returns @{ Report = <key -> @{Previous;Changed}>; Backup = <path or $null> }
#>
param(
[Parameter(Mandatory)][string]$Path,
[Parameter(Mandatory)][string]$ProfileName,
[Parameter(Mandatory)][hashtable]$Settings
)
$report = @{}
foreach ($k in $Settings.Keys) { $report[$k] = @{ Previous = $null; Changed = $false } }
$result = @{ Report = $report; Backup = $null }
if (-not (Test-Path $Path)) { return $result }
$lines = [System.Collections.Generic.List[string]]::new()
Get-Content -LiteralPath $Path | ForEach-Object { $lines.Add($_) }
# Locate the profile's line range.
$header = "[$ProfileName]"
$start = -1
for ($i = 0; $i -lt $lines.Count; $i++) {
if ($lines[$i].Trim() -eq $header) { $start = $i; break }
}
if ($start -lt 0) { return $result }
$end = $lines.Count
for ($i = $start + 1; $i -lt $lines.Count; $i++) {
if ($lines[$i].Trim() -match '^\[.+\]$') { $end = $i; break }
}
# Determine what needs writing.
$pending = @{}
foreach ($key in $Settings.Keys) {
$want = "$($Settings[$key])"
$found = $false
for ($i = $start + 1; $i -lt $end; $i++) {
if ($lines[$i] -match "^\s*$([regex]::Escape($key))\s*=\s*(.*)$") {
$found = $true
$report[$key].Previous = $Matches[1].Trim()
if ($report[$key].Previous -ne $want) { $pending[$key] = @{ Index = $i; Value = $want } }
break
}
}
if (-not $found) {
$report[$key].Previous = '(absent)'
$pending[$key] = @{ Index = -1; Value = $want }
}
}
if (-not $pending.Count) { return $result }
# Back up once, then apply. The backup is what the revert script restores.
$backup = "$Path.$(Get-Date -Format 'yyyyMMdd-HHmmss').bak"
Copy-Item -LiteralPath $Path -Destination $backup -Force
$result.Backup = $backup
Write-Verbose "Backed up $Path to $backup"
# Replace in place first (indexes stay valid), then append the absent ones.
foreach ($key in $pending.Keys) {
$idx = $pending[$key].Index
if ($idx -ge 0) {
$lines[$idx] = "$key=$($pending[$key].Value)"
$report[$key].Changed = $true
}
}
$insertAt = $end
foreach ($key in $pending.Keys) {
if ($pending[$key].Index -lt 0) {
$lines.Insert($insertAt, "$key=$($pending[$key].Value)")
$insertAt++
$report[$key].Changed = $true
}
}
# rasphone.pbk is ASCII with CRLF line endings. Do not let PowerShell
# helpfully write UTF-8 with a BOM here; RasMan will not parse it.
[System.IO.File]::WriteAllText($Path, ($lines -join "`r`n") + "`r`n", [System.Text.Encoding]::ASCII)
return $result
}
if ($SkipRasphone) {
Add-Result -Setting 'rasphone.pbk (MOBIKE etc.)' -Scope 'All connections' -Previous $null -Desired $null -Action 'Skipped'
}
elseif ($connections.Count) {
$pbkSettings = @{
'CacheCredentials' = '1' # so reconnect after wake does not prompt
}
if ($NetworkOutageTime -gt 0) {
$pbkSettings['DisableMobility'] = '0' # MOBIKE on
$pbkSettings['NetworkOutageTime'] = "$NetworkOutageTime"
}
# Auto-redial. THIS is the per-connection reconnect that works on every
# profile, unlike Always On (see section 10, which is limited to one).
# These settings exist in the phonebook but not in the modern Settings app.
if (-not $SkipRedial) {
$pbkSettings['RedialOnLinkFailure'] = '1' # redial when the link drops
$pbkSettings['RedialAttempts'] = "$RedialAttempts" # max 99
$pbkSettings['RedialSeconds'] = "$RedialSeconds" # wait between attempts
}
foreach ($vpn in $connections) {
if ($vpn.AllUserConnection) {
$pbkPath = $AllUserPbk
$scope = "$($vpn.Name) (all users)"
}
else {
$pbkPath = $UserPbk
$scope = $vpn.Name
}
if (-not (Test-Path $pbkPath)) {
Add-Result -Setting 'rasphone.pbk' -Scope $scope -Previous 'file not found' -Desired $null -Action 'Unsupported'
continue
}
if (-not $PSCmdlet.ShouldProcess($scope, "rasphone.pbk -> $(($pbkSettings.Keys | Sort-Object) -join ', ')")) {
foreach ($k in $pbkSettings.Keys) {
Add-Result -Setting "pbk $k" -Scope $scope -Previous $null -Desired $pbkSettings[$k] -Action 'Skipped'
}
continue
}
$outcome = Set-PbkSetting -Path $pbkPath -ProfileName $vpn.Name -Settings $pbkSettings
$report = $outcome.Report
$backup = $outcome.Backup
foreach ($k in ($report.Keys | Sort-Object)) {
if ($null -eq $report[$k].Previous) {
Add-Result -Setting "pbk $k" -Scope $scope -Previous $null -Desired $pbkSettings[$k] -Action 'Unsupported'
}
elseif ($report[$k].Changed) {
$rev = $null
if ($backup) { $rev = @{ Kind = 'PbkBackup'; Path = $pbkPath; Backup = $backup } }
Add-Result -Setting "pbk $k" -Scope $scope -Previous $report[$k].Previous -Desired $pbkSettings[$k] -Action 'Changed' -Revert $rev
}
else {
Add-Result -Setting "pbk $k" -Scope $scope -Previous $report[$k].Previous -Desired $pbkSettings[$k] -Action 'Already set'
}
}
}
Write-Host 'rasphone.pbk changes take effect on the NEXT dial, not on a live tunnel.' -ForegroundColor DarkGray
}
# =============================================================================
# 10. Always On / auto-trigger
#
# READ THIS BEFORE EXPECTING TOO MUCH.
#
# Always On is real and it is what reconnects on user sign-in, network
# change and device screen on. But Microsoft's own documentation is explicit
# about two limits:
#
# * ONE PROFILE ONLY. "When a device has multiple profiles with Always On
# triggers, the user can specify the active profile... only one profile,
# and therefore only one user, is able to use the Always On triggers."
# You cannot turn this on for all your VPNs. It is one, machine-wide.
# AutoTriggerProfileEntryName is a single REG_SZ, not a list.
#
# * IT IS NOT IN THE NORMAL UI. Always On is configured through the VPNv2
# CSP / ProfileXML (Intune, MDM) or Add-VpnConnection -AlwaysOn at
# creation time. Set-VpnConnection has no -AlwaysOn parameter, so a
# hand-made connection cannot be switched to Always On through the
# cmdlets at all. The "Let apps automatically use this VPN connection"
# checkbox in Settings only chooses WHICH already-Always-On profile is
# active -- it does not add Always On to a profile that lacks it. If your
# profiles were created by hand, there is no checkbox to find.
#
# So this section writes the RasMan auto-trigger values directly, for one
# named connection, and is opt-in via -AlwaysOnVpnName. For reconnect
# behaviour across ALL of your connections, section 9's RedialOnLinkFailure
# is the mechanism that actually scales.
#
# Caveat from Microsoft: auto-triggered VPN does not work if Folder
# Redirection for AppData is enabled, because that moves rasphone.pbk.
# =============================================================================
$rebootForAlwaysOn = $false
$autoTriggerName = $null
if (Test-Path $RasManConfig) {
$cfg = Get-ItemProperty -Path $RasManConfig -ErrorAction SilentlyContinue
if ($cfg -and ($cfg.PSObject.Properties.Name -contains 'AutoTriggerProfileEntryName')) {
$autoTriggerName = $cfg.AutoTriggerProfileEntryName
}
}
function Resolve-AlwaysOnTarget {
<#
Works out WHICH connection should get Always On when no name was passed.
There is no such thing as a "currently selected" VPN to read. Windows
keeps no default-entry or last-used pointer for VPN profiles that is
documented or queryable -- rasphone.pbk has no default-entry concept and
the Settings app persists no selection. So the ladder below uses signals
that DO exist, strongest first, and refuses to guess when the answer is
genuinely ambiguous. Guessing wrong here is not harmless: writing the
auto-trigger values DISPLACES whatever profile currently holds the one
Always On slot.
#>
param([Parameter(Mandatory)][AllowEmptyCollection()][array]$Candidates)
# 1. A live tunnel is the strongest statement of intent available.
$live = @($Candidates | Where-Object { $_.ConnectionStatus -eq 'Connected' })
if ($live.Count -eq 1) {
return [pscustomobject]@{ Connection = $live[0]; Reason = 'the only currently connected VPN' }
}
if ($live.Count -gt 1) {
return [pscustomobject]@{ Connection = $null; Reason = "$($live.Count) VPNs are connected at once: $(($live | ForEach-Object Name) -join ', ')" }
}
# 2. Nothing connected. If a profile already holds the Always On slot, keep
# it rather than silently moving it somewhere else.
if ($autoTriggerName) {
$incumbent = $Candidates | Where-Object { $_.Name -eq $autoTriggerName } | Select-Object -First 1
if ($incumbent) {
return [pscustomobject]@{ Connection = $incumbent; Reason = 'already holds the Always On slot; refreshing it' }
}
}
# 3. Only one VPN exists on the machine, so there is nothing to be wrong about.
if ($Candidates.Count -eq 1) {
return [pscustomobject]@{ Connection = $Candidates[0]; Reason = 'the only VPN connection on this machine' }
}
if ($Candidates.Count -eq 0) {
return [pscustomobject]@{ Connection = $null; Reason = 'no VPN connections found' }
}
return [pscustomobject]@{ Connection = $null; Reason = "$($Candidates.Count) VPNs exist and none is connected" }
}
# Resolve the target before deciding what to do.
$aoTarget = $null
$aoReason = $null
$aoWanted = -not $SkipAlwaysOn
if ($AlwaysOnVpnName) {
$aoTarget = $connections | Where-Object { $_.Name -eq $AlwaysOnVpnName } | Select-Object -First 1
$aoReason = 'named explicitly'
if (-not $aoTarget) {
Write-Warning "No VPN connection named '$AlwaysOnVpnName' was found. Always On not configured."
if ($connections.Count) {
Write-Warning "Available: $(($connections | ForEach-Object Name) -join ', ')"
}
}
}
elseif ($aoWanted) {
$resolved = Resolve-AlwaysOnTarget -Candidates $connections
$aoTarget = $resolved.Connection
$aoReason = $resolved.Reason
if ($aoTarget) {
Write-Host ''
Write-Host "Always On target auto-selected: '$($aoTarget.Name)'" -ForegroundColor Cyan
Write-Host " Reason: $aoReason" -ForegroundColor DarkGray
}
else {
# Ambiguous. Ask, if there is a human here to ask. Only one profile can
# hold the slot, so a silent guess could disarm the one that matters.
if ((Test-CanPrompt) -and $connections.Count -gt 0) {
$aoTarget = Select-SingleVpnInteractively -Candidates $connections `
-CurrentHolder $autoTriggerName `
-Prompt "Which VPN should get Always On? ($aoReason)"
if ($aoTarget) {
$aoReason = 'chosen interactively'
Write-Host " Selected: $($aoTarget.Name)" -ForegroundColor Green
}
else {
Write-Host ' Skipped. Always On left unchanged.' -ForegroundColor DarkGray
}
}
else {
Write-Host ''
Write-Warning "Cannot auto-select an Always On target: $aoReason."
Write-Warning 'Always On left unchanged. Re-run with -AlwaysOnVpnName ''<name>'','
Write-Warning 'or without -Silent / -WhatIf to be prompted.'
}
}
}
if ($aoWanted) {
$target = $aoTarget
if (-not $target) {
$scopeLabel = 'unresolved'
if ($AlwaysOnVpnName) { $scopeLabel = $AlwaysOnVpnName }
Add-Result -Setting 'Always On (auto-trigger)' -Scope $scopeLabel -Previous $autoTriggerName -Desired 'Enabled' -Action 'Unsupported'
}
elseif ($autoTriggerName -eq $target.Name) {
Add-Result -Setting 'Always On (auto-trigger)' -Scope $target.Name -Previous $autoTriggerName -Desired $target.Name -Action 'Already set'
Write-Host ''
Write-Host "Always On already points at '$($target.Name)' ($aoReason). Nothing to do." -ForegroundColor Green
}
else {
# All-user connections live in the ProgramData phonebook and pair with the
# Everyone SID; per-user connections use the roaming profile path and the
# actual user SID.
if ($target.AllUserConnection) {
$pbkForTarget = $AllUserPbk
$sidForTarget = 'S-1-1-0'
}
else {
$pbkForTarget = $UserPbk
$sidForTarget = ([System.Security.Principal.WindowsIdentity]::GetCurrent()).User.Value
}
# STEP 1 -- clear the opt-out list FIRST. This is the single most common
# reason Always On silently refuses to arm: Windows treats a past
# un-check as a permanent user preference and will ignore everything
# below while the profile name sits in this list.
$disabled = $null
$cfg = Get-ItemProperty -Path $RasManConfig -ErrorAction SilentlyContinue
if ($cfg -and ($cfg.PSObject.Properties.Name -contains 'AutoTriggerDisabledProfilesList')) {
$disabled = @($cfg.AutoTriggerDisabledProfilesList)
}
$targetName = $target.Name
if ($disabled -and ($disabled -icontains $targetName)) {
$trimmed = @($disabled | Where-Object { $_ -ne $targetName })
Set-RegistryValue -Label 'Always On opt-out list (cleared)' -Scope $targetName `
-Path $RasManConfig -Name 'AutoTriggerDisabledProfilesList' -Value $trimmed -Type MultiString | Out-Null
}
else {
Add-Result -Setting 'Always On opt-out list' -Scope $targetName -Previous 'not listed' -Desired 'not listed' -Action 'Already set'
}
# STEP 2 -- the four values that actually arm it. All four are required;
# writing a subset does nothing.
Set-RegistryValue -Label 'Always On UserSID' -Scope $targetName `
-Path $RasManConfig -Name 'UserSID' -Value $sidForTarget -Type String | Out-Null
Set-RegistryValue -Label 'Always On entry name' -Scope $targetName `
-Path $RasManConfig -Name 'AutoTriggerProfileEntryName' -Value $targetName -Type String | Out-Null
Set-RegistryValue -Label 'Always On phonebook path' -Scope $targetName `
-Path $RasManConfig -Name 'AutoTriggerProfilePhonebookPath' -Value $pbkForTarget -Type String | Out-Null
# AutoTriggerProfileGUID is REG_BINARY -- the GUID's raw 16-byte array,
# NOT the '{...}' string form. Writing it as a string looks like it
# worked and then silently never triggers.
[guid]$targetGuid = $target.Guid
Set-RegistryValue -Label 'Always On profile GUID' -Scope $targetName `
-Path $RasManConfig -Name 'AutoTriggerProfileGUID' -Value $targetGuid.ToByteArray() -Type Binary | Out-Null
Write-Host ''
Write-Host "Always On set for '$targetName' ($aoReason)." -ForegroundColor Yellow
if ($autoTriggerName -and $autoTriggerName -ne $targetName) {
Write-Host " This displaced '$autoTriggerName' -- only one profile can hold it." -ForegroundColor Yellow
}
# RasMan caches this at service start. Without a restart the values sit
# in the registry doing nothing, which looks exactly like "it did not work".
if ($RestartRasMan) {
if ($PSCmdlet.ShouldProcess('RasMan', 'Restart (kills the shared svchost -k netsvcs group)')) {
Write-Host ' Restarting RasMan...' -ForegroundColor Yellow
try {
# Restart-Service does not work on RasMan. Killing the PID is
# the only reliable route, and it takes co-hosted netsvcs
# services down with it.
$rasPid = (Get-CimInstance -ClassName Win32_Service -Filter "Name='RasMan'").ProcessId
if ($rasPid) {
Stop-Process -Id $rasPid -Force
Start-Sleep -Seconds 5
}
Start-Service RasMan
Add-Result -Setting 'RasMan restart' -Scope 'Machine' -Previous 'running' -Desired 'restarted' -Action 'Changed'
}
catch {
Write-Warning "Could not restart RasMan: $($_.Exception.Message). Reboot to apply."
Add-Result -Setting 'RasMan restart' -Scope 'Machine' -Previous 'running' -Desired 'restarted' -Action 'Unsupported'
}
}
}
else {
$rebootForAlwaysOn = $true
Add-Result -Setting 'RasMan restart' -Scope 'Machine' -Previous $null -Desired 'reboot required' -Action 'Skipped'
}
}
}
elseif ($autoTriggerName) {
Add-Result -Setting 'Always On (auto-trigger)' -Scope $autoTriggerName -Previous 'Enabled' -Desired 'unchanged' -Action 'Info'
}
else {
Add-Result -Setting 'Always On (auto-trigger)' -Scope 'None' -Previous 'Not configured' -Desired 'see output' -Action 'Info'
}
# =============================================================================
# Report
# =============================================================================
Write-Host ''
Write-Host '--- Results ------------------------------------------------------------' -ForegroundColor Cyan
$results | Where-Object { $_.Action -ne 'Info' } |
Select-Object Setting, Scope, Previous, Desired, Action | Format-Table -AutoSize
$changedCount = @($results | Where-Object { $_.Action -eq 'Changed' }).Count
if ($changedCount -eq 0) {
Write-Host 'Nothing to do - all settings already correct.' -ForegroundColor Green
}
else {
Write-Host "$changedCount setting(s) changed." -ForegroundColor Yellow
if ($adapterWasReset) {
Write-Host 'A network adapter was reset - reconnect any VPN that dropped.' -ForegroundColor Yellow
}
}
# JSON changelog. Revert-VpnSleepSettings.ps1 consumes this.
$jsonPath = $null
if ($LogPath -and (Test-Path $LogPath)) {
$stamp = Get-Date -Format 'yyyyMMdd-HHmmss'
$jsonPath = Join-Path $LogPath "VpnSleepSettings-$stamp.json"
$capability = 'Unknown'
$capRow = @($results | Where-Object { $_.Setting -eq 'Sleep capability' }) | Select-Object -First 1
if ($capRow) { $capability = $capRow.Previous }
$payload = [pscustomobject]@{
SchemaVersion = 1
Timestamp = (Get-Date).ToString('o')
Computer = $env:COMPUTERNAME
User = "$env:USERDOMAIN\$env:USERNAME"
ScriptVersion = '3.0'
WhatIf = [bool]$WhatIfPreference
IdleDisconnectSeconds = $IdleDisconnectSeconds
NetworkOutageTime = $NetworkOutageTime
HibernateEnabled = $hibernateOn
SleepCapability = $capability
Results = $results
}
$payload | ConvertTo-Json -Depth 6 | Set-Content -LiteralPath $jsonPath -Encoding UTF8
}
# --- Next steps --------------------------------------------------------------
# Only actions the user has to take. Everything explanatory lives in the
# comment-based help: run Get-Help .\Set-VpnSleepSettings.ps1 -Full
$steps = [System.Collections.Generic.List[string]]::new()
if ($adapterWasReset) {
$steps.Add('Reconnect any VPN that dropped (an adapter was reset).')
}
if (-not $SkipRasphone -and $connections.Count -and $changedCount -gt 0) {
$steps.Add('Reconnect your VPN once so the phonebook changes take effect.')
}
if ($rebootForAlwaysOn) {
$steps.Add('Reboot to activate Always On, or re-run adding -RestartRasMan.')
}
if ($jsonPath) {
$steps.Add("To undo: .\Revert-VpnSleepSettings.ps1 -WhatIf")
}
if ($steps.Count) {
Write-Host ''
Write-Host 'Next steps:' -ForegroundColor Cyan
$stepNo = 1
foreach ($s in $steps) {
Write-Host (" {0}. {1}" -f $stepNo, $s)
$stepNo++
}
}
Write-Host ''
Always On) in Settings, untick it'
Write-Host ' there. The UI is authoritative over the registry values.'
Write-Host ' * Credentials already cached on a connection are not cleared by setting'
Write-Host ' RememberCredential back to False. Clear them in the connection properties.'
Write-Host ' * Power settings whose previous state was hidden or absent have no index to'
Write-Host ' write back. To put standby networking back to Windows-managed, set it to 2.'
Write-Host ''
pters.
Everything this script does is a Windows setting. Nothing is installed and
nothing is left running. Every change is written to a JSON changelog that
Revert-VpnSleepSettings.ps1 can replay backwards.
WHAT IT SETS
1. Networking connectivity in Standby -> Enable, on every power scheme,
both AC and DC, plus the Group Policy value so a plan change or GP
refresh does not silently revert it.
2. Wireless Adapter Power Saving Mode -> Maximum Performance.
3. USB selective suspend -> off (matters when the NIC is in a dock).
4. NIC "Allow the computer to turn off this device" -> off, and wake
arming on (magic packet, pattern match, ARP + NS offload) so the NIC
holds its link and address in standby rather than going dark.
5. NIC driver power-saving properties -> off (Green Ethernet, EEE, ULP,
Wi-Fi PowerSaveMode, MIMO power save). This is the layer that actually
kills the link; the Device Manager checkbox in 4 does not cover it.
6. RasMan KeepRasConnections -> 1, so the tunnel survives logoff.
7. IKEv2 MOBIKE network outage tolerance -> 1800s in rasphone.pbk. This is
what lets a tunnel ride out the network gap that sleep creates.
8. VPN IdleDisconnectSeconds -> 14400 (4 hours) and credential caching on.
Items 7 and 8, and the redial keys, are all rasphone.pbk settings and are
applied to EVERY VPN profile by default. Narrow that with -VpnName or
-ConnectedOnly if you need to.
9. TCP KeepAliveTime -> 10 min, so sessions running INSIDE the tunnel
(RDP, SSH, SQL) do not die during a long idle window.
10. Always On (RasMan auto-trigger) on ONE profile -- the connected VPN by
default, or whichever you pick when that is ambiguous. On by default;
turn it off with -SkipAlwaysOn. See ALWAYS ON below. Unlike everything
above, Windows permits this on exactly one profile per machine.
WHAT IT DOES NOT DO
* No scheduled task. No installed script. No background process.
* Does not touch hibernate, Fast Startup, or hybrid sleep -- reports only.
No VPN tunnel survives hibernate or a Fast Startup shutdown; that is a
protocol reality, not a setting.
* Does not stop the machine from sleeping.
.PARAMETER IdleDisconnectSeconds
RAS client idle timeout, in seconds. Default 14400 (4 hours). 0 disables idle
disconnect entirely. This is a phonebook key, so it is applied to every VPN
profile by default; see -ConnectedOnly. Read NOTES before trusting it:
necessary, not sufficient.
.PARAMETER NetworkOutageTime
Seconds of network outage an IKEv2 tunnel tolerates via MOBIKE before tearing
down. Default 1800 (30 min), which is the documented maximum. 0 leaves it alone.
.PARAMETER VpnName
Configure only these connection names. Connected state is ignored -- naming
a profile is taken as knowing what you want.
.PARAMETER ConnectedOnly
Narrow the phonebook changes to the VPN connected right now, instead of all
of them.
HOW THE TARGET LIST IS CHOSEN:
1. -VpnName given -> exactly those profiles.
2. -ConnectedOnly given -> the connected VPN. If none is connected you get
a numbered picker, or nothing changes when the
session cannot prompt.
3. Default -> EVERY VPN profile.
Default 3 is deliberate. Every per-connection setting this script writes is
a rasphone.pbk key, IdleDisconnectSeconds included -- Set-VpnConnection just
writes that into the same phonebook. Phonebook keys are inert configuration:
they cost nothing on a disconnected profile, and setting them everywhere
means a VPN you dial next month is already correct. Narrowing by default
would silently leave your other profiles wrong.
Connected state still matters, but only for Always On, which Windows permits
on exactly one profile. That target is resolved separately and this switch
does not affect it -- see -SkipAlwaysOn.
.PARAMETER Silent
Never prompt. Aliased as -NoPrompt. For unattended or scheduled runs: where
the script would put a choice to the user it warns and takes the documented
non-interactive path instead. Suppresses both the VPN picker and the
Always On picker.
.PARAMETER AlwaysOnVpnName
Enable Always On / auto-trigger for this connection by writing the RasMan
auto-trigger values. Opt-in on purpose -- ticking "Connect automatically" in
Settings does the same thing with no registry edit, and is the supported
route. Without this parameter the script only reports the current state.
.PARAMETER SkipAlwaysOn
Do not configure Always On. Report its current state and move on.
Always On is ON by default. Unlike the phonebook settings it cannot be
applied to every profile -- Windows permits exactly one Always On profile
per machine -- so the script resolves a single target, strongest signal
first:
1. The only currently CONNECTED VPN.
2. The profile that already holds the Always On slot (refresh, not move).
3. The only VPN connection on the machine.
4. Otherwise: a numbered picker, so you choose. Under -Silent or with no
console it warns and leaves Always On alone.
There is deliberately no automatic tie-break past rung 3: arming the wrong
profile silently disarms the right one. When rung 1 does displace an
existing holder, the script says which profile it displaced.
Note there is no such thing as a "currently selected" VPN to read. Windows
keeps no documented default-entry or last-used pointer for VPN profiles;
rasphone.pbk has no default-entry concept and the Settings app persists no
selection. Connected state is the only real signal available.
Always On needs RasMan restarted to take effect -- see -RestartRasMan, or
reboot.
.PARAMETER TcpKeepAliveMinutes
Sets HKLM TCP KeepAliveTime. Default 10. 0 leaves it alone. Machine-wide.
.PARAMETER SetUnattendedSleepTimeout
Seconds for "System unattended sleep timeout" (Windows default 120).
0 means leave alone. Only relevant on S3 machines.
.PARAMETER AllowWirelessPowerSavingOnBattery
Leave Wireless Adapter Power Saving Mode alone on DC. Forcing Maximum
Performance on battery is a real battery cost.
.PARAMETER SkipPolicyKey
Do not write the HKLM\SOFTWARE\Policies standby-networking value.
.PARAMETER SkipVpnIdle
Leave IdleDisconnectSeconds and credential caching alone.
.PARAMETER SkipRasphone
Leave rasphone.pbk alone. You lose MOBIKE / NetworkOutageTime, which is most
of the sleep survival story.
.PARAMETER SkipAdapterPowerManagement
Leave NIC power management and wake arming alone. Use this if you cannot
tolerate the brief adapter reset that changing it causes (it drops live VPN
sessions).
.PARAMETER SkipAdapterAdvancedProperties
Leave NIC driver advanced properties alone. Also causes adapter resets.
.PARAMETER LogPath
Directory for the JSON changelog. Default: the script's own directory.
Keep this file -- Revert-VpnSleepSettings.ps1 needs it.
.EXAMPLE
.\Set-VpnSleepSettings.ps1 -WhatIf
Report what would change, and what this machine is actually capable of,
without changing anything. Run this first.
.EXAMPLE
.\Set-VpnSleepSettings.ps1
Configures EVERY VPN profile: 4-hour idle timeout, 8-hour sleep tolerance,
credential caching, redial. No task installed.
.EXAMPLE
.\Set-VpnSleepSettings.ps1 -ConnectedOnly
Same, but only for the VPN connected right now.
.EXAMPLE
.\Set-VpnSleepSettings.ps1 -AlwaysOnVpnName 'Corp VPN'
Same, plus enable native auto-reconnect for 'Corp VPN'.
.EXAMPLE
.\Set-VpnSleepSettings.ps1 -RestartRasMan
The normal run, plus restart RasMan so Always On takes effect immediately
instead of at the next reboot.
.EXAMPLE
.\Set-VpnSleepSettings.ps1 -SkipAlwaysOn
Phonebook settings on every VPN, but leave Always On exactly as it is.
.EXAMPLE
.\Set-VpnSleepSettings.ps1 -SkipAdapterPowerManagement -SkipAdapterAdvancedProperties
Everything that does not reset a network adapter. Safe while connected.
.NOTES
Requires elevation. Parses English powercfg output.
RECONNECT WITHOUT A WATCHDOG -- WHAT ACTUALLY WORKS ON EVERY CONNECTION
There are two native reconnect mechanisms and they are not equivalent.
1. AUTO-REDIAL (rasphone.pbk) -- works on ALL connections.
RedialOnLinkFailure, RedialAttempts and RedialSeconds are per-profile
phonebook settings. This script sets them on every connection it finds.
They are absent from the modern Settings app; the phonebook is where they
live. Worth checking ncpa.cpl > connection > Properties > Options, which
historically exposes the same redial fields.
Caveat, stated plainly: redial is best-effort. It fires on link failure.
It is not a guarantee, and reports of it not firing in every scenario are
common. It is still the right first move because it costs nothing and
covers every profile.
2. ALWAYS ON (RasMan auto-trigger) -- ONE connection per machine, only.
Reconnects on user sign-in, network change, and device screen on.
Microsoft's documentation is explicit: with multiple Always On profiles
"only one profile, and therefore only one user, is able to use the Always
On triggers." AutoTriggerProfileEntryName is a single REG_SZ, not a list.
So this cannot be applied to all your VPNs. Pick your most important one
and pass -AlwaysOnVpnName.
It is also not in the normal UI for hand-made profiles. Always On is set
through the VPNv2 CSP / ProfileXML or Add-VpnConnection -AlwaysOn at
creation time. Set-VpnConnection has no -AlwaysOn parameter. The "Let apps
automatically use this VPN connection" checkbox in Settings only chooses
which already-Always-On profile is active; it does not add Always On to a
profile that lacks it. If you went looking for a checkbox and could not
find one, that is why.
Microsoft also notes auto-trigger breaks if Folder Redirection for AppData
is enabled, since that relocates rasphone.pbk.
Both mechanisms reconnect; neither prevents the drop. You will still see a
brief gap on wake. Both need saved credentials, which is what section 8's
credential caching is for.
WHY IdleDisconnectSeconds IS NOT ENOUGH
Three timers outrank the RAS client idle timer and none are settable here:
1. The IPsec quick-mode SA idle timeout is hard-coded in vpnike.dll
(roughly 5 minutes) and is NOT influenced by IdleDisconnectSeconds.
Microsoft has confirmed this. The client keeps showing "Connected"
while the server has already processed a Delete SA.
2. Server side wins. RRAS Set-VpnServerConfiguration -IdleDisconnectSeconds,
the NPS network policy Idle-Timeout / Session-Timeout, or your
appliance's own config will cut you off regardless of anything here.
3. NAT and firewall state for UDP 4500 between you and the gateway
typically expires in 30 seconds to 5 minutes.
Correcting something I would otherwise have implied: THERE IS NO NATIVE
KEEPALIVE ON THE WINDOWS VPN CLIENT.
* IKEv2 DPD is not implemented on this code path at all. MS-IKEE
Appendix A, product behaviour note <33>: "Dead Peer Detection is not
implemented on Windows 8 and later for IKEv2-based VPN (that is, VPN
Reconnect)." DPD on Windows is IKEv1 and server-to-server only. Do not
expect liveness probes to hold your tunnel open.
* The NAT-T keepalive on UDP 4500 IS sent, every 20 seconds, fixed and
not configurable (MS-IKEE note <14>). Good news: item 3 above is
therefore handled for you. But it is not IPsec-protected data, so it
does NOT reset the quick-mode SA idle timer. That is precisely how you
end up with a live NAT binding and a dead child SA at 300 seconds.
* Item 1 is documented in MS-IKEE 3.5.2 (5 minutes, or 1 minute with the
NLBS_PRESENT vendor ID) and is not reachable from any published knob.
So with no scheduled task, item 1 is your binding constraint and section 5b
is a long shot at it. If a silent multi-hour idle window drops you, the fix
is traffic, and the script-free options are:
* Map a network drive over the tunnel. SMB2 sends its own echo requests
and keeps the path warm for free. This is the best of them.
* Raise the idle timeout on the server (netsh ras set ikev2connection
idletimeout) or in the NPS network policy.
* Enable DPD / keepalives on the VPN gateway so the SERVER probes you.
Server-to-server DPD is implemented even though the client side is not.
See the "outside this script" list the script prints when it finishes.
WHAT SURVIVES WHAT
Lock (workstation locked, user still logged on) survives, reliably
Modern Standby within NetworkOutageTime usually survives
Modern Standby beyond NetworkOutageTime drops; Always On re-dials
S3 sleep drops; the NIC is off
Hibernate (S4) drops, always
Fast Startup shutdown drops, always
Note the second row now reads "within NetworkOutageTime" rather than
"under 30 minutes". The 1800-second ceiling repeated all over the internet
is one MVP script's ValidateSet, not a platform limit -- the VPNv2 CSP
documents 0 to 4294967295 seconds and Microsoft recommends up to 28800
(8 hours). This script defaults to 28800, so an overnight sleep is now
inside the window rather than outside it. The trade-off is that a genuinely
dead tunnel also lingers that long before the stack is told.
Check your hardware with: powercfg /a
"Standby (S0 Low Power Idle) Network Connected" is the good outcome. If you
only have "Standby (S3)", the standby-networking settings here cannot help
and Always On's reconnect is your whole strategy.
REVERT
Use Revert-VpnSleepSettings.ps1 against the JSON changelog this script writes.
Version: 3.0
Requires: Windows 10 1607+ / Windows 11, PowerShell 5.1+
#>
[CmdletBinding(SupportsShouldProcess)]
param(
[ValidateRange(0, 4294967295)]
[uint32]$IdleDisconnectSeconds = 14400,
# The VPNv2 CSP documents this as range 0-4294967295 seconds, with Microsoft
# RECOMMENDING 0-28800 (8 hours). The 1800 ceiling widely repeated online
# comes from one MVP script's own ValidateSet, not from the platform.
# Default here is 28800 -- 8 hours of outage tolerance, which is what makes
# an overnight sleep survivable rather than a lunch break.
[ValidateRange(0, 28800)]
[int]$NetworkOutageTime = 28800,
# Attempt to raise the global IPsec SA idle time. See notes: this is the WFP
# global default and may not reach the RasMan tunnel policy, but it is
# documented, cheap, and the only sanctioned lever anywhere near the
# hard-coded 300s quick-mode timer. Range 300-3600. 0 = leave alone.
[ValidateRange(0, 3600)]
[int]$MaxSAIdleTimeSeconds = 3600,
# Auto-trigger changes need RasMan restarted. Restart-Service does not work
# on it; the only reliable way is killing its PID, which takes down the
# shared svchost -k netsvcs group with it. Opt-in for that reason.
[switch]$RestartRasMan,
[ValidateRange(0, 99)]
[int]$RedialAttempts = 99,
[ValidateRange(1, 600)]
[int]$RedialSeconds = 15,
[switch]$SkipRedial,
[string[]]$VpnName,
# Narrow the phonebook changes to the VPN connected right now. Off by
# default: phonebook keys are inert on a disconnected profile, so there is
# no reason not to set them everywhere.
[switch]$ConnectedOnly,
[string]$AlwaysOnVpnName,
# Do not configure Always On at all -- report its current state and move on.
# Always On is ON by default: the script resolves a target itself (connected
# VPN first, then the current holder, then a lone connection) and asks when
# that is ambiguous.
[switch]$SkipAlwaysOn,
# Never prompt for anything. Use for unattended or scheduled runs: when a
# choice would otherwise be put to the user, the script warns and takes the
# documented non-interactive path instead.
[Alias('NoPrompt')]
[switch]$Silent,
# Do not clear the screen on start. Use when you are capturing output or
# running this from another script.
[switch]$NoClear,
[ValidateRange(0, 1440)]
[int]$TcpKeepAliveMinutes = 10,
[ValidateRange(0, 86400)]
[int]$SetUnattendedSleepTimeout = 0,
[switch]$AllowWirelessPowerSavingOnBattery,
[switch]$SkipPolicyKey,
[switch]$SkipVpnIdle,
[switch]$SkipRasphone,
[switch]$SkipAdapterPowerManagement,
[switch]$SkipAdapterAdvancedProperties,
[string]$LogPath = $PSScriptRoot
)
Set-StrictMode -Version Latest
$ErrorActionPreference = 'Stop'
# Start from a clean screen so the results table is the only thing on it.
# Guarded: Clear-Host is meaningless, and can throw or emit stray escape codes,
# when output is redirected or the host has no real console.
if (-not $NoClear -and [Environment]::UserInteractive) {
try { Clear-Host } catch { }
}
# --- Identifiers -------------------------------------------------------------
$StandbyNetworkSetting = 'F15576E8-98B7-4186-B944-EAFA664402D9' # 0=Disable 1=Enable 2=Managed by Windows
$SleepSubGroup = '238C9FA8-0AAD-41ED-83F4-97BE242C8F20' # SUB_SLEEP
$UnattendedSleepTimeout = '7BC4A2F9-D8FC-4469-B07B-33EB785AACA0'
$WirelessSubGroup = '19CBB8FA-5279-450E-9FAC-8A3D5FEDD0C1'
$WirelessPowerSaving = '12BBEBE6-58D6-4636-95BB-3217EF867C1A' # 0=Max Perf .. 3=Max Power Saving
$UsbSubGroup = '2A737441-1930-4402-8D77-B2BEBBA308A3' # SUB_USB
$UsbSelectiveSuspend = '48E6B7A6-50F5-4782-A5D4-53BB8F07E226' # 0=Disabled
$RasManParameters = 'HKLM:\SYSTEM\CurrentControlSet\Services\RasMan\Parameters'
$RasManConfig = 'HKLM:\SYSTEM\CurrentControlSet\Services\RasMan\Config'
$TcpipParameters = 'HKLM:\SYSTEM\CurrentControlSet\Services\Tcpip\Parameters'
$PowerPolicyRoot = 'HKLM:\SOFTWARE\Policies\Microsoft\Power\PowerSettings'
$PowerControl = 'HKLM:\SYSTEM\CurrentControlSet\Control\Power'
$AllUserPbk = 'C:\ProgramData\Microsoft\Network\Connections\Pbk\rasphone.pbk'
$UserPbk = Join-Path $env:APPDATA 'Microsoft\Network\Connections\Pbk\rasphone.pbk'
# NIC *driver* advanced properties that break standby connectivity, keyed by the
# driver registry keyword. Value is the target RegistryValue. Only applied when
# the adapter actually exposes the keyword.
#
# The NDIS standardized keywords (*WakeOnMagicPacket, *PMARPOffload,
# *SelectiveSuspend and friends) are deliberately NOT here -- section 4 sets
# those through Get/Set-NetAdapterPowerManagement, which writes the same
# keywords. Listing them twice would just reset the adapter twice.
$AdapterKeywords = [ordered]@{
'EnableGreenEthernet' = '0' # Realtek / Intel
'GigaLite' = '0' # Realtek
'EnableDynamicPowerGating' = '0' # Realtek
'AdvancedEEE' = '0' # Intel
'EEELinkAdvertisement' = '0' # Intel
'*EEE' = '0' # generic NDIS, no PM-object equivalent
'ULPMode' = '0' # Intel Ultra Low Power
'ReduceSpeedOnPowerDown' = '0'
'PowerSavingMode' = '0'
'PowerSaveMode' = '0' # Intel Wi-Fi: 0 = No power saving
'MIMOPowerSaveMode' = '0' # 0 = No SMPS
'AutoPowerSaveModeEnabled' = '0'
}
$results = [System.Collections.Generic.List[object]]::new()
function Add-Result {
<#
Records one item for the console table and the JSON changelog.
-Revert carries the machine-readable instructions the revert script needs.
It is only meaningful on 'Changed' rows. Shape depends on Kind:
Registry Path, Name, Type (DWord|String|MultiString), Value, Existed
PowerScheme Scheme, SubGroup, Setting, Rail, Value
NetAdapterPM Adapter, Property, Value
NetAdapterAdvanced Adapter, Keyword, Value
VpnConnection Name, AllUser, Property, Value
PbkBackup Path, Backup
#>
param(
[Parameter(Mandatory)][string]$Setting,
[Parameter(Mandatory)][string]$Scope,
[AllowNull()]$Previous,
[AllowNull()]$Desired,
[Parameter(Mandatory)]
[ValidateSet('Changed', 'Already set', 'Skipped', 'Unsupported', 'Info')]
[string]$Action,
[hashtable]$Revert
)
$prev = '-'
if ($null -ne $Previous -and "$Previous" -ne '') { $prev = "$Previous" }
$want = '-'
if ($null -ne $Desired -and "$Desired" -ne '') { $want = "$Desired" }
$revertPayload = $null
if ($Revert) { $revertPayload = [pscustomobject]$Revert }
$results.Add([pscustomobject]@{
Setting = $Setting
Scope = $Scope
Previous = $prev
Desired = $want
Action = $Action
Revert = $revertPayload
})
}
# =============================================================================
# Preflight: what is this machine actually capable of?
# =============================================================================
Write-Host ''
Write-Host '--- Machine capability -------------------------------------------------' -ForegroundColor Cyan
# powercfg /a prints an "available" block and then a "not available" block.
# Matching the whole output would happily find S3 in the *unavailable* list, so
# split first and only look at what is actually available.
$sleepStates = powercfg /a 2>$null | Out-String
$availableBlock = ($sleepStates -split 'The following sleep states are not available')[0]
$hasS0Network = $availableBlock -match 'S0 Low Power Idle[^\)]*Network Connected'
$hasS0 = $availableBlock -match 'S0 Low Power Idle'
$hasS3 = $availableBlock -match 'Standby \(S3\)'
if ($hasS0Network) {
Write-Host ' Modern Standby with network: supported. A tunnel can survive sleep.' -ForegroundColor Green
Add-Result -Setting 'Sleep capability' -Scope 'Machine' -Previous 'S0 Low Power Idle Network Connected' -Desired $null -Action 'Info'
}
elseif ($hasS0) {
Write-Host ' Modern Standby: supported, but network not listed. Driver dependent.' -ForegroundColor Yellow
Add-Result -Setting 'Sleep capability' -Scope 'Machine' -Previous 'S0 Low Power Idle (no network)' -Desired $null -Action 'Info'
}
elseif ($hasS3) {
Write-Host ' S3 sleep only. No tunnel survives sleep here; the NIC powers off.' -ForegroundColor Red
Add-Result -Setting 'Sleep capability' -Scope 'Machine' -Previous 'S3 only' -Desired $null -Action 'Info'
}
else {
Write-Host ' Sleep capability could not be determined from powercfg /a.' -ForegroundColor Yellow
Add-Result -Setting 'Sleep capability' -Scope 'Machine' -Previous 'Unknown' -Desired $null -Action 'Info'
}
# Report hibernate and Fast Startup; do not change them.
$hibernateOn = $false
try {
$power = Get-ItemProperty $PowerControl -ErrorAction Stop
$powerValues = $power.PSObject.Properties.Name
if (($powerValues -contains 'HibernateEnabled') -and $null -ne $power.HibernateEnabled) {
$hibernateOn = ($power.HibernateEnabled -eq 1)
}
elseif ($powerValues -contains 'HibernateEnabledDefault') {
$hibernateOn = ($power.HibernateEnabledDefault -eq 1)
}
$fastStartup = $false
if ($powerValues -contains 'HiberbootEnabled') {
$fastStartup = ($power.HiberbootEnabled -eq 1)
}
if ($hibernateOn) {
Write-Host ' Hibernate is enabled (left alone). No VPN survives hibernate.' -ForegroundColor DarkGray
}
$hibText = 'Disabled'
if ($hibernateOn) { $hibText = 'Enabled' }
Add-Result -Setting 'Hibernate' -Scope 'Machine' -Previous $hibText -Desired 'unchanged' -Action 'Info'
if ($fastStartup) {
Write-Host ' Fast Startup is enabled (left alone). A shutdown is really a hibernate.' -ForegroundColor DarkGray
}
$fsText = 'Disabled'
if ($fastStartup) { $fsText = 'Enabled' }
Add-Result -Setting 'Fast Startup' -Scope 'Machine' -Previous $fsText -Desired 'unchanged' -Action 'Info'
}
catch {
Add-Result -Setting 'Hibernate / Fast Startup' -Scope 'Machine' -Previous 'unreadable' -Desired $null -Action 'Info'
}
Write-Host ''
# =============================================================================
# Helpers
# =============================================================================
function Get-PowerSchemeGuid {
$out = powercfg /list
($out | Select-String -Pattern 'GUID:\s*([0-9a-fA-F-]{36})' |
ForEach-Object { $_.Matches[0].Groups[1].Value }) | Select-Object -Unique
}
function Get-PowerSettingIndex {
<# Returns @{AC=<int>; DC=<int>} or $null when the setting is hidden/absent. #>
param(
[Parameter(Mandatory)][string]$Scheme,
[Parameter(Mandatory)][string]$SubGroup,
[Parameter(Mandatory)][string]$Setting
)
$out = powercfg /query $Scheme $SubGroup $Setting 2>$null
if ($LASTEXITCODE -ne 0 -or -not $out) { return $null }
$parse = {
param($rail)
$m = @($out | Select-String -Pattern "Current $rail Power Setting Index:\s*0x([0-9a-fA-F]+)")
if ($m.Count) { [Convert]::ToInt64($m[0].Matches[0].Groups[1].Value, 16) } else { $null }
}
$ac = & $parse 'AC'
$dc = & $parse 'DC'
if ($null -eq $ac -and $null -eq $dc) { return $null }
@{ AC = $ac; DC = $dc }
}
function Set-PowerSettingIndex {
<# Sets the named rails only where they differ. Returns $true if anything changed. #>
param(
[Parameter(Mandatory)][string]$Label,
[Parameter(Mandatory)][string]$Scheme,
[Parameter(Mandatory)][string]$SubGroup,
[Parameter(Mandatory)][string]$Setting,
[Parameter(Mandatory)][int]$Value,
[ValidateSet('AC', 'DC', 'Both')][string]$Rails = 'Both'
)
$current = Get-PowerSettingIndex -Scheme $Scheme -SubGroup $SubGroup -Setting $Setting
$changed = $false
$targets = @('AC', 'DC')
if ($Rails -eq 'AC') { $targets = @('AC') }
elseif ($Rails -eq 'DC') { $targets = @('DC') }
foreach ($rail in $targets) {
$previous = $null
if ($current) { $previous = $current[$rail] }
$scope = "$Scheme ($rail)"
if ($previous -eq $Value) {
Add-Result -Setting $Label -Scope $scope -Previous $previous -Desired $Value -Action 'Already set'
continue
}
if (-not $PSCmdlet.ShouldProcess($scope, "$Label -> $Value")) {
Add-Result -Setting $Label -Scope $scope -Previous $previous -Desired $Value -Action 'Skipped'
continue
}
$verb = '/setacvalueindex'
if ($rail -eq 'DC') { $verb = '/setdcvalueindex' }
powercfg $verb $Scheme $SubGroup $Setting $Value | Out-Null
if ($LASTEXITCODE -ne 0) {
Add-Result -Setting $Label -Scope $scope -Previous $previous -Desired $Value -Action 'Unsupported'
continue
}
Add-Result -Setting $Label -Scope $scope -Previous $previous -Desired $Value -Action 'Changed' -Revert @{
Kind = 'PowerScheme'
Scheme = $Scheme
SubGroup = $SubGroup
Setting = $Setting
Rail = $rail
Value = $previous
}
$changed = $true
}
return $changed
}
function Set-RegistryValue {
<# Idempotent registry write with reporting and revert capture. #>
param(
[Parameter(Mandatory)][string]$Label,
[Parameter(Mandatory)][string]$Scope,
[Parameter(Mandatory)][string]$Path,
[Parameter(Mandatory)][string]$Name,
[Parameter(Mandatory)]$Value,
[ValidateSet('DWord', 'String', 'MultiString', 'Binary')][string]$Type = 'DWord'
)
$previous = $null
$existed = $false
if (Test-Path $Path) {
$item = Get-ItemProperty -Path $Path -Name $Name -ErrorAction SilentlyContinue
if ($item) {
$previous = $item.$Name
$existed = $true
}
}
$same = $false
if ($existed) {
if ($Type -eq 'MultiString' -or $Type -eq 'Binary') {
$same = ((@($previous) -join ',') -eq (@($Value) -join ','))
}
else {
$same = ($previous -eq $Value)
}
}
if ($same) {
Add-Result -Setting $Label -Scope $Scope -Previous $previous -Desired $Value -Action 'Already set'
return $false
}
if (-not $PSCmdlet.ShouldProcess("$Path\$Name", "$Label -> $Value")) {
Add-Result -Setting $Label -Scope $Scope -Previous $previous -Desired $Value -Action 'Skipped'
return $false
}
if (-not (Test-Path $Path)) { New-Item -Path $Path -Force | Out-Null }
Set-ItemProperty -Path $Path -Name $Name -Value $Value -Type $Type
$prevDisplay = $previous
if (-not $existed) { $prevDisplay = '(absent)' }
Add-Result -Setting $Label -Scope $Scope -Previous $prevDisplay -Desired $Value -Action 'Changed' -Revert @{
Kind = 'Registry'
Path = $Path
Name = $Name
Type = $Type
Value = $previous
Existed = $existed
}
return $true
}
# =============================================================================
# 1. Unhide "Networking connectivity in Standby"
# powercfg cannot read or write a hidden setting, so this must run first.
# =============================================================================
$attributesKey = "HKLM:\SYSTEM\CurrentControlSet\Control\Power\PowerSettings\$StandbyNetworkSetting"
if (Test-Path $attributesKey) {
Set-RegistryValue -Label 'Standby network setting visibility' -Scope 'Machine' `
-Path $attributesKey -Name 'Attributes' -Value 2 | Out-Null
}
else {
Add-Result -Setting 'Standby network setting visibility' -Scope 'Machine' -Previous $null -Desired 'Visible' -Action 'Unsupported'
}
# =============================================================================
# 2. Power scheme values, every scheme, both rails
# =============================================================================
$schemeChanged = $false
foreach ($scheme in Get-PowerSchemeGuid) {
$schemeChanged = (Set-PowerSettingIndex -Label 'Networking connectivity in Standby' `
-Scheme $scheme -SubGroup SUB_NONE -Setting $StandbyNetworkSetting -Value 1) -or $schemeChanged
$wirelessRails = 'Both'
if ($AllowWirelessPowerSavingOnBattery) { $wirelessRails = 'AC' }
$schemeChanged = (Set-PowerSettingIndex -Label 'Wireless Power Saving Mode' `
-Scheme $scheme -SubGroup $WirelessSubGroup -Setting $WirelessPowerSaving -Value 0 -Rails $wirelessRails) -or $schemeChanged
$schemeChanged = (Set-PowerSettingIndex -Label 'USB selective suspend' `
-Scheme $scheme -SubGroup $UsbSubGroup -Setting $UsbSelectiveSuspend -Value 0) -or $schemeChanged
if ($SetUnattendedSleepTimeout -gt 0) {
$schemeChanged = (Set-PowerSettingIndex -Label 'System unattended sleep timeout' `
-Scheme $scheme -SubGroup $SleepSubGroup -Setting $UnattendedSleepTimeout -Value $SetUnattendedSleepTimeout) -or $schemeChanged
}
}
if ($AllowWirelessPowerSavingOnBattery) {
Add-Result -Setting 'Wireless Power Saving Mode' -Scope 'All schemes (DC)' -Previous $null -Desired 'unchanged' -Action 'Skipped'
}
# powercfg writes take effect on the active scheme only after it is re-applied.
if ($schemeChanged -and $PSCmdlet.ShouldProcess('Active power scheme', 'Re-apply')) {
powercfg /setactive SCHEME_CURRENT | Out-Null
}
# =============================================================================
# 3. Group Policy key for standby networking
# The per-scheme value above is reverted by GP refresh and by power-plan
# changes. The Policies value is what makes it stick.
# =============================================================================
if ($SkipPolicyKey) {
Add-Result -Setting 'GP standby networking' -Scope 'Machine' -Previous $null -Desired 1 -Action 'Skipped'
}
else {
$policyKey = Join-Path $PowerPolicyRoot $StandbyNetworkSetting.ToLower()
Set-RegistryValue -Label 'GP standby networking (AC)' -Scope 'Machine' -Path $policyKey -Name 'ACSettingIndex' -Value 1 | Out-Null
Set-RegistryValue -Label 'GP standby networking (DC)' -Scope 'Machine' -Path $policyKey -Name 'DCSettingIndex' -Value 1 | Out-Null
}
# =============================================================================
# 4. RasMan: keep VPN across logoff
# =============================================================================
Set-RegistryValue -Label 'RasMan KeepRasConnections' -Scope 'Machine' `
-Path $RasManParameters -Name 'KeepRasConnections' -Value 1 | Out-Null
# =============================================================================
# 5. TCP keepalive
# Default is 7,200,000 ms (2 hours). Over a long idle window, sessions inside
# the tunnel die even when the tunnel itself is fine.
# =============================================================================
if ($TcpKeepAliveMinutes -le 0) {
Add-Result -Setting 'TCP KeepAliveTime' -Scope 'Machine' -Previous $null -Desired 'unchanged' -Action 'Skipped'
}
else {
Set-RegistryValue -Label 'TCP KeepAliveTime (ms)' -Scope 'Machine' `
-Path $TcpipParameters -Name 'KeepAliveTime' -Value ($TcpKeepAliveMinutes * 60000) | Out-Null
}
# =============================================================================
# 5b. Global IPsec SA idle time
# A long shot, but a documented and cheap one.
#
# The killer for idle tunnels is the quick-mode (child) SA idle timer.
# Microsoft documents the default in MS-IKEE 3.5.2: 5 minutes, or 1 minute
# when the peer advertises the NLBS_PRESENT vendor ID. On expiry the host
# deletes all SAD state for that SA -- which is exactly the "client still
# says Connected, server already sent Delete SA" symptom.
#
# That value lives in IPSEC_TUNNEL_POLICY2.saIdleTimeout, supplied
# programmatically by vpnike.dll inside RasMan. It is hard-coded there and
# overridable only by registry values Microsoft has never published. Do not
# believe RasMan\Parameters\IdleDisconnectSeconds -- that one circulates
# widely and was refuted in the very thread it came from.
#
# Set-NetFirewallSetting -MaxSAIdleTimeSeconds is the WFP GLOBAL default
# (range 300-3600, default 300). Because a per-policy saIdleTimeout beats a
# global default, this probably does not reach the RasMan tunnel policy.
# But it is documented, reversible, and the only sanctioned lever in the
# area, so it is worth setting and measuring.
# =============================================================================
if ($MaxSAIdleTimeSeconds -le 0) {
Add-Result -Setting 'IPsec MaxSAIdleTimeSeconds' -Scope 'Machine' -Previous $null -Desired 'unchanged' -Action 'Skipped'
}
else {
try {
$currentSaIdle = (Get-NetFirewallSetting -PolicyStore ActiveStore -ErrorAction Stop).MaxSAIdleTimeSeconds
if ("$currentSaIdle" -eq "$MaxSAIdleTimeSeconds") {
Add-Result -Setting 'IPsec MaxSAIdleTimeSeconds' -Scope 'Machine' -Previous $currentSaIdle -Desired $MaxSAIdleTimeSeconds -Action 'Already set'
}
elseif (-not $PSCmdlet.ShouldProcess('Global IPsec settings', "MaxSAIdleTimeSeconds -> $MaxSAIdleTimeSeconds")) {
Add-Result -Setting 'IPsec MaxSAIdleTimeSeconds' -Scope 'Machine' -Previous $currentSaIdle -Desired $MaxSAIdleTimeSeconds -Action 'Skipped'
}
else {
Set-NetFirewallSetting -MaxSAIdleTimeSeconds $MaxSAIdleTimeSeconds -ErrorAction Stop
Add-Result -Setting 'IPsec MaxSAIdleTimeSeconds' -Scope 'Machine' -Previous $currentSaIdle -Desired $MaxSAIdleTimeSeconds -Action 'Changed' -Revert @{
Kind = 'FirewallSetting'
Property = 'MaxSAIdleTimeSeconds'
Value = $currentSaIdle
}
}
}
catch {
Add-Result -Setting 'IPsec MaxSAIdleTimeSeconds' -Scope 'Machine' -Previous 'unreadable' -Desired $MaxSAIdleTimeSeconds -Action 'Unsupported'
}
}
# =============================================================================
# 6. NIC power management and wake arming
# Changing these resets the adapter, which drops live VPN sessions. Every
# change is conditional on the current value actually differing, which is
# what keeps re-runs quiet.
# =============================================================================
$adapterWasReset = $false
if ($SkipAdapterPowerManagement) {
Add-Result -Setting 'NIC power management' -Scope 'All physical adapters' -Previous $null -Desired $null -Action 'Skipped'
}
else {
# 'Not Present' adapters are stale profiles with no backing device; querying
# them just fills $Error.
$adapters = @(Get-NetAdapter -Physical -ErrorAction SilentlyContinue |
Where-Object { $_.Status -ne 'Not Present' })
$pmTargets = [ordered]@{
'AllowComputerToTurnOffDevice' = 'Disabled' # the Device Manager checkbox
'DeviceSleepOnDisconnect' = 'Disabled' # do not power down on link loss
'SelectiveSuspend' = 'Disabled' # USB / dock NICs
'WakeOnMagicPacket' = 'Enabled' # keeps the NIC armed in standby
'WakeOnPattern' = 'Enabled'
'ArpOffload' = 'Enabled' # NIC answers ARP while the CPU sleeps
'NSOffload' = 'Enabled' # same, IPv6 neighbor solicitation
}
foreach ($adapter in $adapters) {
$pm = Get-NetAdapterPowerManagement -Name $adapter.Name -ErrorAction SilentlyContinue
if (-not $pm) {
Add-Result -Setting 'NIC power management' -Scope $adapter.Name -Previous $null -Desired $null -Action 'Unsupported'
continue
}
$available = $pm.PSObject.Properties.Name
$dirty = $false
foreach ($prop in $pmTargets.Keys) {
$want = $pmTargets[$prop]
if ($available -notcontains $prop) {
Add-Result -Setting "NIC $prop" -Scope $adapter.Name -Previous $null -Desired $want -Action 'Unsupported'
continue
}
$previous = "$($pm.$prop)"
if ($previous -eq 'Unsupported' -or $previous -eq '') {
Add-Result -Setting "NIC $prop" -Scope $adapter.Name -Previous $previous -Desired $want -Action 'Unsupported'
continue
}
if ($previous -eq $want) {
Add-Result -Setting "NIC $prop" -Scope $adapter.Name -Previous $previous -Desired $want -Action 'Already set'
continue
}
if (-not $PSCmdlet.ShouldProcess($adapter.Name, "$prop -> $want (resets adapter)")) {
Add-Result -Setting "NIC $prop" -Scope $adapter.Name -Previous $previous -Desired $want -Action 'Skipped'
continue
}
# The cmdlet has no per-property parameters; you mutate the object
# and pipe it back via -InputObject.
$pm.$prop = $want
Add-Result -Setting "NIC $prop" -Scope $adapter.Name -Previous $previous -Desired $want -Action 'Changed' -Revert @{
Kind = 'NetAdapterPM'
Adapter = $adapter.Name
Property = $prop
Value = $previous
}
$dirty = $true
}
if ($dirty) {
try {
Set-NetAdapterPowerManagement -InputObject $pm -ErrorAction Stop
$adapterWasReset = $true
}
catch {
Write-Warning "Could not apply power management to '$($adapter.Name)': $($_.Exception.Message)"
}
}
}
}
# =============================================================================
# 7. NIC driver advanced properties
# This is the layer that actually kills the link in standby. The generic
# "Allow the computer to turn off this device" shim above does not cover it.
# =============================================================================
if ($SkipAdapterAdvancedProperties) {
Add-Result -Setting 'NIC advanced properties' -Scope 'All physical adapters' -Previous $null -Desired $null -Action 'Skipped'
}
else {
$adapters = @(Get-NetAdapter -Physical -ErrorAction SilentlyContinue |
Where-Object { $_.Status -ne 'Not Present' })
foreach ($adapter in $adapters) {
$props = @(Get-NetAdapterAdvancedProperty -Name $adapter.Name -AllProperties -ErrorAction SilentlyContinue)
if (-not $props.Count) { continue }
foreach ($keyword in $AdapterKeywords.Keys) {
$want = $AdapterKeywords[$keyword]
$prop = $props | Where-Object { $_.RegistryKeyword -eq $keyword } | Select-Object -First 1
if (-not $prop) { continue } # driver does not expose it; nothing to report
$previous = "$($prop.RegistryValue)"
# Do not fight a driver that only accepts certain values.
$valid = $null
if ($prop.PSObject.Properties.Name -contains 'ValidRegistryValues') {
$valid = $prop.ValidRegistryValues
}
if ($valid -and @($valid).Count -and (@($valid) -notcontains $want)) {
Add-Result -Setting "NIC $keyword" -Scope $adapter.Name -Previous $previous -Desired $want -Action 'Unsupported'
continue
}
if ($previous -eq $want) {
Add-Result -Setting "NIC $keyword" -Scope $adapter.Name -Previous $previous -Desired $want -Action 'Already set'
continue
}
if (-not $PSCmdlet.ShouldProcess($adapter.Name, "$keyword -> $want (resets adapter)")) {
Add-Result -Setting "NIC $keyword" -Scope $adapter.Name -Previous $previous -Desired $want -Action 'Skipped'
continue
}
try {
Set-NetAdapterAdvancedProperty -Name $adapter.Name -RegistryKeyword $keyword `
-RegistryValue $want -ErrorAction Stop
Add-Result -Setting "NIC $keyword" -Scope $adapter.Name -Previous $previous -Desired $want -Action 'Changed' -Revert @{
Kind = 'NetAdapterAdvanced'
Adapter = $adapter.Name
Keyword = $keyword
Value = $previous
}
$adapterWasReset = $true
}
catch {
Add-Result -Setting "NIC $keyword" -Scope $adapter.Name -Previous $previous -Desired $want -Action 'Unsupported'
}
}
}
}
# =============================================================================
# 8. VPN connections: idle timeout and credential caching
# Must run BEFORE the rasphone.pbk edits: Set-VpnConnection rewrites the same
# file and would clobber them.
# =============================================================================
function Test-CanPrompt {
<# A human is present and we are allowed to bother them. #>
return ([Environment]::UserInteractive -and -not $Silent -and -not $WhatIfPreference)
}
function Select-VpnInteractively {
<#
Multi-select numbered picker. Accepts a single number, a comma or space
separated list, 'A' for all, or 0/Enter for none.
Re-prompts on anything else rather than accepting it. Any invalid token
rejects the WHOLE answer -- silently keeping the good half of "2,9" and
dropping the rest would leave the user believing they picked two.
#>
param(
[Parameter(Mandatory)][array]$Candidates,
[string]$Prompt = 'Which VPN(s) should I configure?',
[int]$MaxAttempts = 5
)
$max = $Candidates.Count
Write-Host ''
Write-Host $Prompt -ForegroundColor Cyan
Write-Host ''
$i = 0
foreach ($c in $Candidates) {
$i++
$scopeTag = 'this user'
if ($c.AllUserConnection) { $scopeTag = 'all users' }
Write-Host (" {0}. {1,-30} {2,-14} {3,-9} {4}" -f `
$i, $c.Name, $c.ConnectionStatus, $c.TunnelType, $scopeTag)
}
Write-Host ' A. All of them'
Write-Host ' 0. None - exit without changing any VPN profile'
Write-Host ''
for ($attempt = 1; $attempt -le $MaxAttempts; $attempt++) {
$answer = "$(Read-Host "Choose 1-$max, a comma-separated list, A, or 0")".Trim()
if ($answer -eq '' -or $answer -eq '0') { return @() }
if ($answer -match '^(a|all)$') { return @($Candidates) }
$picked = [System.Collections.Generic.List[object]]::new()
$bad = [System.Collections.Generic.List[string]]::new()
foreach ($token in @($answer -split '[,\s]+' | Where-Object { $_ })) {
$idx = 0
if ([int]::TryParse($token, [ref]$idx) -and $idx -ge 1 -and $idx -le $max) {
$c = $Candidates[$idx - 1]
$dupe = $picked | Where-Object {
$_.Name -eq $c.Name -and $_.AllUserConnection -eq $c.AllUserConnection
}
if (-not $dupe) { $picked.Add($c) }
}
else {
$bad.Add($token)
}
}
if ($bad.Count) {
Write-Host (" Not valid: {0}. Enter numbers 1-{1}, A, or 0." -f ($bad -join ', '), $max) -ForegroundColor Yellow
continue
}
if (-not $picked.Count) {
Write-Host " Nothing recognised. Enter numbers 1-$max, A, or 0." -ForegroundColor Yellow
continue
}
return @($picked)
}
Write-Warning "No valid choice after $MaxAttempts attempts. Treating that as 'none'."
return @()
}
function Select-SingleVpnInteractively {
<#
Single-select numbered picker, for settings Windows permits on exactly
one profile. Returns one connection object, or $null for skip.
Re-prompts on invalid input.
#>
param(
[Parameter(Mandatory)][array]$Candidates,
[AllowNull()][string]$CurrentHolder,
[string]$Prompt = 'Which VPN should get Always On?',
[int]$MaxAttempts = 5
)
$max = $Candidates.Count
Write-Host ''
Write-Host $Prompt -ForegroundColor Cyan
Write-Host ' Only one connection can hold it.' -ForegroundColor DarkGray
Write-Host ''
$i = 0
foreach ($c in $Candidates) {
$i++
$marks = @()
if ($c.ConnectionStatus -eq 'Connected') { $marks += 'connected' }
if ($CurrentHolder -and $c.Name -eq $CurrentHolder) { $marks += 'current holder' }
$suffix = ''
if ($marks.Count) { $suffix = " <- $($marks -join ', ')" }
Write-Host (" {0}. {1}{2}" -f $i, $c.Name, $suffix)
}
Write-Host ' 0. Skip - leave Always On alone'
Write-Host ''
for ($attempt = 1; $attempt -le $MaxAttempts; $attempt++) {
$answer = "$(Read-Host "Choose 0-$max")".Trim()
if ($answer -eq '' -or $answer -eq '0') { return $null }
$idx = 0
if ([int]::TryParse($answer, [ref]$idx) -and $idx -ge 1 -and $idx -le $max) {
return $Candidates[$idx - 1]
}
Write-Host " '$answer' is not a choice. Enter 1-$max, or 0 to skip." -ForegroundColor Yellow
}
Write-Warning "No valid choice after $MaxAttempts attempts. Always On left unchanged."
return $null
}
function Get-TargetVpnConnection {
$all = @()
$all += @(Get-VpnConnection -ErrorAction SilentlyContinue)
$all += @(Get-VpnConnection -AllUserConnection -ErrorAction SilentlyContinue)
# A connection can appear in both lists on some systems. De-dupe on
# name + scope so we do not process it twice.
$seen = @{}
$unique = foreach ($c in $all) {
$key = "$($c.Name)|$($c.AllUserConnection)"
if ($seen.ContainsKey($key)) { continue }
$seen[$key] = $true
$c
}
$selected = @($unique)
if (-not $selected.Count) {
$script:VpnScopeNote = 'no VPN profiles found'
return @()
}
# 1. Explicit names win outright; connected state is irrelevant.
if ($VpnName) {
$script:VpnScopeNote = 'named only (-VpnName)'
return @($selected | Where-Object { $VpnName -contains $_.Name })
}
# 2. Opt-in narrowing to the live tunnel only.
if ($ConnectedOnly) {
$live = @($selected | Where-Object { $_.ConnectionStatus -eq 'Connected' })
if ($live.Count) {
$script:VpnScopeNote = 'connected only (-ConnectedOnly)'
return $live
}
if (Test-CanPrompt) {
Write-Host ''
Write-Warning '-ConnectedOnly was specified but no VPN is connected.'
$chosen = Select-VpnInteractively -Candidates $selected `
-Prompt 'Which VPN(s) should I configure instead?'
if ($chosen.Count) {
$script:VpnScopeNote = "$($chosen.Count) chosen interactively"
return $chosen
}
Write-Warning 'Nothing selected. No VPN profile will be changed.'
$script:VpnScopeNote = 'none selected'
return @()
}
Write-Warning '-ConnectedOnly was specified but no VPN is connected, and this'
Write-Warning 'session cannot prompt. No VPN profile was changed.'
$script:VpnScopeNote = 'connected only -- none connected'
return @()
}
# 3. Default: every profile.
#
# Everything this script sets per connection is a rasphone.pbk key --
# IdleDisconnectSeconds included, since Set-VpnConnection just writes it
# into the same phonebook. Phonebook keys are inert configuration: they
# cost nothing on a disconnected profile and mean a VPN you dial next
# month is already correct. So there is no reason to narrow this, and
# narrowing it would silently leave other profiles wrong.
#
# Connected state still matters, but only for Always On, which Windows
# permits on exactly one profile. That target is resolved separately.
$script:VpnScopeNote = 'all VPN profiles'
return $selected
}
# Set by Get-TargetVpnConnection so the banner can say how the list was chosen.
$VpnScopeNote = 'all VPN profiles'
$connections = Get-TargetVpnConnection
# Always show what was found. Everything below acts on exactly this list, so it
# should never be a mystery which profiles were touched.
if ($connections.Count) {
Write-Host "--- Will configure: $VpnScopeNote " -ForegroundColor Cyan -NoNewline
Write-Host ('-' * [Math]::Max(1, 52 - $VpnScopeNote.Length)) -ForegroundColor Cyan
$n = 0
foreach ($c in $connections) {
$n++
$scopeTag = 'this user'
if ($c.AllUserConnection) { $scopeTag = 'all users' }
Write-Host (" {0}. {1,-30} {2,-14} {3,-9} {4}" -f `
$n, $c.Name, $c.ConnectionStatus, $c.TunnelType, $scopeTag)
}
Write-Host ''
}
if (-not $connections.Count) {
Write-Warning 'No built-in Windows VPN connections found. Sections 8-10 have nothing to do.'
Write-Warning 'If you use AnyConnect, GlobalProtect, FortiClient, OpenVPN or WireGuard,'
Write-Warning 'their timeouts live in the client -- nothing in this script reaches them.'
Add-Result -Setting 'VPN connections' -Scope 'Machine' -Previous 'none found' -Desired $null -Action 'Info'
}
if ($SkipVpnIdle) {
Add-Result -Setting 'VPN IdleDisconnectSeconds' -Scope 'All connections' -Previous $null -Desired $IdleDisconnectSeconds -Action 'Skipped'
}
else {
foreach ($vpn in $connections) {
$allUserArgs = @{}
$scope = $vpn.Name
if ($vpn.AllUserConnection) {
$allUserArgs = @{ AllUserConnection = $true }
$scope = "$($vpn.Name) (all users)"
}
# Idle timeout
if ($vpn.IdleDisconnectSeconds -eq $IdleDisconnectSeconds) {
Add-Result -Setting 'VPN IdleDisconnectSeconds' -Scope $scope -Previous $vpn.IdleDisconnectSeconds -Desired $IdleDisconnectSeconds -Action 'Already set'
}
elseif (-not $PSCmdlet.ShouldProcess($scope, "IdleDisconnectSeconds -> $IdleDisconnectSeconds")) {
Add-Result -Setting 'VPN IdleDisconnectSeconds' -Scope $scope -Previous $vpn.IdleDisconnectSeconds -Desired $IdleDisconnectSeconds -Action 'Skipped'
}
else {
Set-VpnConnection -Name $vpn.Name -IdleDisconnectSeconds $IdleDisconnectSeconds -Force @allUserArgs
Add-Result -Setting 'VPN IdleDisconnectSeconds' -Scope $scope -Previous $vpn.IdleDisconnectSeconds -Desired $IdleDisconnectSeconds -Action 'Changed' -Revert @{
Kind = 'VpnConnection'
Name = $vpn.Name
AllUser = [bool]$vpn.AllUserConnection
Property = 'IdleDisconnectSeconds'
Value = $vpn.IdleDisconnectSeconds
}
}
# Credential caching -- Always On cannot reconnect without it.
if ($vpn.RememberCredential) {
Add-Result -Setting 'VPN RememberCredential' -Scope $scope -Previous $vpn.RememberCredential -Desired $true -Action 'Already set'
}
elseif (-not $PSCmdlet.ShouldProcess($scope, 'RememberCredential -> True')) {
Add-Result -Setting 'VPN RememberCredential' -Scope $scope -Previous $vpn.RememberCredential -Desired $true -Action 'Skipped'
}
else {
Set-VpnConnection -Name $vpn.Name -RememberCredential $true -Force @allUserArgs
Add-Result -Setting 'VPN RememberCredential' -Scope $scope -Previous $vpn.RememberCredential -Desired $true -Action 'Changed' -Revert @{
Kind = 'VpnConnection'
Name = $vpn.Name
AllUser = [bool]$vpn.AllUserConnection
Property = 'RememberCredential'
Value = [bool]$vpn.RememberCredential
}
}
}
}
# =============================================================================
# 9. rasphone.pbk: IKEv2 MOBIKE outage tolerance
# The most important single setting for surviving sleep. MOBIKE is what lets
# an IKEv2 tunnel ride out the network gap sleep creates. Without it,
# "networking in standby" gains you almost nothing.
# DisableMobility and NetworkOutageTime are mutually exclusive: mobility must
# be ON (0) for an outage time to mean anything.
# =============================================================================
function Set-PbkSetting {
<#
Line-based rasphone.pbk editor. Replaces the value if the key exists in
the target profile, appends it inside the profile if it does not.
Returns @{ Report = <key -> @{Previous;Changed}>; Backup = <path or $null> }
#>
param(
[Parameter(Mandatory)][string]$Path,
[Parameter(Mandatory)][string]$ProfileName,
[Parameter(Mandatory)][hashtable]$Settings
)
$report = @{}
foreach ($k in $Settings.Keys) { $report[$k] = @{ Previous = $null; Changed = $false } }
$result = @{ Report = $report; Backup = $null }
if (-not (Test-Path $Path)) { return $result }
$lines = [System.Collections.Generic.List[string]]::new()
Get-Content -LiteralPath $Path | ForEach-Object { $lines.Add($_) }
# Locate the profile's line range.
$header = "[$ProfileName]"
$start = -1
for ($i = 0; $i -lt $lines.Count; $i++) {
if ($lines[$i].Trim() -eq $header) { $start = $i; break }
}
if ($start -lt 0) { return $result }
$end = $lines.Count
for ($i = $start + 1; $i -lt $lines.Count; $i++) {
if ($lines[$i].Trim() -match '^\[.+\]$') { $end = $i; break }
}
# Determine what needs writing.
$pending = @{}
foreach ($key in $Settings.Keys) {
$want = "$($Settings[$key])"
$found = $false
for ($i = $start + 1; $i -lt $end; $i++) {
if ($lines[$i] -match "^\s*$([regex]::Escape($key))\s*=\s*(.*)$") {
$found = $true
$report[$key].Previous = $Matches[1].Trim()
if ($report[$key].Previous -ne $want) { $pending[$key] = @{ Index = $i; Value = $want } }
break
}
}
if (-not $found) {
$report[$key].Previous = '(absent)'
$pending[$key] = @{ Index = -1; Value = $want }
}
}
if (-not $pending.Count) { return $result }
# Back up once, then apply. The backup is what the revert script restores.
$backup = "$Path.$(Get-Date -Format 'yyyyMMdd-HHmmss').bak"
Copy-Item -LiteralPath $Path -Destination $backup -Force
$result.Backup = $backup
Write-Verbose "Backed up $Path to $backup"
# Replace in place first (indexes stay valid), then append the absent ones.
foreach ($key in $pending.Keys) {
$idx = $pending[$key].Index
if ($idx -ge 0) {
$lines[$idx] = "$key=$($pending[$key].Value)"
$report[$key].Changed = $true
}
}
$insertAt = $end
foreach ($key in $pending.Keys) {
if ($pending[$key].Index -lt 0) {
$lines.Insert($insertAt, "$key=$($pending[$key].Value)")
$insertAt++
$report[$key].Changed = $true
}
}
# rasphone.pbk is ASCII with CRLF line endings. Do not let PowerShell
# helpfully write UTF-8 with a BOM here; RasMan will not parse it.
[System.IO.File]::WriteAllText($Path, ($lines -join "`r`n") + "`r`n", [System.Text.Encoding]::ASCII)
return $result
}
if ($SkipRasphone) {
Add-Result -Setting 'rasphone.pbk (MOBIKE etc.)' -Scope 'All connections' -Previous $null -Desired $null -Action 'Skipped'
}
elseif ($connections.Count) {
$pbkSettings = @{
'CacheCredentials' = '1' # so reconnect after wake does not prompt
}
if ($NetworkOutageTime -gt 0) {
$pbkSettings['DisableMobility'] = '0' # MOBIKE on
$pbkSettings['NetworkOutageTime'] = "$NetworkOutageTime"
}
# Auto-redial. THIS is the per-connection reconnect that works on every
# profile, unlike Always On (see section 10, which is limited to one).
# These settings exist in the phonebook but not in the modern Settings app.
if (-not $SkipRedial) {
$pbkSettings['RedialOnLinkFailure'] = '1' # redial when the link drops
$pbkSettings['RedialAttempts'] = "$RedialAttempts" # max 99
$pbkSettings['RedialSeconds'] = "$RedialSeconds" # wait between attempts
}
foreach ($vpn in $connections) {
if ($vpn.AllUserConnection) {
$pbkPath = $AllUserPbk
$scope = "$($vpn.Name) (all users)"
}
else {
$pbkPath = $UserPbk
$scope = $vpn.Name
}
if (-not (Test-Path $pbkPath)) {
Add-Result -Setting 'rasphone.pbk' -Scope $scope -Previous 'file not found' -Desired $null -Action 'Unsupported'
continue
}
if (-not $PSCmdlet.ShouldProcess($scope, "rasphone.pbk -> $(($pbkSettings.Keys | Sort-Object) -join ', ')")) {
foreach ($k in $pbkSettings.Keys) {
Add-Result -Setting "pbk $k" -Scope $scope -Previous $null -Desired $pbkSettings[$k] -Action 'Skipped'
}
continue
}
$outcome = Set-PbkSetting -Path $pbkPath -ProfileName $vpn.Name -Settings $pbkSettings
$report = $outcome.Report
$backup = $outcome.Backup
foreach ($k in ($report.Keys | Sort-Object)) {
if ($null -eq $report[$k].Previous) {
Add-Result -Setting "pbk $k" -Scope $scope -Previous $null -Desired $pbkSettings[$k] -Action 'Unsupported'
}
elseif ($report[$k].Changed) {
$rev = $null
if ($backup) { $rev = @{ Kind = 'PbkBackup'; Path = $pbkPath; Backup = $backup } }
Add-Result -Setting "pbk $k" -Scope $scope -Previous $report[$k].Previous -Desired $pbkSettings[$k] -Action 'Changed' -Revert $rev
}
else {
Add-Result -Setting "pbk $k" -Scope $scope -Previous $report[$k].Previous -Desired $pbkSettings[$k] -Action 'Already set'
}
}
}
Write-Host 'rasphone.pbk changes take effect on the NEXT dial, not on a live tunnel.' -ForegroundColor DarkGray
}
# =============================================================================
# 10. Always On / auto-trigger
#
# READ THIS BEFORE EXPECTING TOO MUCH.
#
# Always On is real and it is what reconnects on user sign-in, network
# change and device screen on. But Microsoft's own documentation is explicit
# about two limits:
#
# * ONE PROFILE ONLY. "When a device has multiple profiles with Always On
# triggers, the user can specify the active profile... only one profile,
# and therefore only one user, is able to use the Always On triggers."
# You cannot turn this on for all your VPNs. It is one, machine-wide.
# AutoTriggerProfileEntryName is a single REG_SZ, not a list.
#
# * IT IS NOT IN THE NORMAL UI. Always On is configured through the VPNv2
# CSP / ProfileXML (Intune, MDM) or Add-VpnConnection -AlwaysOn at
# creation time. Set-VpnConnection has no -AlwaysOn parameter, so a
# hand-made connection cannot be switched to Always On through the
# cmdlets at all. The "Let apps automatically use this VPN connection"
# checkbox in Settings only chooses WHICH already-Always-On profile is
# active -- it does not add Always On to a profile that lacks it. If your
# profiles were created by hand, there is no checkbox to find.
#
# So this section writes the RasMan auto-trigger values directly, for one
# named connection, and is opt-in via -AlwaysOnVpnName. For reconnect
# behaviour across ALL of your connections, section 9's RedialOnLinkFailure
# is the mechanism that actually scales.
#
# Caveat from Microsoft: auto-triggered VPN does not work if Folder
# Redirection for AppData is enabled, because that moves rasphone.pbk.
# =============================================================================
$rebootForAlwaysOn = $false
$autoTriggerName = $null
if (Test-Path $RasManConfig) {
$cfg = Get-ItemProperty -Path $RasManConfig -ErrorAction SilentlyContinue
if ($cfg -and ($cfg.PSObject.Properties.Name -contains 'AutoTriggerProfileEntryName')) {
$autoTriggerName = $cfg.AutoTriggerProfileEntryName
}
}
function Resolve-AlwaysOnTarget {
<#
Works out WHICH connection should get Always On when no name was passed.
There is no such thing as a "currently selected" VPN to read. Windows
keeps no default-entry or last-used pointer for VPN profiles that is
documented or queryable -- rasphone.pbk has no default-entry concept and
the Settings app persists no selection. So the ladder below uses signals
that DO exist, strongest first, and refuses to guess when the answer is
genuinely ambiguous. Guessing wrong here is not harmless: writing the
auto-trigger values DISPLACES whatever profile currently holds the one
Always On slot.
#>
param([Parameter(Mandatory)][AllowEmptyCollection()][array]$Candidates)
# 1. A live tunnel is the strongest statement of intent available.
$live = @($Candidates | Where-Object { $_.ConnectionStatus -eq 'Connected' })
if ($live.Count -eq 1) {
return [pscustomobject]@{ Connection = $live[0]; Reason = 'the only currently connected VPN' }
}
if ($live.Count -gt 1) {
return [pscustomobject]@{ Connection = $null; Reason = "$($live.Count) VPNs are connected at once: $(($live | ForEach-Object Name) -join ', ')" }
}
# 2. Nothing connected. If a profile already holds the Always On slot, keep
# it rather than silently moving it somewhere else.
if ($autoTriggerName) {
$incumbent = $Candidates | Where-Object { $_.Name -eq $autoTriggerName } | Select-Object -First 1
if ($incumbent) {
return [pscustomobject]@{ Connection = $incumbent; Reason = 'already holds the Always On slot; refreshing it' }
}
}
# 3. Only one VPN exists on the machine, so there is nothing to be wrong about.
if ($Candidates.Count -eq 1) {
return [pscustomobject]@{ Connection = $Candidates[0]; Reason = 'the only VPN connection on this machine' }
}
if ($Candidates.Count -eq 0) {
return [pscustomobject]@{ Connection = $null; Reason = 'no VPN connections found' }
}
return [pscustomobject]@{ Connection = $null; Reason = "$($Candidates.Count) VPNs exist and none is connected" }
}
# Resolve the target before deciding what to do.
$aoTarget = $null
$aoReason = $null
$aoWanted = -not $SkipAlwaysOn
if ($AlwaysOnVpnName) {
$aoTarget = $connections | Where-Object { $_.Name -eq $AlwaysOnVpnName } | Select-Object -First 1
$aoReason = 'named explicitly'
if (-not $aoTarget) {
Write-Warning "No VPN connection named '$AlwaysOnVpnName' was found. Always On not configured."
if ($connections.Count) {
Write-Warning "Available: $(($connections | ForEach-Object Name) -join ', ')"
}
}
}
elseif ($aoWanted) {
$resolved = Resolve-AlwaysOnTarget -Candidates $connections
$aoTarget = $resolved.Connection
$aoReason = $resolved.Reason
if ($aoTarget) {
Write-Host ''
Write-Host "Always On target auto-selected: '$($aoTarget.Name)'" -ForegroundColor Cyan
Write-Host " Reason: $aoReason" -ForegroundColor DarkGray
}
else {
# Ambiguous. Ask, if there is a human here to ask. Only one profile can
# hold the slot, so a silent guess could disarm the one that matters.
if ((Test-CanPrompt) -and $connections.Count -gt 0) {
$aoTarget = Select-SingleVpnInteractively -Candidates $connections `
-CurrentHolder $autoTriggerName `
-Prompt "Which VPN should get Always On? ($aoReason)"
if ($aoTarget) {
$aoReason = 'chosen interactively'
Write-Host " Selected: $($aoTarget.Name)" -ForegroundColor Green
}
else {
Write-Host ' Skipped. Always On left unchanged.' -ForegroundColor DarkGray
}
}
else {
Write-Host ''
Write-Warning "Cannot auto-select an Always On target: $aoReason."
Write-Warning 'Always On left unchanged. Re-run with -AlwaysOnVpnName ''<name>'','
Write-Warning 'or without -Silent / -WhatIf to be prompted.'
}
}
}
if ($aoWanted) {
$target = $aoTarget
if (-not $target) {
$scopeLabel = 'unresolved'
if ($AlwaysOnVpnName) { $scopeLabel = $AlwaysOnVpnName }
Add-Result -Setting 'Always On (auto-trigger)' -Scope $scopeLabel -Previous $autoTriggerName -Desired 'Enabled' -Action 'Unsupported'
}
elseif ($autoTriggerName -eq $target.Name) {
Add-Result -Setting 'Always On (auto-trigger)' -Scope $target.Name -Previous $autoTriggerName -Desired $target.Name -Action 'Already set'
Write-Host ''
Write-Host "Always On already points at '$($target.Name)' ($aoReason). Nothing to do." -ForegroundColor Green
}
else {
# All-user connections live in the ProgramData phonebook and pair with the
# Everyone SID; per-user connections use the roaming profile path and the
# actual user SID.
if ($target.AllUserConnection) {
$pbkForTarget = $AllUserPbk
$sidForTarget = 'S-1-1-0'
}
else {
$pbkForTarget = $UserPbk
$sidForTarget = ([System.Security.Principal.WindowsIdentity]::GetCurrent()).User.Value
}
# STEP 1 -- clear the opt-out list FIRST. This is the single most common
# reason Always On silently refuses to arm: Windows treats a past
# un-check as a permanent user preference and will ignore everything
# below while the profile name sits in this list.
$disabled = $null
$cfg = Get-ItemProperty -Path $RasManConfig -ErrorAction SilentlyContinue
if ($cfg -and ($cfg.PSObject.Properties.Name -contains 'AutoTriggerDisabledProfilesList')) {
$disabled = @($cfg.AutoTriggerDisabledProfilesList)
}
$targetName = $target.Name
if ($disabled -and ($disabled -icontains $targetName)) {
$trimmed = @($disabled | Where-Object { $_ -ne $targetName })
Set-RegistryValue -Label 'Always On opt-out list (cleared)' -Scope $targetName `
-Path $RasManConfig -Name 'AutoTriggerDisabledProfilesList' -Value $trimmed -Type MultiString | Out-Null
}
else {
Add-Result -Setting 'Always On opt-out list' -Scope $targetName -Previous 'not listed' -Desired 'not listed' -Action 'Already set'
}
# STEP 2 -- the four values that actually arm it. All four are required;
# writing a subset does nothing.
Set-RegistryValue -Label 'Always On UserSID' -Scope $targetName `
-Path $RasManConfig -Name 'UserSID' -Value $sidForTarget -Type String | Out-Null
Set-RegistryValue -Label 'Always On entry name' -Scope $targetName `
-Path $RasManConfig -Name 'AutoTriggerProfileEntryName' -Value $targetName -Type String | Out-Null
Set-RegistryValue -Label 'Always On phonebook path' -Scope $targetName `
-Path $RasManConfig -Name 'AutoTriggerProfilePhonebookPath' -Value $pbkForTarget -Type String | Out-Null
# AutoTriggerProfileGUID is REG_BINARY -- the GUID's raw 16-byte array,
# NOT the '{...}' string form. Writing it as a string looks like it
# worked and then silently never triggers.
[guid]$targetGuid = $target.Guid
Set-RegistryValue -Label 'Always On profile GUID' -Scope $targetName `
-Path $RasManConfig -Name 'AutoTriggerProfileGUID' -Value $targetGuid.ToByteArray() -Type Binary | Out-Null
Write-Host ''
Write-Host "Always On set for '$targetName' ($aoReason)." -ForegroundColor Yellow
if ($autoTriggerName -and $autoTriggerName -ne $targetName) {
Write-Host " This displaced '$autoTriggerName' -- only one profile can hold it." -ForegroundColor Yellow
}
# RasMan caches this at service start. Without a restart the values sit
# in the registry doing nothing, which looks exactly like "it did not work".
if ($RestartRasMan) {
if ($PSCmdlet.ShouldProcess('RasMan', 'Restart (kills the shared svchost -k netsvcs group)')) {
Write-Host ' Restarting RasMan...' -ForegroundColor Yellow
try {
# Restart-Service does not work on RasMan. Killing the PID is
# the only reliable route, and it takes co-hosted netsvcs
# services down with it.
$rasPid = (Get-CimInstance -ClassName Win32_Service -Filter "Name='RasMan'").ProcessId
if ($rasPid) {
Stop-Process -Id $rasPid -Force
Start-Sleep -Seconds 5
}
Start-Service RasMan
Add-Result -Setting 'RasMan restart' -Scope 'Machine' -Previous 'running' -Desired 'restarted' -Action 'Changed'
}
catch {
Write-Warning "Could not restart RasMan: $($_.Exception.Message). Reboot to apply."
Add-Result -Setting 'RasMan restart' -Scope 'Machine' -Previous 'running' -Desired 'restarted' -Action 'Unsupported'
}
}
}
else {
$rebootForAlwaysOn = $true
Add-Result -Setting 'RasMan restart' -Scope 'Machine' -Previous $null -Desired 'reboot required' -Action 'Skipped'
}
}
}
elseif ($autoTriggerName) {
Add-Result -Setting 'Always On (auto-trigger)' -Scope $autoTriggerName -Previous 'Enabled' -Desired 'unchanged' -Action 'Info'
}
else {
Add-Result -Setting 'Always On (auto-trigger)' -Scope 'None' -Previous 'Not configured' -Desired 'see output' -Action 'Info'
}
# =============================================================================
# Report
# =============================================================================
Write-Host ''
Write-Host '--- Results ------------------------------------------------------------' -ForegroundColor Cyan
$results | Where-Object { $_.Action -ne 'Info' } |
Select-Object Setting, Scope, Previous, Desired, Action | Format-Table -AutoSize
$changedCount = @($results | Where-Object { $_.Action -eq 'Changed' }).Count
if ($changedCount -eq 0) {
Write-Host 'Nothing to do - all settings already correct.' -ForegroundColor Green
}
else {
Write-Host "$changedCount setting(s) changed." -ForegroundColor Yellow
if ($adapterWasReset) {
Write-Host 'A network adapter was reset - reconnect any VPN that dropped.' -ForegroundColor Yellow
}
}
# JSON changelog. Revert-VpnSleepSettings.ps1 consumes this.
$jsonPath = $null
if ($LogPath -and (Test-Path $LogPath)) {
$stamp = Get-Date -Format 'yyyyMMdd-HHmmss'
$jsonPath = Join-Path $LogPath "VpnSleepSettings-$stamp.json"
$capability = 'Unknown'
$capRow = @($results | Where-Object { $_.Setting -eq 'Sleep capability' }) | Select-Object -First 1
if ($capRow) { $capability = $capRow.Previous }
$payload = [pscustomobject]@{
SchemaVersion = 1
Timestamp = (Get-Date).ToString('o')
Computer = $env:COMPUTERNAME
User = "$env:USERDOMAIN\$env:USERNAME"
ScriptVersion = '3.0'
WhatIf = [bool]$WhatIfPreference
IdleDisconnectSeconds = $IdleDisconnectSeconds
NetworkOutageTime = $NetworkOutageTime
HibernateEnabled = $hibernateOn
SleepCapability = $capability
Results = $results
}
$payload | ConvertTo-Json -Depth 6 | Set-Content -LiteralPath $jsonPath -Encoding UTF8
}
# --- Next steps --------------------------------------------------------------
# Only actions the user has to take. Everything explanatory lives in the
# comment-based help: run Get-Help .\Set-VpnSleepSettings.ps1 -Full
$steps = [System.Collections.Generic.List[string]]::new()
if ($adapterWasReset) {
$steps.Add('Reconnect any VPN that dropped (an adapter was reset).')
}
if (-not $SkipRasphone -and $connections.Count -and $changedCount -gt 0) {
$steps.Add('Reconnect your VPN once so the phonebook changes take effect.')
}
if ($rebootForAlwaysOn) {
$steps.Add('Reboot to activate Always On, or re-run adding -RestartRasMan.')
}
if ($jsonPath) {
$steps.Add("To undo: .\Revert-VpnSleepSettings.ps1 -WhatIf")
}
if ($steps.Count) {
Write-Host ''
Write-Host 'Next steps:' -ForegroundColor Cyan
$stepNo = 1
foreach ($s in $steps) {
Write-Host (" {0}. {1}" -f $stepNo, $s)
$stepNo++
}
}
Write-Host ''
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment