# CH-J Server Manager - fixed remote helper for Windows pagefile inspection and configuration. # Started through the short -EncodedCommand bootstrap; arguments arrive as JSON on stdin. # Changes require an elevated token, are backed up first and never restart Windows. param([string]$ArgsJson) Set-StrictMode -Version 2 $ErrorActionPreference = 'Stop' $ProgressPreference = 'SilentlyContinue' $StateDir = Join-Path $env:ProgramData 'CH-J\chj-system-monitor' $MemoryKey = 'HKLM:\SYSTEM\CurrentControlSet\Control\Session Manager\Memory Management' $CrashKey = 'HKLM:\SYSTEM\CurrentControlSet\Control\CrashControl' function Fail([string]$Code, [string]$Message) { throw [System.Exception]::new("CHJ:${Code}:$Message") } function Prop($Object, [string]$Name) { if ($null -ne $Object -and $Object.PSObject.Properties[$Name]) { return $Object.$Name }; return $null } function Elevated { (New-Object Security.Principal.WindowsPrincipal([Security.Principal.WindowsIdentity]::GetCurrent())).IsInRole([Security.Principal.WindowsBuiltInRole]::Administrator) } function Require-Elevation { if (-not (Elevated)) { Fail 'WINDOWS_ELEVATION_REQUIRED' 'Changing the pagefile requires an SSH session with an elevated administrator token. CH-J does not bypass UAC.' } } function Boot-Time { try { return (Get-CimInstance Win32_OperatingSystem -ErrorAction Stop).LastBootUpTime.ToUniversalTime().ToString('o') } catch { return $null } } function Get-State { $errors = @() $memory = Get-ItemProperty $MemoryKey -ErrorAction SilentlyContinue $state = [ordered]@{ elevated = (Elevated) automatic = $null configured = @() active = @() registryPagingFiles = @([string[]](Prop $memory 'PagingFiles') | Where-Object { $_ }) existingPageFiles = @([string[]](Prop $memory 'ExistingPageFiles') | Where-Object { $_ }) crashDumpEnabled = [int](Prop (Get-ItemProperty $CrashKey -ErrorAction SilentlyContinue) 'CrashDumpEnabled') volumes = @([IO.DriveInfo]::GetDrives() | Where-Object { $_.DriveType -eq 'Fixed' -and $_.IsReady } | ForEach-Object { [ordered]@{ drive = $_.Name.Substring(0, 2); format = [string]$_.DriveFormat; free = [double]$_.AvailableFreeSpace; total = [double]$_.TotalSize } }) systemDrive = [string]$env:SystemDrive commitLimit = $null; committed = $null pending = $null bootTime = (Boot-Time) errors = $errors } try { $state.automatic = [bool](Get-CimInstance Win32_ComputerSystem -ErrorAction Stop).AutomaticManagedPagefile } catch { $state.errors += "Win32_ComputerSystem: $($_.Exception.Message)" } try { $state.configured = @(Get-CimInstance Win32_PageFileSetting -ErrorAction Stop | ForEach-Object { [ordered]@{ name = [string]$_.Name; initialMb = [int]$_.InitialSize; maximumMb = [int]$_.MaximumSize } }) } catch { $state.errors += "Win32_PageFileSetting: $($_.Exception.Message)" } try { $state.active = @(Get-CimInstance Win32_PageFileUsage -ErrorAction Stop | ForEach-Object { [ordered]@{ name = [string]$_.Name; allocatedMb = [int]$_.AllocatedBaseSize; currentMb = [int]$_.CurrentUsage; peakMb = [int]$_.PeakUsage } }) } catch { $state.errors += "Win32_PageFileUsage: $($_.Exception.Message)" } try { $m = Get-CimInstance Win32_PerfRawData_PerfOS_Memory -ErrorAction Stop; $state.commitLimit = [double]$m.CommitLimit; $state.committed = [double]$m.CommittedBytes } catch { $state.errors += "Memory counters: $($_.Exception.Message)" } $marker = Join-Path $StateDir 'pending.json' if (Test-Path -LiteralPath $marker) { $pending = Get-Content -LiteralPath $marker -Raw | ConvertFrom-Json # A change that was reverted to the configuration active since boot is no longer pending. $sameAsBoot = ($pending.baseline.automatic -eq $state.automatic) -and ((@($pending.baseline.registryPagingFiles) -join '|') -eq ($state.registryPagingFiles -join '|')) if ($pending.bootTime -eq $state.bootTime -and -not $sameAsBoot) { $state.pending = $pending } else { Remove-Item -LiteralPath $marker -Force -ErrorAction SilentlyContinue } } $state } function Save-Backup($Before) { if (-not (Test-Path -LiteralPath $StateDir)) { New-Item -ItemType Directory -Path $StateDir -Force | Out-Null } $acl = New-Object Security.AccessControl.DirectorySecurity $acl.SetAccessRuleProtection($true, $false) foreach ($sid in @('S-1-5-18', 'S-1-5-32-544')) { $acl.AddAccessRule((New-Object Security.AccessControl.FileSystemAccessRule((New-Object Security.Principal.SecurityIdentifier($sid)), 'FullControl', 'ContainerInherit,ObjectInherit', 'None', 'Allow'))) } Set-Acl -LiteralPath $StateDir -AclObject $acl $path = Join-Path $StateDir ('pagefile-backup-' + (Get-Date).ToUniversalTime().ToString('yyyyMMddTHHmmssZ') + '.json') [IO.File]::WriteAllText($path, (ConvertTo-Json -InputObject ([ordered]@{ automatic = $Before.automatic; configured = $Before.configured; registryPagingFiles = $Before.registryPagingFiles }) -Depth 5), (New-Object Text.UTF8Encoding($false))) Get-ChildItem -LiteralPath $StateDir -Filter 'pagefile-backup-*.json' | Sort-Object LastWriteTime -Descending | Select-Object -Skip 20 | Remove-Item -Force -ErrorAction SilentlyContinue $path } function Mark-Pending([string]$Description, $Before) { $marker = Join-Path $StateDir 'pending.json' $existing = @() $baseline = [ordered]@{ automatic = $Before.automatic; registryPagingFiles = @($Before.registryPagingFiles) } if (Test-Path -LiteralPath $marker) { $old = Get-Content -LiteralPath $marker -Raw | ConvertFrom-Json; if ($old.bootTime -eq (Boot-Time)) { $existing = @($old.changes); $baseline = $old.baseline } } $value = [ordered]@{ bootTime = (Boot-Time); baseline = $baseline; changes = @($existing + @([ordered]@{ at = (Get-Date).ToUniversalTime().ToString('o'); description = $Description })) } [IO.File]::WriteAllText($marker, (ConvertTo-Json -InputObject $value -Depth 5), (New-Object Text.UTF8Encoding($false))) } function Check-Drive([string]$Drive, [int]$InitialMb, $State) { if ($Drive -notmatch '^[A-Za-z]:$') { Fail 'PAGEFILE_DRIVE_INVALID' 'Invalid drive letter.' } $volume = @($State.volumes | Where-Object { $_.drive -ieq $Drive }) | Select-Object -First 1 if (-not $volume) { Fail 'PAGEFILE_DRIVE_INVALID' 'The drive is not a ready fixed volume.' } if ($volume.format -notin @('NTFS', 'ReFS')) { Fail 'PAGEFILE_DRIVE_UNSUPPORTED' "Pagefiles are supported on NTFS volumes; $Drive is $($volume.format)." } $current = @($State.active | Where-Object { $_.name -like "$Drive*" }) | Select-Object -First 1 $reuse = 0; if ($current) { $reuse = [double]$current.allocatedMb * 1MB } if ($InitialMb -gt 0 -and ($volume.free + $reuse) -lt ([double]$InitialMb * 1MB + 1GB)) { Fail 'PAGEFILE_DISK_FULL' "Not enough free space on $Drive for $InitialMb MB plus a 1 GB reserve." } } function Op-Apply($A, $State) { Require-Elevation $action = [string]$A.action $before = $State $backup = $null $cs = Get-CimInstance Win32_ComputerSystem switch ($action) { 'automatic' { if ($before.automatic -eq $true) { Fail 'PAGEFILE_NO_CHANGE' 'Windows already manages the pagefile automatically.' } $backup = Save-Backup $before Set-CimInstance -InputObject $cs -Property @{ AutomaticManagedPagefile = $true } | Out-Null Mark-Pending 'Automatic pagefile management enabled.' $before } 'set' { $drive = ([string]$A.drive).ToUpperInvariant(); $initial = [int]$A.initialMb; $maximum = [int]$A.maximumMb if ($initial -lt 0 -or $maximum -lt 0 -or $initial -gt 4194304 -or $maximum -gt 4194304 -or ($maximum -gt 0 -and $initial -gt $maximum) -or (($initial -eq 0) -ne ($maximum -eq 0)) -or ($initial -gt 0 -and $initial -lt 16)) { Fail 'PAGEFILE_SIZE_INVALID' 'Initial and maximum size must be 16 MB or more with initial <= maximum, or both 0 for a system-managed size.' } Check-Drive $drive $initial $before $backup = Save-Backup $before if ($before.automatic -ne $false) { Set-CimInstance -InputObject $cs -Property @{ AutomaticManagedPagefile = $false } | Out-Null } $name = "$drive\pagefile.sys" $setting = @(Get-CimInstance Win32_PageFileSetting | Where-Object { $_.Name -ieq $name }) | Select-Object -First 1 if (-not $setting) { $setting = New-CimInstance -ClassName Win32_PageFileSetting -Property @{ Name = $name } } Set-CimInstance -InputObject $setting -Property @{ InitialSize = [uint32]$initial; MaximumSize = [uint32]$maximum } | Out-Null Mark-Pending "Pagefile $name set to $initial-$maximum MB (0 = system managed size)." $before } 'remove' { $drive = ([string]$A.drive).ToUpperInvariant() if ($drive -notmatch '^[A-Z]:$') { Fail 'PAGEFILE_DRIVE_INVALID' 'Invalid drive letter.' } if ($before.automatic -ne $false) { Fail 'PAGEFILE_AUTOMATIC' 'Switch to manual management before removing a pagefile.' } $name = "$drive\pagefile.sys" $setting = @(Get-CimInstance Win32_PageFileSetting | Where-Object { $_.Name -ieq $name }) | Select-Object -First 1 if (-not $setting) { Fail 'PAGEFILE_NOT_FOUND' 'No pagefile is configured on this drive.' } $remaining = @($before.configured | Where-Object { $_.name -ine $name }).Count if ($remaining -eq 0 -and (Prop $A 'confirmLast') -ne $true) { Fail 'PAGEFILE_LAST' 'This is the last configured pagefile. Removing it lowers the commit limit to physical memory and can break crash dumps; explicit confirmation is required.' } if ($drive -ieq $before.systemDrive -and $before.crashDumpEnabled -ne 0 -and (Prop $A 'confirmCrashDump') -ne $true) { Fail 'PAGEFILE_CRASHDUMP' 'Crash dumps are enabled and normally need a pagefile on the system drive; explicit confirmation is required.' } $backup = Save-Backup $before Remove-CimInstance -InputObject $setting Mark-Pending "Pagefile $name removed." $before } default { Fail 'ACTION_INVALID' 'Unsupported pagefile action.' } } $after = Get-State [ordered]@{ result = 'ok'; backup = $backup; restartRequired = $true; state = $after } } try { $A = $ArgsJson | ConvertFrom-Json $state = Get-State $value = switch ([string]$A.op) { 'inspect' { $state } 'apply' { Op-Apply $A $state } default { Fail 'ACTION_INVALID' 'Unsupported operation.' } } [Console]::Out.WriteLine('CHJ_RESULT:' + [Convert]::ToBase64String([Text.Encoding]::UTF8.GetBytes((ConvertTo-Json -InputObject $value -Depth 8 -Compress)))) } catch { $exception = $_.Exception while ($exception.InnerException -and -not $exception.Message.StartsWith('CHJ:')) { $exception = $exception.InnerException } $message = $exception.Message; $code = 'WINDOWS_OPERATION_FAILED' if ($message -match '^CHJ:([A-Z0-9_]+):([\s\S]*)$') { $code = $Matches[1]; $message = $Matches[2] } elseif ($exception.GetType().FullName -match 'AccessDenied|UnauthorizedAccess' -or $exception.HResult -eq -2147024891) { $code = 'ACCESS_DENIED' } [Console]::Out.WriteLine('CHJ_ERR:' + $code + ':' + [Convert]::ToBase64String([Text.Encoding]::UTF8.GetBytes($message))) }