Last active
September 4, 2026 18:25
-
-
Save tcartwright/937202e2cad64321eee0f0db61b736f1 to your computer and use it in GitHub Desktop.
POWERSHELL: Configures Windows so VPN connections survive sleep (Modern Standby).
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| #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 '' |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| #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