diff --git a/CLAUDE.md b/CLAUDE.md index ce2c473..d397830 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -49,6 +49,7 @@ All scripts follow a consistent three-part structure defined in `script-template Scripts are organized by category prefixes: - `app-*`: Application-specific scripts (Duo, Adobe, Teramind, etc.) - `bdr-*`: Backup and Disaster Recovery (Veeam, MSP360) +- `db-*`: Database engines (MySQL, etc.) - `iaas-*`: Infrastructure as a Service (Azure, Backblaze, Dynu) - `mw-*`: Middleware/Microsoft 365 scripts - `msft-*`: Microsoft Windows system scripts @@ -533,13 +534,100 @@ if ($veeamVersion -ge $requiredVersion) { ## Git Workflow +See [DTC KB ... Change Taxonomy](https://kb.dtctoday.com/books/developer-operations-devops/page/change-taxonomy) for the canonical reference. This file mirrors the rules; the KB is authoritative. + **Branching Model:** -* `development` — default branch (HEAD), active work lands here -* `release` — stable/production branch, merged from development when ready -* `enhancement/{name}` — branched from development for new functionality -* `problem/{name}` — branched from development for bug fixes and issue resolution -* No `main` or `master` branches - -**GitHub Issues & Labels:** -* New functionality uses the **enhancement** label, not "feature" -* Configure repository labels accordingly +* `main` ... default branch and release branch. Production code deployed to customer environments lives here. +* This repo has no `development` branch. Feature/fix PRs target `main` directly. +* All changes go through a typed branch and a pull request ... no direct commits to `main` (exception: minor `CLAUDE.md` / `README.md` doc updates that do not affect script functionality). + +**CRITICAL: All changes must be made in a typed branch, never directly to `main`.** + +### Change Taxonomy (two-tier ... Halo / GitHub / branch) + +Every change to this repo maps to one of four categories under two parent types. The category drives the branch prefix, the GitHub labels, and the default semver bump (future-state: this repo has no semver versioning today, but the bump column is recorded for when it does). `Refactor` spans both parent types ... see the table. + +| Halo Type | Halo Category | GitHub labels | Branch prefix | Default semver | +|---|---|---|---|---| +| `Problem` | `Bug` | `type:problem` + `category:bug` | `bug/{name}` | Patch | +| `Problem` | `Refactor` | `type:problem` + `category:refactor` | `refactor/{name}` | Patch | +| `Enhancement` | `Feature` | `type:enhancement` + `category:feature` | `feature/{name}` | Minor | +| `Enhancement` | `Improvement` | `type:enhancement` + `category:improvement` | `improvement/{name}` | Minor | +| `Enhancement` | `Refactor` | `type:enhancement` + `category:refactor` | `refactor/{name}` | Minor | + +How to pick: +- **Bug** ... the script doesn't do what it was designed to do. Null check missing, wrong path, broken regex, off-by-one. +- **Refactor** ... a redesign of how something is shaped. Spans both parent types ... `Problem/Refactor` when the original design was wrong (broken-shape redo, patch bump); `Enhancement/Refactor` when the original design works but is clunky (working-but-clunky redo, minor bump). Same branch prefix and same `category:refactor` label either way; the parent type column disambiguates motivation and semver. +- **Improvement** ... an existing capability done better. Hardening, polish, clearer error output, integrity checks added to existing downloads. +- **Feature** ... net-new capability. A new script, new delivery mechanism, new integration target. + +**`BREAKING:` PR title prefix** forces a major version bump regardless of category. (Future-state: applies once this repo carries a semver version.) + +**Name branches after the change, not the fix.** Good: `bug/iso-dismount-fails-on-server-2022`, `feature/jsdelivr-script-delivery`. Bad: `bug/my-fix`, `feature/wip`. + +**`dependabot/*` branches** ... categorize by the upstream change. CVE or upstream defect → `bug`. Major-version bump that takes new capability → `improvement` or `feature`. + +**Legacy prefixes:** `enhancement/` and `problem/` are deprecated by this taxonomy. Existing branches with these prefixes are accepted as-is and can merge under their original names; new work uses the four-prefix model above (`bug/`, `refactor/`, `improvement/`, `feature/`). + +### GitHub Labels + +Two-tier label set, one of each per PR: + +**Type labels:** `type:problem`, `type:enhancement` + +**Category labels:** `category:bug`, `category:refactor`, `category:improvement`, `category:feature` + +The legacy single-tier labels (`bug`, `enhancement`, `feature`) remain on the repo for issues opened under the old convention. New issues and PRs use the two-tier labels. + +### Workflow for All Changes + +1. **Create a typed branch off `main`** + ```bash + git checkout main + git pull + git checkout -b feature/descriptive-name + # or bug/, refactor/, improvement/ per the taxonomy above + ``` + + Examples: + - `feature/jsdelivr-script-delivery` + - `improvement/vendor-download-integrity` + - `bug/iso-dismount-error` + - `refactor/rmm-input-handler-shared-helper` + +2. **Make changes on the branch** + - Make all code modifications on the typed branch + - Commit changes with descriptive messages + - Test thoroughly in both interactive and RMM modes + +3. **Push branch** + ```bash + git push -u origin feature/descriptive-name + ``` + +4. **Create pull request** + - Open PR from your branch to `main` + - Apply the two labels from the taxonomy table (one `type:*` + one `category:*`) + - Include description of changes, testing performed, and RMM compatibility + - Wait for review and approval before merging + +5. **Merge to `main`** + - Only merge after testing and approval + - Delete the branch after successful merge + +### If Changes Are Accidentally Committed to Main + +```bash +# Revert the commit from main +git revert --no-edit +git push + +# Create the typed branch and restore the changes +git checkout -b feature/descriptive-name +git cherry-pick +git push -u origin feature/descriptive-name +``` + +### Exception: Documentation Updates + +Minor documentation updates to `CLAUDE.md` or `README.md` may be committed directly to `main` if they do not affect script functionality. diff --git a/integrations/ninja-halo-guid-sync.ps1 b/integrations/ninja-halo-guid-sync.ps1 new file mode 100644 index 0000000..95b3638 --- /dev/null +++ b/integrations/ninja-halo-guid-sync.ps1 @@ -0,0 +1,177 @@ +<# +.SYNOPSIS + Syncs DTC Org GUID from NinjaRMM organizations to Halo PSA client custom field. + +.DESCRIPTION + Reads the dtcOrgGuid custom field from each NinjaRMM organization and writes + it to the CFDtcClientGuid field in Halo PSA for all exactly-matched clients. + + Runs unattended via NinjaOne Scheduled Task (Sundays 2:00 AM EDT). + Matching is exact by client name. Name mismatches are logged and skipped. + +.PARAMETER haloclientsecret + Halo PSA API client secret. Injected by NinjaOne Script Variable: haloclientsecret. + +.PARAMETER ninjaclientsecret + NinjaRMM API client secret. Injected by NinjaOne Script Variable: ninjaclientsecret. + +.NOTES + Author: Tyler Dantzler + Repo: DTC-Inc/msp-script-library/integrations/ninja-halo-guid-sync.ps1 + BookStack: Page 1908 + Schedule: Weekly, Sundays 2:00 AM EDT (NinjaOne Scheduled Task) + + ## PLEASE COMMENT YOUR VARIABLES DIRECTLY BELOW HERE IF YOU ARE RUNNING FROM A RMM + # $env:RMM -- set to "1" by NinjaOne at runtime; controls RMM vs interactive mode + # $env:Description -- human-readable audit trail label for this run + # $env:haloclientsecret -- Halo PSA API client secret (NinjaOne Script Variable) + # $env:ninjaclientsecret -- NinjaRMM API client secret (NinjaOne Script Variable) +#> + +[CmdletBinding()] +param() + +$ErrorActionPreference = 'Stop' +$ProgressPreference = 'SilentlyContinue' + +# --- AUDIT TRAIL & LOGGING --- +$ScriptLogName = 'ninja-halo-guid-sync' +$Description = if ($env:Description) { $env:Description } else { 'NinjaRMM to Halo PSA GUID sync (manual run)' } +$logPath = "C:\ProgramData\DTC\Logs\$ScriptLogName-$(Get-Date -Format 'yyyyMMdd-HHmmss').log" +New-Item -ItemType Directory -Path (Split-Path $logPath) -Force | Out-Null +Start-Transcript -Path $logPath -Append + +Write-Information "[$ScriptLogName] Starting — $Description" -InformationAction Continue + +try { + + # --- CONFIGURATION --- + # Client IDs are not secrets — hardcoded here. + # Client secrets are injected via NinjaOne Script Variables (env vars at runtime). + $HaloClientId = '25542a6d-2d0e-4093-bf82-e11edc64faf6' + $HaloClientSecret = $env:haloclientsecret + $HaloBaseUrl = 'https://psa.dtctoday.com' + $HaloScope = 'read:customers edit:customers' + + $NinjaClientId = '0S1xEjce1FQp7Rbn_GJTrSWTp64' + $NinjaClientSecret = $env:ninjaclientsecret + $NinjaBaseUrl = 'https://app.ninjarmm.com' + + # --- VALIDATE SECRETS --- + $missing = @() + if ([string]::IsNullOrWhiteSpace($HaloClientSecret)) { $missing += 'haloclientsecret' } + if ([string]::IsNullOrWhiteSpace($NinjaClientSecret)) { $missing += 'ninjaclientsecret' } + + if ($missing.Count -gt 0) { + throw "Missing NinjaOne Script Variables: $($missing -join ', '). Set default values in Library > Automation > Halo GUID Sync > Script Variables." + } + + # --- AUTHENTICATE: HALO --- + Write-Information ' Authenticating with Halo PSA...' -InformationAction Continue + $haloTokenResp = Invoke-RestMethod -Method Post -Uri "$HaloBaseUrl/auth/token" ` + -ContentType 'application/x-www-form-urlencoded' -Body @{ + grant_type = 'client_credentials' + client_id = $HaloClientId + client_secret = $HaloClientSecret + scope = $HaloScope + } + $haloToken = $haloTokenResp.access_token + $haloHeaders = @{ Authorization = "Bearer $haloToken" } + Write-Information ' Halo auth OK' -InformationAction Continue + + # --- AUTHENTICATE: NINJA --- + Write-Information ' Authenticating with NinjaRMM...' -InformationAction Continue + $ninjaTokenResp = Invoke-RestMethod -Method Post -Uri "$NinjaBaseUrl/ws/oauth/token" ` + -ContentType 'application/x-www-form-urlencoded' -Body @{ + grant_type = 'client_credentials' + client_id = $NinjaClientId + client_secret = $NinjaClientSecret + scope = 'monitoring management' + } + $ninjaToken = $ninjaTokenResp.access_token + $ninjaHeaders = @{ Authorization = "Bearer $ninjaToken" } + Write-Information ' Ninja auth OK' -InformationAction Continue + + # --- PULL NINJA ORGS --- + $orgs = Invoke-RestMethod -Method Get -Uri "$NinjaBaseUrl/v2/organizations" -Headers $ninjaHeaders + $results = @() + + foreach ($org in $orgs) { + $orgName = $org.name + $orgId = $org.id + + # Read DTC Org GUID custom field + try { + $cf = Invoke-RestMethod -Method Get ` + -Uri "$NinjaBaseUrl/v2/organization/$orgId/custom-fields" -Headers $ninjaHeaders + $guid = $cf.dtcOrgGuid + } catch { + Write-Warning "Could not read custom fields for '$orgName' — skipping" + continue + } + + if ([string]::IsNullOrWhiteSpace($guid)) { + $results += [PSCustomObject]@{ NinjaOrg = $orgName; Status = 'No GUID'; GUID = '' } + continue + } + + # Search Halo for matching client by exact name + try { + $haloSearch = Invoke-RestMethod -Method Get ` + -Uri "$HaloBaseUrl/api/client?search=$([uri]::EscapeDataString($orgName))" ` + -Headers $haloHeaders + } catch { + Write-Warning "Halo search failed for '$orgName' — skipping" + continue + } + + $haloClient = $haloSearch.clients | Where-Object { $_.name -eq $orgName } | Select-Object -First 1 + + if (-not $haloClient) { + Write-Warning "No exact Halo match for '$orgName' — skipping" + $results += [PSCustomObject]@{ NinjaOrg = $orgName; Status = 'No Halo match'; GUID = $guid } + continue + } + + # Build body — PowerShell 5 unwraps single-item @() on ConvertTo-Json so manually wrap in [] + $clientUpdate = @{ + id = $haloClient.id + customfields = @( + @{ name = 'CFDtcClientGuid'; value = $guid } + ) + } + $body = '[' + ($clientUpdate | ConvertTo-Json -Depth 5) + ']' + + try { + Invoke-RestMethod -Method Post -Uri "$HaloBaseUrl/api/client" ` + -Headers $haloHeaders -ContentType 'application/json' -Body $body | Out-Null + Write-Information " [OK] $orgName" -InformationAction Continue + $results += [PSCustomObject]@{ NinjaOrg = $orgName; Status = 'Updated'; GUID = $guid } + } catch { + $errorDetail = $_.ErrorDetails.Message + Write-Warning "Failed to update '$orgName' — $($_.Exception.Message) | $errorDetail" + $results += [PSCustomObject]@{ NinjaOrg = $orgName; Status = 'Failed'; GUID = $guid } + } + } + + # --- SUMMARY --- + Write-Information '' -InformationAction Continue + Write-Information '===== SYNC SUMMARY =====' -InformationAction Continue + $results | Format-Table -AutoSize + + $failed = $results | Where-Object { $_.Status -eq 'Failed' } + if ($failed) { + Write-Warning "$($failed.Count) client(s) failed to update — see above" + exit 2 + } + + Write-Information "[$ScriptLogName] Completed successfully." -InformationAction Continue + exit 0 + +} catch { + Write-Error "FAILED: $_" + Write-Error $_.ScriptStackTrace + exit 1 +} finally { + Stop-Transcript +} diff --git a/msft-windows/msft-win-shutdown-disable.ps1 b/msft-windows/msft-win-shutdown-disable.ps1 deleted file mode 100644 index e9f4982..0000000 --- a/msft-windows/msft-win-shutdown-disable.ps1 +++ /dev/null @@ -1,102 +0,0 @@ -## PLEASE COMMENT YOUR VARIALBES DIRECTLY BELOW HERE IF YOU'RE RUNNING FROM A RMM -## THIS IS HOW WE EASILY LET PEOPLE KNOW WHAT VARIABLES NEED SET IN THE RMM - -# Getting input from user if not running from RMM else set variables from RMM. - -$ScriptLogName = "msft-win-shutdown-disable.log" - -if ($RMM -ne 1) { - $ValidInput = 0 - # Checking for valid input. - while ($ValidInput -ne 1) { - # Ask for input here. This is the interactive area for getting variable information. - # Remember to make ValidInput = 1 whenever correct input is given. - $Description = Read-Host "Please enter the ticket # and, or your initials. Its used as the Description for the job" - if ($Description) { - $ValidInput = 1 - } else { - Write-Host "Invalid input. Please try again." - } - - $DisableShutdownUi = Read-Host "Enter Y to enable shutdown, enter N to disable shutdown" - if ($DisableShutdownUi -eq "Y") { - $DisableShutdownUi = $True - $ValidInput = 1 - } elseif ($DisableShutdownUi -eq "N") { - $DisableShutdownUi = $False - $ValidInput = 1 - } else { - Write-Host "Input invalid, please only enter Y or N." - $ValidInput = 0 - } - - } - $LogPath = "$ENV:WINDIR\logs\$ScriptLogName" - -} else { - # Store the logs in the RMMScriptPath - if ($null -eq $RMMScriptPath) { - $LogPath = "$RMMScriptPath\logs\$ScriptLogName" - - } else { - $LogPath = "$ENV:WINDIR\logs\$ScriptLogName" - - } - - if ($null -eq $Description) { - Write-Host "Description is null. This was most likely run automatically from the RMM and no information was passed." - $Description = "No Description" - } - - - -} - -# Start the script logic here. This is the part that actually gets done what you need done. - -Start-Transcript -Path $LogPath - -Write-Host "Description: $Description" -Write-Host "Log path: $LogPath" -Write-Host "RMM: $RMM" -Write Host "Disable Shutdown: $DisableShutdownUi" - -# Set this variable: -# $true => Disable the shutdown option from the Windows UI -# $false => Enable (restore) the shutdown option in the Windows UI -# $DisableShutdownUI = $true # Change to $false to re-enable the shutdown option - -# Define the registry path for Explorer policies -$regPath = "HKCU:\Software\Microsoft\Windows\CurrentVersion\Policies\Explorer" - -if ($DisableShutdownUI) { - # Disable Shutdown UI: - # Create the registry key if it doesn't exist - if (-not (Test-Path $regPath)) { - New-Item -Path "HKCU:\Software\Microsoft\Windows\CurrentVersion\Policies" -Name "Explorer" -Force | Out-Null - } - # Set the NoClose value to disable the shutdown/restart options in the UI - Set-ItemProperty -Path $regPath -Name "NoClose" -Value 1 -Type DWord - Write-Host "Shutdown option from the Windows UI has been disabled." -} -else { - # Enable Shutdown UI: - if (Test-Path $regPath) { - # If the "NoClose" property exists, remove it - if (Get-ItemProperty -Path $regPath -Name "NoClose" -ErrorAction SilentlyContinue) { - Remove-ItemProperty -Path $regPath -Name "NoClose" -ErrorAction SilentlyContinue - Write-Host "Shutdown option from the Windows UI has been enabled." - } - else { - Write-Host "No shutdown disabling registry entry found. Shutdown option is already enabled." - } - } - else { - Write-Host "Registry key not found. Shutdown option should be enabled by default." - } -} - -Write-Host "Note: You may need to log off or restart Explorer for the changes to take effect." - -Stop-Transcript - diff --git a/msft-windows/msft-win-uptime-lockdown.ps1 b/msft-windows/msft-win-uptime-lockdown.ps1 new file mode 100644 index 0000000..d2a8691 --- /dev/null +++ b/msft-windows/msft-win-uptime-lockdown.ps1 @@ -0,0 +1,509 @@ +## PLEASE COMMENT YOUR VARIABLES DIRECTLY BELOW HERE IF YOU'RE RUNNING FROM A RMM +## NinjaRMM passes script preset variables as environment variables, so each is read via $env: in this script. +## $env:RMM - Set to "1" by NinjaRMM to indicate RMM (non-interactive) mode +## $env:Description - Ticket # or initials for audit trail +## $env:RMMScriptPath - Optional log directory base provided by the RMM +## $env:DisableShutdownUi - Optional. "true" to lock down user shutdown (NoClose + power button = Do nothing); +## "false"/empty leaves shutdown available (power-plan/uptime config still applies). Default: false. + +# This script consolidates Windows uptime/power configuration and (optional) user-shutdown lockdown into one deployable unit. +# It REPLACES two prior scripts: msft-windows-power-management-config.ps1 and msft-win-shutdown-disable.ps1. +# +# Power-plan / uptime configuration (ALWAYS applied - keeps endpoints awake for backups, patching, maintenance): +# 1. Disables display timeout (never turn off display) +# 2. Disables hybrid sleep across all plans +# 3. Disables fast startup globally +# 4. Disables hibernation on desktops only (keeps hibernation enabled on laptops) +# 5. Stops hard disks from turning off on all plans +# 6. Disables sleeping completely across all plans +# 7. Allows sleeping only when the lid is shut for laptops across all plans +# 8. Sets critical battery action to hibernate (laptops) or shutdown (desktops) +# 9. Disables USB selective suspend across all plans +# 10. Disables PCIE Link State Power Management across all plans +# 11. Enables all wake timers across all plans +# 12. Sets wireless adapters to maximum performance across all plans +# 13. Sets video playback to maximum quality across all plans +# 14. Optimizes multimedia settings for best performance across all plans +# +# Shutdown lockdown (ONLY when $env:DisableShutdownUi = "true"): +# 15. Sets the power button SHORT-PRESS action to "Do nothing" across all plans +# 16. Sets HKLM Explorer policy NoClose=1 (removes Shut Down/Restart/Sleep/Hibernate from Start and Ctrl+Alt+Del, machine-wide) +# NOTE: The 4-second power-button HOLD is firmware-level and cannot be disabled by any software. + +# Getting input from user if not running from RMM else set variables from RMM. + +$ScriptLogName = "msft-win-uptime-lockdown.log" + +if ($env:RMM -ne "1") { + $ValidInput = 0 + while ($ValidInput -ne 1) { + $env:Description = Read-Host "Please enter the ticket # and/or your initials for audit trail" + if ($env:Description) { + $ValidInput = 1 + } else { + Write-Host "Invalid input. Please try again." + } + } + + $shutdownAnswer = "" + while ($shutdownAnswer -notmatch '^(?i:y|n)$') { + $shutdownAnswer = Read-Host "Lock down user shutdown? (Y = disable shutdown UI + power button, N = leave shutdown available)" + if ($shutdownAnswer -notmatch '^(?i:y|n)$') { Write-Host "Please enter Y or N." } + } + if ($shutdownAnswer -match '^(?i:y)$') { $env:DisableShutdownUi = "true" } else { $env:DisableShutdownUi = "false" } + + $LogPath = "$env:WINDIR\logs\$ScriptLogName" +} else { + if (-not [string]::IsNullOrEmpty($env:RMMScriptPath)) { + $LogPath = "$env:RMMScriptPath\logs\$ScriptLogName" + } else { + $LogPath = "$env:WINDIR\logs\$ScriptLogName" + } + + if ([string]::IsNullOrEmpty($env:Description)) { + Write-Host "Description is null. This was most likely run automatically from the RMM and no information was passed." + $env:Description = "Windows Uptime / Shutdown Lockdown Policy" + } +} + +# Normalize the shutdown-lockdown toggle to a boolean. Default: false (power-plan config only). +$disableShutdown = $false +if ("$env:DisableShutdownUi".Trim() -match '^(?i:true|1|yes|y)$') { $disableShutdown = $true } + +# Ensure log directory exists before starting the transcript +$logDir = Split-Path -Path $LogPath -Parent +if (-not (Test-Path -Path $logDir)) { + New-Item -Path $logDir -ItemType Directory -Force | Out-Null +} + +# Start the script logic here. + +$TranscriptStarted = $false +try { + Start-Transcript -Path $LogPath -ErrorAction Stop + $TranscriptStarted = $true +} catch { + Write-Host "Warning: Could not start transcript logging to $LogPath - $($_.Exception.Message)" +} + +Write-Host "Description: $Description" +Write-Host "Log path: $LogPath" +Write-Host "RMM: $RMM `n" + +Write-Host "=== Windows Power Management Configuration Script ===" -ForegroundColor Cyan +Write-Host "This script will configure power settings for optimal performance and control." -ForegroundColor White +Write-Host "" + +try { + # Check if running as administrator + $currentPrincipal = New-Object Security.Principal.WindowsPrincipal([Security.Principal.WindowsIdentity]::GetCurrent()) + $isAdmin = $currentPrincipal.IsInRole([Security.Principal.WindowsBuiltInRole]::Administrator) + + if (-not $isAdmin) { + Write-Error "This script must be run as Administrator to modify power settings." + if ($TranscriptStarted) { Stop-Transcript } + exit 1 + } + + Write-Host "✓ Running with Administrator privileges" -ForegroundColor Green + Write-Host "" + + # Detect if this is a laptop or desktop + Write-Host "Detecting device type..." -ForegroundColor Yellow + $chassisTypes = (Get-CimInstance -ClassName Win32_SystemEnclosure).ChassisTypes + # Laptop chassis types: 8=Portable, 9=Laptop, 10=Notebook, 14=Sub Notebook, 31=Convertible, 32=Detachable + $laptopChassisTypes = @(8, 9, 10, 14, 31, 32) + $IsLaptop = $false + foreach ($type in $chassisTypes) { + if ($laptopChassisTypes -contains $type) { + $IsLaptop = $true + break + } + } + Write-Host "Device Type: $(if ($IsLaptop) { 'Laptop' } else { 'Desktop' })" -ForegroundColor Cyan + Write-Host "" + + # Get all power schemes + Write-Host "Step 1: Getting all power schemes..." -ForegroundColor Yellow + $powerSchemes = powercfg /list | Where-Object { $_ -match "GUID: ([a-f0-9\-]+)" } | ForEach-Object { + if ($_ -match "GUID: ([a-f0-9\-]+)\s+\((.+?)\)(?:\s+\*)?") { + [PSCustomObject]@{ + GUID = $matches[1] + Name = $matches[2].Trim() + IsActive = $_ -match "\*$" + } + } + } + + Write-Host "Found $($powerSchemes.Count) power scheme(s):" -ForegroundColor White + foreach ($scheme in $powerSchemes) { + $activeIndicator = if ($scheme.IsActive) { " (ACTIVE)" } else { "" } + Write-Host " - $($scheme.Name)$activeIndicator" -ForegroundColor Gray + } + Write-Host "" + + # Step 2: Disable Fast Startup globally via registry + Write-Host "Step 2: Disabling Fast Startup globally..." -ForegroundColor Yellow + try { + $fastStartupRegPath = "HKLM:\SYSTEM\CurrentControlSet\Control\Session Manager\Power" + + # Create the registry path if it doesn't exist + if (!(Test-Path $fastStartupRegPath)) { + New-Item -Path $fastStartupRegPath -Force | Out-Null + Write-Host "Created registry path: $fastStartupRegPath" -ForegroundColor Gray + } + + Set-ItemProperty -Path $fastStartupRegPath -Name "HiberbootEnabled" -Value 0 -Type DWord + Write-Host "✓ Fast Startup disabled globally" -ForegroundColor Green + } catch { + Write-Host "❌ Failed to disable Fast Startup: $($_.Exception.Message)" -ForegroundColor Red + } + Write-Host "" + + # Step 3: Configure hibernation based on device type + Write-Host "Step 3: Configuring hibernation..." -ForegroundColor Yellow + try { + if ($IsLaptop) { + # Keep hibernation enabled on laptops - needed for critical battery action + Write-Host "✓ Hibernation kept enabled (laptop detected - needed for critical battery)" -ForegroundColor Green + } else { + # Disable hibernation on desktops + $hibernationResult = powercfg /hibernate off 2>&1 + if ($LASTEXITCODE -eq 0) { + Write-Host "✓ Hibernation disabled (desktop detected)" -ForegroundColor Green + } else { + Write-Host "⚠ Hibernation disable command completed with warnings: $hibernationResult" -ForegroundColor Yellow + } + } + } catch { + Write-Host "❌ Failed to configure hibernation: $($_.Exception.Message)" -ForegroundColor Red + } + Write-Host "" + + # Step 4-7: Configure power settings for each scheme + Write-Host "Step 4-7: Configuring power settings for each power scheme..." -ForegroundColor Yellow + + foreach ($scheme in $powerSchemes) { + Write-Host "Configuring power scheme: $($scheme.Name)" -ForegroundColor Cyan + + try { + # Power setting GUIDs used in this script: + # SUB_SLEEP = 238C9FA8-0AAD-41ED-83F4-97BE242C8F20 + # HYBRIDSLEEP = 94ac6d29-73ce-41a6-809f-6363ba21b47e + # STANDBYIDLE = 29f6c1db-86da-48c5-9fdb-f2b67b1f44da + # UNATTENDSLEEP = 7bc4a2f9-d8fc-4469-b07b-33eb785aaca0 + # WAKETIMERS = BD3B718A-0680-4D9D-8AB2-E1D2B4AC806D + # SUB_DISK = 0012EE47-9041-4B5D-9B77-535FBA8B1442 + # DISKIDLE = 6738E2C4-E8A5-4A42-B16A-E040E769756E + # SUB_BUTTONS = 4F971E89-EEBD-4455-A8DE-9E59040E7347 + # LIDACTION = 5ca83367-6e45-459f-a27b-476b1d01c936 + # SUB_BATTERY = E73A048D-BF27-4F12-9731-8B2076E8891F + # CRITBATTERYACTION = 637ea02f-bbcb-4015-8e2c-a1c7b9c0b546 + # SUB_USB = 2A737441-1930-4402-8D77-B2BEBBA308A3 + # USBSELECTIVESUSPEND = 48E6B7A6-50F5-4782-A5D4-53BB8F07E226 + # SUB_PCIEXPRESS = 501A4D13-42AF-4429-9FD1-A8218C268E20 + # ASPM = EE12F906-D277-404B-B6DA-E5FA1A576DF5 + # SUB_RADIO = 19cbb8fa-5279-450e-9fac-8a3d5fedd0c1 + # RADIOPS = 12bbebe6-58d6-4636-95bb-3217ef867c1a + # SUB_MULTIMEDIA = 9596fb26-9850-41fd-ac3e-f7c3c00afd4b + # VIDEOQUALITYBIAS = 10778347-1370-4ee0-8bbd-33bdacaade49 + # WHENPLAYINGVIDEO = 34C7B99F-9A6D-4b3c-8DC7-B6693B78CEF4 + + # 4a. Disable hybrid sleep for both AC and DC (battery) + Write-Host " - Disabling hybrid sleep..." -ForegroundColor White + # Using actual GUIDs: SUB_SLEEP = 238C9FA8-0AAD-41ED-83F4-97BE242C8F20, HYBRIDSLEEP = 94ac6d29-73ce-41a6-809f-6363ba21b47e + powercfg /setacvalueindex $($scheme.GUID) 238C9FA8-0AAD-41ED-83F4-97BE242C8F20 94ac6d29-73ce-41a6-809f-6363ba21b47e 0 | Out-Null + powercfg /setdcvalueindex $($scheme.GUID) 238C9FA8-0AAD-41ED-83F4-97BE242C8F20 94ac6d29-73ce-41a6-809f-6363ba21b47e 0 | Out-Null + + # 4b. Disable hard disk turn off for both AC and DC + Write-Host " - Disabling hard disk turn off..." -ForegroundColor White + # Using actual GUIDs: SUB_DISK = 0012EE47-9041-4B5D-9B77-535FBA8B1442, DISKIDLE = 6738E2C4-E8A5-4A42-B16A-E040E769756E + powercfg /setacvalueindex $($scheme.GUID) 0012EE47-9041-4B5D-9B77-535FBA8B1442 6738E2C4-E8A5-4A42-B16A-E040E769756E 0 | Out-Null + powercfg /setdcvalueindex $($scheme.GUID) 0012EE47-9041-4B5D-9B77-535FBA8B1442 6738E2C4-E8A5-4A42-B16A-E040E769756E 0 | Out-Null + + # 4c. Disable automatic sleep for both AC and DC + Write-Host " - Disabling automatic sleep..." -ForegroundColor White + # Using actual GUIDs: SUB_SLEEP = 238C9FA8-0AAD-41ED-83F4-97BE242C8F20, STANDBYIDLE = 29f6c1db-86da-48c5-9fdb-f2b67b1f44da + powercfg /setacvalueindex $($scheme.GUID) 238C9FA8-0AAD-41ED-83F4-97BE242C8F20 29f6c1db-86da-48c5-9fdb-f2b67b1f44da 0 | Out-Null + powercfg /setdcvalueindex $($scheme.GUID) 238C9FA8-0AAD-41ED-83F4-97BE242C8F20 29f6c1db-86da-48c5-9fdb-f2b67b1f44da 0 | Out-Null + + # 4d. Configure lid close action to sleep (only for laptops) + Write-Host " - Setting lid close action to sleep..." -ForegroundColor White + # Lid close actions: 0=Do nothing, 1=Sleep, 2=Hibernate, 3=Shut down + # Using actual GUIDs: SUB_BUTTONS = 4F971E89-EEBD-4455-A8DE-9E59040E7347, LIDACTION = 5CA83367-6E45-459F-A27B-476B1D01C936 + powercfg /setacvalueindex $($scheme.GUID) 4F971E89-EEBD-4455-A8DE-9E59040E7347 5CA83367-6E45-459F-A27B-476B1D01C936 1 | Out-Null + powercfg /setdcvalueindex $($scheme.GUID) 4F971E89-EEBD-4455-A8DE-9E59040E7347 5CA83367-6E45-459F-A27B-476B1D01C936 1 | Out-Null + + # 4d-ii. Power button SHORT-PRESS action (shutdown lockdown only). + # PBUTTONACTION = 7648EFA3-DD9C-4E3E-B566-50F929386280 (0=Do nothing, 1=Sleep, 2=Hibernate, 3=Shut down) + # The 4-second power-button HOLD is firmware-level and cannot be changed here. + if ($disableShutdown) { + Write-Host " - Setting power button short-press to Do nothing (shutdown lockdown)..." -ForegroundColor White + powercfg /setacvalueindex $($scheme.GUID) 4F971E89-EEBD-4455-A8DE-9E59040E7347 7648EFA3-DD9C-4E3E-B566-50F929386280 0 | Out-Null + powercfg /setdcvalueindex $($scheme.GUID) 4F971E89-EEBD-4455-A8DE-9E59040E7347 7648EFA3-DD9C-4E3E-B566-50F929386280 0 | Out-Null + } else { + Write-Host " - Restoring power button short-press to Shut down..." -ForegroundColor White + powercfg /setacvalueindex $($scheme.GUID) 4F971E89-EEBD-4455-A8DE-9E59040E7347 7648EFA3-DD9C-4E3E-B566-50F929386280 3 | Out-Null + powercfg /setdcvalueindex $($scheme.GUID) 4F971E89-EEBD-4455-A8DE-9E59040E7347 7648EFA3-DD9C-4E3E-B566-50F929386280 3 | Out-Null + } + + # 4e. Set critical battery action based on device type + # Critical battery actions: 0=Do nothing, 1=Sleep, 2=Hibernate, 3=Shut down + # Laptops: Hibernate (preserves state), Desktops: Shutdown + # Using actual GUIDs: SUB_BATTERY = E73A048D-BF27-4F12-9731-8B2076E8891F, CRITBATTERYACTION = 637EA02F-BBCB-4015-8E2C-A1C7B9C0B546 + if ($IsLaptop) { + Write-Host " - Setting critical battery action to hibernate (laptop)..." -ForegroundColor White + powercfg /setdcvalueindex $($scheme.GUID) E73A048D-BF27-4F12-9731-8B2076E8891F 637EA02F-BBCB-4015-8E2C-A1C7B9C0B546 2 | Out-Null + } else { + Write-Host " - Setting critical battery action to shutdown (desktop)..." -ForegroundColor White + powercfg /setdcvalueindex $($scheme.GUID) E73A048D-BF27-4F12-9731-8B2076E8891F 637EA02F-BBCB-4015-8E2C-A1C7B9C0B546 3 | Out-Null + } + + # Apply the settings to the scheme + powercfg /setactive $($scheme.GUID) | Out-Null + + Write-Host "✓ Power scheme '$($scheme.Name)' configured successfully" -ForegroundColor Green + + } catch { + Write-Host "❌ Failed to configure power scheme '$($scheme.Name)': $($_.Exception.Message)" -ForegroundColor Red + } + } + Write-Host "" + + # Step 4f: Shutdown UI lockdown (machine-wide, HKLM so it applies under SYSTEM/RMM to every user on the device). + Write-Host "Step 4f: Configuring shutdown UI lockdown (NoClose)..." -ForegroundColor Yellow + try { + $explorerPolicyPath = "HKLM:\SOFTWARE\Microsoft\Windows\CurrentVersion\Policies\Explorer" + if (-not (Test-Path $explorerPolicyPath)) { New-Item -Path $explorerPolicyPath -Force | Out-Null } + if ($disableShutdown) { + New-ItemProperty -Path $explorerPolicyPath -Name "NoClose" -Value 1 -PropertyType DWord -Force | Out-Null + $noCloseNow = (Get-ItemProperty -Path $explorerPolicyPath -Name "NoClose" -ErrorAction SilentlyContinue).NoClose + if ($noCloseNow -eq 1) { + Write-Host "✓ Shutdown/Restart/Sleep/Hibernate removed from Start and Ctrl+Alt+Del (NoClose=1)" -ForegroundColor Green + } else { + Write-Host "❌ NoClose verify failed: value is '$noCloseNow', expected 1" -ForegroundColor Red + } + } else { + Remove-ItemProperty -Path $explorerPolicyPath -Name "NoClose" -ErrorAction SilentlyContinue + $noCloseNow = (Get-ItemProperty -Path $explorerPolicyPath -Name "NoClose" -ErrorAction SilentlyContinue).NoClose + if ($null -eq $noCloseNow) { + Write-Host "✓ Shutdown UI available (NoClose removed)" -ForegroundColor Green + } else { + Write-Host "❌ NoClose verify failed: still present ='$noCloseNow'" -ForegroundColor Red + } + } + Write-Host "Note: NoClose takes effect at the user's next sign-in (or after Explorer restart)." -ForegroundColor Gray + Write-Host "Note: The 4-second power-button HOLD is firmware-level and cannot be disabled by software." -ForegroundColor Gray + } catch { + Write-Host "❌ Failed to configure shutdown UI lockdown: $($_.Exception.Message)" -ForegroundColor Red + } + Write-Host "" + + # Additional power settings configuration + Write-Host "Step 5: Configuring additional power settings..." -ForegroundColor Yellow + + try { + # Disable system unattended sleep timeout for all schemes + foreach ($scheme in $powerSchemes) { + Write-Host " - Disabling unattended sleep timeout for '$($scheme.Name)'..." -ForegroundColor White + # Using actual GUIDs: SUB_SLEEP = 238C9FA8-0AAD-41ED-83F4-97BE242C8F20, UNATTENDSLEEP = 7bc4a2f9-d8fc-4469-b07b-33eb785aaca0 + powercfg /setacvalueindex $($scheme.GUID) 238C9FA8-0AAD-41ED-83F4-97BE242C8F20 7bc4a2f9-d8fc-4469-b07b-33eb785aaca0 0 | Out-Null + powercfg /setdcvalueindex $($scheme.GUID) 238C9FA8-0AAD-41ED-83F4-97BE242C8F20 7bc4a2f9-d8fc-4469-b07b-33eb785aaca0 0 | Out-Null + } + Write-Host "✓ Unattended sleep timeout disabled for all schemes" -ForegroundColor Green + } catch { + Write-Host "⚠ Some unattended sleep settings may not have been configured" -ForegroundColor Yellow + } + + try { + # Configure USB selective suspend (disable to prevent issues) + foreach ($scheme in $powerSchemes) { + Write-Host " - Disabling USB selective suspend for '$($scheme.Name)'..." -ForegroundColor White + # Using actual GUIDs: SUB_USB = 2A737441-1930-4402-8D77-B2BEBBA308A3, USBSELECTIVESUSPEND = 48E6B7A6-50F5-4782-A5D4-53BB8F07E226 + powercfg /setacvalueindex $($scheme.GUID) 2A737441-1930-4402-8D77-B2BEBBA308A3 48E6B7A6-50F5-4782-A5D4-53BB8F07E226 0 | Out-Null + powercfg /setdcvalueindex $($scheme.GUID) 2A737441-1930-4402-8D77-B2BEBBA308A3 48E6B7A6-50F5-4782-A5D4-53BB8F07E226 0 | Out-Null + } + Write-Host "✓ USB selective suspend disabled for all schemes" -ForegroundColor Green + } catch { + Write-Host "⚠ USB selective suspend settings may not have been configured" -ForegroundColor Yellow + } + + try { + # Configure PCIE Link State Power Management (disable to prevent issues) + foreach ($scheme in $powerSchemes) { + Write-Host " - Disabling PCIE Link State Power Management for '$($scheme.Name)'..." -ForegroundColor White + # ASPM (Active State Power Management) - 0=Off, 1=Moderate power savings, 2=Maximum power savings + # Using actual GUIDs: SUB_PCIEXPRESS = 501A4D13-42AF-4429-9FD1-A8218C268E20, ASPM = EE12F906-D277-404B-B6DA-E5FA1A576DF5 + powercfg /setacvalueindex $($scheme.GUID) 501A4D13-42AF-4429-9FD1-A8218C268E20 EE12F906-D277-404B-B6DA-E5FA1A576DF5 0 | Out-Null + powercfg /setdcvalueindex $($scheme.GUID) 501A4D13-42AF-4429-9FD1-A8218C268E20 EE12F906-D277-404B-B6DA-E5FA1A576DF5 0 | Out-Null + } + Write-Host "✓ PCIE Link State Power Management disabled for all schemes" -ForegroundColor Green + } catch { + Write-Host "⚠ PCIE Link State Power Management settings may not have been configured" -ForegroundColor Yellow + } + + try { + # Configure wake timers (allow all wake timers) + foreach ($scheme in $powerSchemes) { + Write-Host " - Enabling wake timers for '$($scheme.Name)'..." -ForegroundColor White + # Wake timers: 0=Disable, 1=Enable, 2=Important wake timers only + # Using actual GUIDs: SUB_SLEEP = 238C9FA8-0AAD-41ED-83F4-97BE242C8F20, Wake Timers = BD3B718A-0680-4D9D-8AB2-E1D2B4AC806D + powercfg /setacvalueindex $($scheme.GUID) 238C9FA8-0AAD-41ED-83F4-97BE242C8F20 BD3B718A-0680-4D9D-8AB2-E1D2B4AC806D 1 2>&1 | Out-Null + powercfg /setdcvalueindex $($scheme.GUID) 238C9FA8-0AAD-41ED-83F4-97BE242C8F20 BD3B718A-0680-4D9D-8AB2-E1D2B4AC806D 1 2>&1 | Out-Null + } + Write-Host "✓ Wake timers enabled for all schemes" -ForegroundColor Green + } catch { + Write-Host "⚠ Wake timer settings may not have been configured" -ForegroundColor Yellow + } + + try { + # Configure wireless adapter power saving mode (disable for maximum performance) + foreach ($scheme in $powerSchemes) { + Write-Host " - Setting wireless adapter to maximum performance for '$($scheme.Name)'..." -ForegroundColor White + # Wireless adapter power saving: 0=Maximum Performance, 1=Low Power Saving, 2=Medium Power Saving, 3=Maximum Power Saving + # Using actual GUIDs: SUB_RADIO = 19cbb8fa-5279-450e-9fac-8a3d5fedd0c1, RADIOPS = 12bbebe6-58d6-4636-95bb-3217ef867c1a + powercfg /setacvalueindex $($scheme.GUID) 19cbb8fa-5279-450e-9fac-8a3d5fedd0c1 12bbebe6-58d6-4636-95bb-3217ef867c1a 0 2>&1 | Out-Null + powercfg /setdcvalueindex $($scheme.GUID) 19cbb8fa-5279-450e-9fac-8a3d5fedd0c1 12bbebe6-58d6-4636-95bb-3217ef867c1a 0 2>&1 | Out-Null + } + Write-Host "✓ Wireless adapter set to maximum performance for all schemes" -ForegroundColor Green + } catch { + Write-Host "⚠ Wireless adapter settings may not have been configured" -ForegroundColor Yellow + } + + try { + # Configure video playback quality bias (set to video playback performance bias for maximum quality) + foreach ($scheme in $powerSchemes) { + Write-Host " - Setting video playback to maximum quality for '$($scheme.Name)'..." -ForegroundColor White + # Video playback quality bias: 0=Video playback power-saving bias, 1=Video playback performance bias + # Using actual GUIDs: SUB_MULTIMEDIA = 9596fb26-9850-41fd-ac3e-f7c3c00afd4b, VIDEOQUALITYBIAS = 10778347-1370-4ee0-8bbd-33bdacaade49 + powercfg /setacvalueindex $($scheme.GUID) 9596fb26-9850-41fd-ac3e-f7c3c00afd4b 10778347-1370-4ee0-8bbd-33bdacaade49 1 2>&1 | Out-Null + powercfg /setdcvalueindex $($scheme.GUID) 9596fb26-9850-41fd-ac3e-f7c3c00afd4b 10778347-1370-4ee0-8bbd-33bdacaade49 1 2>&1 | Out-Null + } + Write-Host "✓ Video playback set to maximum quality for all schemes" -ForegroundColor Green + } catch { + Write-Host "⚠ Video playback settings may not have been configured" -ForegroundColor Yellow + } + + try { + # Configure multimedia settings for optimal performance + foreach ($scheme in $powerSchemes) { + Write-Host " - Optimizing multimedia settings for '$($scheme.Name)'..." -ForegroundColor White + # When playing video: 0=Optimize video quality, 1=Balanced, 2=Optimize power savings + # Using actual GUIDs: SUB_MULTIMEDIA = 9596fb26-9850-41fd-ac3e-f7c3c00afd4b, WHENPLAYINGVIDEO = 34C7B99F-9A6D-4b3c-8DC7-B6693B78CEF4 + powercfg /setacvalueindex $($scheme.GUID) 9596fb26-9850-41fd-ac3e-f7c3c00afd4b 34C7B99F-9A6D-4b3c-8DC7-B6693B78CEF4 0 2>&1 | Out-Null + powercfg /setdcvalueindex $($scheme.GUID) 9596fb26-9850-41fd-ac3e-f7c3c00afd4b 34C7B99F-9A6D-4b3c-8DC7-B6693B78CEF4 0 2>&1 | Out-Null + } + Write-Host "✓ Multimedia settings optimized for all schemes" -ForegroundColor Green + } catch { + Write-Host "⚠ Multimedia settings may not have been configured" -ForegroundColor Yellow + } + Write-Host "" + + # Step 6: Verify hibernation status + Write-Host "Step 6: Verifying hibernation status..." -ForegroundColor Yellow + try { + $hibernationStatus = powercfg /availablesleepstates 2>&1 + if ($IsLaptop) { + # Laptops should have hibernation available + if ($hibernationStatus -like "*Hibernate*") { + Write-Host "✓ Hibernation is available (required for laptop critical battery action)" -ForegroundColor Green + } else { + Write-Host "⚠ Hibernation not available - critical battery will fall back to shutdown" -ForegroundColor Yellow + } + } else { + # Desktops should have hibernation disabled + if ($hibernationStatus -like "*Hibernate*") { + Write-Host "⚠ Hibernation may still be available" -ForegroundColor Yellow + Write-Host "Hibernation status: $hibernationStatus" -ForegroundColor Gray + } else { + Write-Host "✓ Hibernation is properly disabled" -ForegroundColor Green + } + } + } catch { + Write-Host "Could not verify hibernation status" -ForegroundColor Yellow + } + Write-Host "" + + # Step 7: Display current power scheme configuration + Write-Host "Step 7: Displaying current power configuration..." -ForegroundColor Yellow + + # Get the active power scheme + $activeScheme = $powerSchemes | Where-Object { $_.IsActive } + if ($activeScheme) { + Write-Host "Active Power Scheme: $($activeScheme.Name)" -ForegroundColor Cyan + + # Display key settings for the active scheme + try { + Write-Host "Current settings for active scheme:" -ForegroundColor White + + # Get hybrid sleep setting + $hybridSleepAC = powercfg /q $($activeScheme.GUID) 238C9FA8-0AAD-41ED-83F4-97BE242C8F20 94ac6d29-73ce-41a6-809f-6363ba21b47e | Select-String "Current AC Power Setting Index:" + $hybridSleepDC = powercfg /q $($activeScheme.GUID) 238C9FA8-0AAD-41ED-83F4-97BE242C8F20 94ac6d29-73ce-41a6-809f-6363ba21b47e | Select-String "Current DC Power Setting Index:" + Write-Host " - Hybrid Sleep (AC): $(if ($hybridSleepAC -match '0x00000000') { 'Disabled' } else { 'Enabled' })" -ForegroundColor Gray + Write-Host " - Hybrid Sleep (DC): $(if ($hybridSleepDC -match '0x00000000') { 'Disabled' } else { 'Enabled' })" -ForegroundColor Gray + + # Get sleep timeout settings + $sleepAC = powercfg /q $($activeScheme.GUID) 238C9FA8-0AAD-41ED-83F4-97BE242C8F20 29f6c1db-86da-48c5-9fdb-f2b67b1f44da | Select-String "Current AC Power Setting Index:" + $sleepDC = powercfg /q $($activeScheme.GUID) 238C9FA8-0AAD-41ED-83F4-97BE242C8F20 29f6c1db-86da-48c5-9fdb-f2b67b1f44da | Select-String "Current DC Power Setting Index:" + Write-Host " - Sleep Timeout (AC): $(if ($sleepAC -match '0x00000000') { 'Never' } else { 'Configured' })" -ForegroundColor Gray + Write-Host " - Sleep Timeout (DC): $(if ($sleepDC -match '0x00000000') { 'Never' } else { 'Configured' })" -ForegroundColor Gray + + # Get disk timeout settings + $diskAC = powercfg /q $($activeScheme.GUID) 0012EE47-9041-4B5D-9B77-535FBA8B1442 6738E2C4-E8A5-4A42-B16A-E040E769756E | Select-String "Current AC Power Setting Index:" + $diskDC = powercfg /q $($activeScheme.GUID) 0012EE47-9041-4B5D-9B77-535FBA8B1442 6738E2C4-E8A5-4A42-B16A-E040E769756E | Select-String "Current DC Power Setting Index:" + Write-Host " - Disk Timeout (AC): $(if ($diskAC -match '0x00000000') { 'Never' } else { 'Configured' })" -ForegroundColor Gray + Write-Host " - Disk Timeout (DC): $(if ($diskDC -match '0x00000000') { 'Never' } else { 'Configured' })" -ForegroundColor Gray + + } catch { + Write-Host "Could not retrieve detailed power settings" -ForegroundColor Yellow + } + } + Write-Host "" + + # Final summary + Write-Host "=== Configuration Summary ===" -ForegroundColor Cyan + Write-Host "Device Type: $(if ($IsLaptop) { 'Laptop' } else { 'Desktop' })" -ForegroundColor White + Write-Host "✓ Balanced power plan set as active" -ForegroundColor Green + Write-Host "✓ Display timeout disabled (never turn off)" -ForegroundColor Green + Write-Host "✓ Hybrid sleep disabled across all power plans" -ForegroundColor Green + Write-Host "✓ Fast startup disabled globally" -ForegroundColor Green + if ($IsLaptop) { + Write-Host "✓ Hibernation kept enabled (laptop - needed for critical battery)" -ForegroundColor Green + } else { + Write-Host "✓ Hibernation disabled (desktop)" -ForegroundColor Green + } + Write-Host "✓ Hard disk turn off disabled on all plans" -ForegroundColor Green + Write-Host "✓ Automatic sleep disabled across all plans" -ForegroundColor Green + Write-Host "✓ Lid close action set to sleep (laptops only)" -ForegroundColor Green + if ($IsLaptop) { + Write-Host "✓ Critical battery action set to hibernate (laptop)" -ForegroundColor Green + } else { + Write-Host "✓ Critical battery action set to shutdown (desktop)" -ForegroundColor Green + } + Write-Host "✓ USB selective suspend disabled for stability" -ForegroundColor Green + Write-Host "✓ PCIE Link State Power Management disabled for stability" -ForegroundColor Green + Write-Host "✓ Wake timers enabled to allow scheduled tasks" -ForegroundColor Green + Write-Host "✓ Wireless adapters set to maximum performance" -ForegroundColor Green + Write-Host "✓ Video playback optimized for maximum quality" -ForegroundColor Green + Write-Host "✓ Multimedia settings optimized for best performance" -ForegroundColor Green + if ($disableShutdown) { + Write-Host "✓ Shutdown UI disabled (NoClose) + power button short-press = Do nothing" -ForegroundColor Green + } else { + Write-Host "✓ Shutdown UI available (NoClose removed) + power button short-press = Shut down" -ForegroundColor Green + } + Write-Host "===============================" -ForegroundColor Cyan + Write-Host "" + + Write-Host "Power management configuration completed successfully!" -ForegroundColor Green + Write-Host "Note: Some settings may require a system restart to take full effect." -ForegroundColor Yellow + +} catch { + Write-Error "An error occurred during power management configuration: $($_.Exception.Message)" + Write-Host "Error details: $($_.Exception)" -ForegroundColor Red + if ($TranscriptStarted) { Stop-Transcript } + exit 1 +} + +if ($TranscriptStarted) { Stop-Transcript } + +exit 0 diff --git a/msft-windows/msft-windows-os-edition-inventory.ps1 b/msft-windows/msft-windows-os-edition-inventory.ps1 new file mode 100644 index 0000000..185b23a --- /dev/null +++ b/msft-windows/msft-windows-os-edition-inventory.ps1 @@ -0,0 +1,135 @@ +## PLEASE COMMENT YOUR VARIABLES DIRECTLY BELOW HERE IF YOU'RE RUNNING FROM A RMM +## NinjaRMM passes script preset variables as environment variables, so each is read via $env: in this script. +## $env:RMM - Set to "1" by NinjaRMM to indicate RMM (non-interactive) mode +## $env:Description - Ticket # or initials for audit trail +## $env:RMMScriptPath - Optional log directory base provided by the RMM +## +## $env:CustomFieldServerEdition - Text field name for the server edition callout (default: "serverEdition") +## +## NOTE: Ninja-Property-Set only works in SYSTEM context. Run this script as SYSTEM. + +# Server Edition inventory for NinjaRMM. +# +# Writes one custom field: +# serverEdition - Server edition callout, one of: +# "Standard", "Datacenter", "Standard (Evaluation)", +# "Datacenter (Evaluation)", " (Evaluation)", +# "" for other server SKUs, or +# "N/A - Workstation OS" for non-server systems. +# +# Edition is resolved from the registry EditionID (cleanest source for +# Standard/Datacenter and the *Eval evaluation SKUs) with the WMI Caption as a +# fallback. ProductType distinguishes server (2 = DC, 3 = member server) from +# workstation (1). + +$ScriptLogName = "msft-windows-os-edition-inventory.log" + +# --- Default optional RMM environment variables -------------------------- + +if ([string]::IsNullOrEmpty($env:CustomFieldServerEdition)) { + $env:CustomFieldServerEdition = "serverEdition" +} + +# --- Input handling: RMM vs interactive ---------------------------------- + +if ($env:RMM -ne "1") { + $ValidInput = 0 + while ($ValidInput -ne 1) { + $env:Description = Read-Host "Please enter the ticket # and/or your initials for audit trail" + if ($env:Description) { + $ValidInput = 1 + } else { + Write-Host "Invalid input. Please try again." + } + } + $LogPath = "$env:WINDIR\logs\$ScriptLogName" +} else { + if (-not [string]::IsNullOrEmpty($env:RMMScriptPath)) { + $LogPath = "$env:RMMScriptPath\logs\$ScriptLogName" + } else { + $LogPath = "$env:WINDIR\logs\$ScriptLogName" + } + + if ([string]::IsNullOrEmpty($env:Description)) { + Write-Host "Description is null. This was most likely run automatically from the RMM and no information was passed." + $env:Description = "No Description" + } +} + +# Ensure log directory exists before starting the transcript +$logDir = Split-Path -Path $LogPath -Parent +if (-not (Test-Path -Path $logDir)) { + Write-Host "Creating log directory: $logDir" + New-Item -Path $logDir -ItemType Directory -Force | Out-Null +} + +# --- Script logic -------------------------------------------------------- + +Start-Transcript -Path $LogPath + +Write-Host "Description: $env:Description" +Write-Host "Log path: $LogPath" +Write-Host "RMM: $env:RMM" + +# --- Gather OS facts ----------------------------------------------------- + +$os = Get-CimInstance -ClassName Win32_OperatingSystem +$caption = ($os.Caption).Trim() + +# ProductType: 1 = Workstation, 2 = Domain Controller, 3 = Server +$isServer = $os.ProductType -ne 1 + +# EditionID is the cleanest edition source: ServerStandard, ServerDatacenter, +# ServerStandardEval, ServerDatacenterEval, ServerStandardCore, etc. +$cvKey = "HKLM:\SOFTWARE\Microsoft\Windows NT\CurrentVersion" +$editionId = (Get-ItemProperty -Path $cvKey -Name EditionID -ErrorAction SilentlyContinue).EditionID + +Write-Host "Caption: $caption" +Write-Host "EditionID: $editionId" +Write-Host "ProductType: $($os.ProductType) (IsServer: $isServer)" + +# --- Determine server edition callout ----------------------------------- +# Check EditionID first, fall back to Caption for both family and evaluation. + +$isEval = ($editionId -match 'Eval') -or ($caption -match 'Evaluation') +$isDatacenter = ($editionId -match 'Datacenter') -or ($caption -match 'Datacenter') +$isStandard = ($editionId -match 'Standard') -or ($caption -match 'Standard') + +if (-not $isServer) { + $serverEdition = "N/A - Workstation OS" +} else { + if ($isDatacenter) { + $serverEdition = "Datacenter" + } elseif ($isStandard) { + $serverEdition = "Standard" + } elseif (-not [string]::IsNullOrEmpty($editionId)) { + # Other server SKU (Essentials, Web, Storage, etc.) - report it verbatim. + $serverEdition = $editionId + } else { + $serverEdition = "Unknown server edition" + } + + if ($isEval) { + $serverEdition += " (Evaluation)" + Write-Host "WARNING: This server is running an EVALUATION edition of Windows Server. Evaluation builds expire and will begin shutting down automatically. Confirm licensing/activation status." + } +} + +Write-Host "----------------------------------------" +Write-Host "Server Edition field value: $serverEdition" +Write-Host "----------------------------------------" + +# --- Write to NinjaRMM custom field (SYSTEM/RMM context only) ------------ + +if ($env:RMM -eq "1") { + try { + Ninja-Property-Set -Name $env:CustomFieldServerEdition -Value $serverEdition + Write-Host "Wrote server edition to '$env:CustomFieldServerEdition'" + } catch { + Write-Host "ERROR: Failed to write '$env:CustomFieldServerEdition' - $_" + } +} else { + Write-Host "Interactive mode - skipping Ninja-Property-Set (cmdlet only works in SYSTEM/RMM context)." +} + +Stop-Transcript diff --git a/msft-windows/msft-windows-power-management-config.ps1 b/msft-windows/msft-windows-power-management-config.ps1 deleted file mode 100644 index 8de3304..0000000 Binary files a/msft-windows/msft-windows-power-management-config.ps1 and /dev/null differ diff --git a/msft-windows/msft-windows-suspend-bitlocker.ps1 b/msft-windows/msft-windows-suspend-bitlocker.ps1 index 42f26fc..7f7e735 100644 --- a/msft-windows/msft-windows-suspend-bitlocker.ps1 +++ b/msft-windows/msft-windows-suspend-bitlocker.ps1 @@ -1,13 +1,26 @@ -# Getting input from user if not running from RMM else set variables from RMM. +## PLEASE COMMENT YOUR VARIABLES DIRECTLY BELOW HERE IF YOU'RE RUNNING FROM A RMM +## THIS IS HOW WE EASILY LET PEOPLE KNOW WHAT VARIABLES NEED SET IN THE RMM +## $env:RMM - "1" when executed from the RMM (string). Anything else = interactive. +## $env:Description - ticket # and/or initials, used as the job description +## $env:RebootCount - reboots BitLocker stays suspended before it auto-resumes (optional, default 1) +## +## Exit codes: 0 = system volume protection is OFF (suspended, or no BitLocker) -> safe to reboot. +## 1 = system volume is STILL protected (suspend failed) -> NOT safe to reboot. $ScriptLogName = "bitlocker-suspend.log" -if ($RMM -ne 1) { +# Resolve RebootCount from the RMM environment variable; default to 1 if unset/invalid. +# 1 = suspend for exactly the next reboot, then BitLocker re-protects itself. +$RebootCount = 1 +$parsedCount = 0 +if ($env:RebootCount -and [int]::TryParse($env:RebootCount, [ref]$parsedCount) -and $parsedCount -ge 1) { + $RebootCount = $parsedCount +} + +if ($env:RMM -ne "1") { + # Interactive run: prompt for a description. $ValidInput = 0 - # Checking for valid input. while ($ValidInput -ne 1) { - # Ask for input here. This is the interactive area for getting variable information. - # Remember to make ValidInput = 1 whenever correct input is given. $Description = Read-Host "Please enter the ticket # and, or your initials. Its used as the Description for the job" if ($Description) { $ValidInput = 1 @@ -15,60 +28,79 @@ if ($RMM -ne 1) { Write-Host "Invalid input. Please try again." } } - $LogPath = "$ENV:WINDIR\logs\$ScriptLogName" + $LogPath = "$env:WINDIR\logs\$ScriptLogName" -} else { - # Store the logs in the RMMScriptPath - if ($null -eq $RMMScriptPath) { - $LogPath = "$RMMScriptPath\logs\$ScriptLogName" - +} else { + # RMM run: all variables arrive as environment variables. + if (-not [string]::IsNullOrEmpty($env:RMMScriptPath)) { + $LogPath = "$env:RMMScriptPath\logs\$ScriptLogName" } else { - $LogPath = "$ENV:WINDIR\logs\$ScriptLogName" - + $LogPath = "$env:WINDIR\logs\$ScriptLogName" } - if ($null -eq $Description) { + $Description = $env:Description + if ([string]::IsNullOrEmpty($Description)) { Write-Host "Description is null. This was most likely run automatically from the RMM and no information was passed." $Description = "No Description" - } - - - + } } Start-Transcript -Path $LogPath -Write-Host "Description: $Description" -Write-Host "Log path: $LogPath" -Write-Host "RMM: $RMM" +Write-Host "================ Suspend BitLocker ================" +Write-Host "Description : $Description" +Write-Host "Log path : $LogPath" +Write-Host "RMM mode : $env:RMM" +Write-Host "RebootCount : $RebootCount" +Write-Host "===================================================" -# Function to suspend BitLocker encryption on all volumes -function Suspend-AllBitLocker { - try { - # Get all BitLocker encrypted volumes - $bitLockerVolumes = Get-BitLockerVolume | Where-Object { $_.VolumeStatus -eq 'FullyEncrypted' } +# Suspend BitLocker on the system volume only. Only the OS/system volume can throw +# the pre-boot recovery prompt; fixed data volumes auto-unlock from the +# (suspended-but-bootable) system drive once Windows is up, so this covers the reboot. +function Suspend-SystemBitLocker { + param([int]$RebootCount = 1) + $mp = $env:SystemDrive - if ($bitLockerVolumes.Count -gt 0) { - foreach ($volume in $bitLockerVolumes) { - # Suspend BitLocker encryption - Suspend-BitLocker -MountPoint $volume.MountPoint -RebootCount 0 -Verbose + Write-Host "[1/3] Checking BitLocker on system volume $mp ..." + try { + $vol = Get-BitLockerVolume -MountPoint $mp -ErrorAction Stop + } catch { + Write-Host "ERROR: could not query BitLocker on ${mp}: $($_.Exception.Message)" + Write-Host "RESULT: unable to determine BitLocker state on $mp -> EXIT 1" + return 1 + } + Write-Host " VolumeStatus : $($vol.VolumeStatus)" + Write-Host " ProtectionStatus : $($vol.ProtectionStatus)" - Write-Output "BitLocker encryption on volume $($volume.MountPoint) has been suspended." - } - } else { - Write-Output "No BitLocker encrypted volumes found." - } - Exit 0 + if ($vol.ProtectionStatus -ne 'On') { + Write-Host "[2/3] Protection already OFF on $mp - nothing to suspend." + Write-Host "RESULT: $mp is NOT protected -> safe to reboot -> EXIT 0" + return 0 } - catch { - Write-Error "An error occurred: $_" - exit 1 + + Write-Host "[2/3] Suspending BitLocker on $mp for $RebootCount reboot(s) ..." + try { + Suspend-BitLocker -MountPoint $mp -RebootCount $RebootCount -ErrorAction Stop | Out-Null + } catch { + Write-Host "ERROR: Suspend-BitLocker failed on ${mp}: $($_.Exception.Message)" + Write-Host "RESULT: suspend FAILED on $mp -> still protected -> EXIT 1" + return 1 } -} -# Call the function to suspend BitLocker on all volumes -Suspend-AllBitLocker + Write-Host "[3/3] Verifying protection is now off ..." + $vol = Get-BitLockerVolume -MountPoint $mp + Write-Host " ProtectionStatus after suspend : $($vol.ProtectionStatus)" + if ($vol.ProtectionStatus -eq 'On') { + Write-Host "RESULT: $mp STILL PROTECTED after suspend -> NOT safe to reboot -> EXIT 1" + return 1 + } + Write-Host "RESULT: BitLocker on $mp SUSPENDED for $RebootCount reboot(s) -> safe to reboot -> EXIT 0" + return 0 +} +$exitCode = Suspend-SystemBitLocker -RebootCount $RebootCount +Write-Host "Final exit code: $exitCode" Stop-Transcript +exit $exitCode diff --git a/rmm-ninja/lockhart-remediation.ps1 b/rmm-ninja/lockhart-remediation.ps1 new file mode 100644 index 0000000..b4b664c --- /dev/null +++ b/rmm-ninja/lockhart-remediation.ps1 @@ -0,0 +1,1295 @@ +<# +.SYNOPSIS + NinjaOne Backup - Lockhart Remediation / Repair. Diagnoses and repairs failed + NinjaOne Backup jobs by examining the Lockhart service and its dependencies, + then restoring the device to a backup-ready state when safe to do so. + +.DESCRIPTION + Responds to NinjaOne condition "Backup Job Last success 25 hours ago" + (policy 58). Flow: HV0-skip -> CHECK -> DIAGNOSE -> REPAIR (gated) -> + CONFIRM -> STATE. + + Excluded hosts: any hostname matching ^HV0 (Hyper-V hosts). Checked at + entry point before any file writes, transcript, or mutex creation. + + RMM INTEGRATION (per repo CLAUDE.md): + - Execution context detected via $env:RMM ('1' = RMM mode). + - NinjaRMM passes script preset variables as ENVIRONMENT variables. + Every parameter below can be overridden by a same-named NinjaOne + script variable. In particular the NOC checkboxes: + ForceDisruptiveRepairs=1 and ClearStateAndExit=1 + are read from $env: (CLI switches also work for interactive use). + - $env:Description captured for audit trail. + - Transcript logging: $env:RMMScriptPath\logs\ in RMM mode (fallback + $env:WINDIR\logs\), $env:WINDIR\logs\ interactive. 10MB rotation. + - Template deviation (deliberate): no Read-Host prompts. This script + is condition-triggered automation and must never block on input; + a hung prompt would silence backup remediation fleet-wide. + + Device-offline short-circuit (sole reason, no counter increment): + - Public internet unreachable + - System uptime < MinUptimeMinutes + + Business hours gating (default 07:00-18:00 device-local): + During business hours, these repairs defer to off-hours: + - Dnscache service restart + - ARP cache clear + - VSS DLL re-registration (escalation) + - NinjaRMMAgent restart (escalation) + Override with -ForceDisruptiveRepairs or env ForceDisruptiveRepairs=1. + + Anytime repairs: + - DNS cache flush + - Time resync + - Windows\Temp cleanup + - NinjaRMMAgent start (if stopped) + - VSS stack reset + - Force-kill stuck Lockhart + - Lockhart start/restart + + Diagnosed but not repaired (forensic capture, NOC escalation): + - Cloud endpoints unreachable post-repair + - DNS / default gateway unreachable post-repair + - AV quarantine of lockhart.exe + - Lockhart binary missing + - agent.yaml missing or corrupt + - Disk space critically low post-cleanup + + Counter: tracks remediation INTERVENTIONS (successful or not) within the + CounterResetHours window. Auto-reset after a 48h gap, or when a run finds + everything healthy with no repairs needed (AlreadyHealthy_CounterCleared). + No increment on DeviceOffline*, RemediationDeferred, or LiveJobSkipped. + Guardrail at MaxConsecutiveAttempts interventions - needing repeated + intervention, even successful, indicates a recurring root cause that + requires human investigation. + + Auto-close: NinjaOne condition clears alert + Halo ticket when next + scheduled backup succeeds. Script does not touch alert or Halo flow. + +.PARAMETER SampleSeconds + Duration of process I/O + CPU sampling window for live backup detection. + Default 90. Lower = faster runs but more false-positive "stuck" classifications. + +.PARAMETER ActiveIoThresholdMB + Process I/O delta in MB over SampleSeconds that indicates an active backup. + Default 10. + +.PARAMETER ActiveCpuThresholdSec + Process CPU delta in seconds over SampleSeconds that indicates active work. + Default 5.0. + +.PARAMETER MaxConsecutiveAttempts + Number of consecutive remediation interventions (successful or failed) + within the CounterResetHours window before the script stops acting and + escalates to NOC. Repeated interventions - even when each one succeeds - + indicate a recurring underlying issue. Default 2. + +.PARAMETER CounterResetHours + Hours of no script execution before the intervention counter auto-resets. + Default 48 (the condition wouldn't re-fire if backups were working). + +.PARAMETER MinFreeSpacePercent + Minimum system drive free space percentage. Below this triggers temp cleanup + and is reported as a hard blocker if cleanup doesn't recover it. Default 10. + +.PARAMETER NetTestTimeoutMs + TCP connection test timeout per endpoint in milliseconds. Default 5000. + +.PARAMETER DnsTimeoutMs + DNS resolution test timeout per host in milliseconds. Default 4000. + +.PARAMETER TempCleanupMinAgeDays + Files in Windows\Temp older than this many days are eligible for cleanup + when free space is low. Default 7. + +.PARAMETER MaxRuntimeSeconds + Hard cap on total script runtime. Background job force-kills the script + process if this is exceeded. Default 400. + +.PARAMETER HistoryRetentionEntries + Number of historical run entries kept in the state file. Default 20. + +.PARAMETER TimeSkewToleranceMinutes + Time sync age (minutes since last successful w32tm sync) above which + triggers a resync. Default 5. + +.PARAMETER BusinessHoursStartHour + Hour of day (0-23, device-local time) when business hours start. Default 7. + +.PARAMETER BusinessHoursEndHour + Hour of day (0-23, device-local time) when business hours end. Default 18. + +.PARAMETER MinUptimeMinutes + System uptime below this triggers DeviceOffline_RecentReboot. Default 30. + +.PARAMETER ForceDisruptiveRepairs + Override business-hours gating and allow disruptive repairs immediately. + RMM: set NinjaOne script variable ForceDisruptiveRepairs (Checkbox) - read + via $env:ForceDisruptiveRepairs per repo convention. CLI switch works for + interactive runs. + +.PARAMETER ClearStateAndExit + Wipe state file and exit without remediation. Used by NOC after manually + resolving an underlying issue on a device sitting at MaxAttemptsReached. + RMM: set NinjaOne script variable ClearStateAndExit (Checkbox) - read via + $env:ClearStateAndExit per repo convention. CLI switch works for + interactive runs. + +.NOTES + Author: Zachary Boogher, DTC + Date: 2026-06-03 + Version: 8.0 + Repo: dtc-inc/msp-script-library + Path: rmm-ninja/lockhart-remediation.ps1 + Target: PowerShell 5.1 (NinjaOne) + Requires: LocalSystem / Administrator privileges + Runtime: ~120-180 seconds typical + Exit: 0 = success / not applicable / live job skipped / deferred + 1 = remediation failed or hard blocker + 2 = max attempts reached, NOC must investigate +#> + +## PLEASE COMMENT YOUR VARIABLES DIRECTLY BELOW HERE IF YOU'RE RUNNING FROM A RMM +## $RMM - Set to 1 when running from RMM (selects RMMScriptPath log location) +## $Description - Ticket # or initials for audit trail (optional; defaulted if blank) +## $ForceDisruptiveRepairs - 1 to override business-hours gating (Checkbox) +## $ClearStateAndExit - 1 to wipe remediation state file and exit (Checkbox) +## Optional tuning overrides (advanced; same names as parameters): +## $SampleSeconds, $ActiveIoThresholdMB, $ActiveCpuThresholdSec, +## $MaxConsecutiveAttempts, $CounterResetHours, $MinFreeSpacePercent, +## $NetTestTimeoutMs, $DnsTimeoutMs, $TempCleanupMinAgeDays, +## $MaxRuntimeSeconds, $HistoryRetentionEntries, $TimeSkewToleranceMinutes, +## $BusinessHoursStartHour, $BusinessHoursEndHour, $MinUptimeMinutes + +[Diagnostics.CodeAnalysis.SuppressMessageAttribute('PSReviewUnusedParameter', '', + Justification = 'All parameters consumed by Invoke-LockhartRemediation via script-scope variable inheritance; PSScriptAnalyzer does not follow control flow into nested function calls.')] +[CmdletBinding()] +param( + [int]$SampleSeconds = 90, + [int]$ActiveIoThresholdMB = 10, + [double]$ActiveCpuThresholdSec = 5.0, + [int]$MaxConsecutiveAttempts = 2, + [int]$CounterResetHours = 48, + [int]$MinFreeSpacePercent = 10, + [int]$NetTestTimeoutMs = 5000, + [int]$DnsTimeoutMs = 4000, + [int]$TempCleanupMinAgeDays = 7, + [int]$MaxRuntimeSeconds = 400, + [int]$HistoryRetentionEntries = 20, + [int]$TimeSkewToleranceMinutes = 5, + [int]$BusinessHoursStartHour = 7, + [int]$BusinessHoursEndHour = 18, + [int]$MinUptimeMinutes = 30, + [switch]$ForceDisruptiveRepairs, + [switch]$ClearStateAndExit +) + +$ErrorActionPreference = 'Stop' +$ProgressPreference = 'SilentlyContinue' + +# ============================================================ +# INPUT HANDLING SECTION (per CLAUDE.md / script-template-powershell.ps1) +# NinjaRMM passes preset variables as ENVIRONMENT variables. Bare param +# references do not bind from env vars, so every RMM-suppliable value is +# resolved here: env var wins when present and valid, else the param/default. +# NOTE (template deviation, deliberate): no Read-Host. This script is +# condition-triggered automation and must never block on input - a hung +# prompt would silence backup remediation fleet-wide if the RMM preset +# were ever missing. Interactive runs use parameter defaults instead. +# ============================================================ +function Get-RmmInt { + param([string]$Name, [int]$Current) + $v = [Environment]::GetEnvironmentVariable($Name) + if ($v -match '^\d+$') { return [int]$v } + return $Current +} + +function Get-RmmDouble { + param([string]$Name, [double]$Current) + $v = [Environment]::GetEnvironmentVariable($Name) + $out = 0.0 + if (-not [string]::IsNullOrWhiteSpace($v) -and [double]::TryParse($v, [ref]$out)) { return $out } + return $Current +} + +function Test-RmmFlag { + param([string]$Name) + return ([Environment]::GetEnvironmentVariable($Name) -match '^(?i)(1|true|yes|on)$') +} + +# Numeric tunables: env override -> param -> default +$SampleSeconds = Get-RmmInt 'SampleSeconds' $SampleSeconds +$ActiveIoThresholdMB = Get-RmmInt 'ActiveIoThresholdMB' $ActiveIoThresholdMB +$ActiveCpuThresholdSec = Get-RmmDouble 'ActiveCpuThresholdSec' $ActiveCpuThresholdSec +$MaxConsecutiveAttempts = Get-RmmInt 'MaxConsecutiveAttempts' $MaxConsecutiveAttempts +$CounterResetHours = Get-RmmInt 'CounterResetHours' $CounterResetHours +$MinFreeSpacePercent = Get-RmmInt 'MinFreeSpacePercent' $MinFreeSpacePercent +$NetTestTimeoutMs = Get-RmmInt 'NetTestTimeoutMs' $NetTestTimeoutMs +$DnsTimeoutMs = Get-RmmInt 'DnsTimeoutMs' $DnsTimeoutMs +$TempCleanupMinAgeDays = Get-RmmInt 'TempCleanupMinAgeDays' $TempCleanupMinAgeDays +$MaxRuntimeSeconds = Get-RmmInt 'MaxRuntimeSeconds' $MaxRuntimeSeconds +$HistoryRetentionEntries = Get-RmmInt 'HistoryRetentionEntries' $HistoryRetentionEntries +$TimeSkewToleranceMinutes = Get-RmmInt 'TimeSkewToleranceMinutes' $TimeSkewToleranceMinutes +$BusinessHoursStartHour = Get-RmmInt 'BusinessHoursStartHour' $BusinessHoursStartHour +$BusinessHoursEndHour = Get-RmmInt 'BusinessHoursEndHour' $BusinessHoursEndHour +$MinUptimeMinutes = Get-RmmInt 'MinUptimeMinutes' $MinUptimeMinutes + +# NOC checkboxes: env var (NinjaOne UI path) OR CLI switch (interactive path) +$script:ForceDisruptiveResolved = $ForceDisruptiveRepairs.IsPresent -or (Test-RmmFlag 'ForceDisruptiveRepairs') +$script:ClearStateResolved = $ClearStateAndExit.IsPresent -or (Test-RmmFlag 'ClearStateAndExit') + +# Execution context + audit trail (env vars are strings: compare against '1') +$script:IsRmmMode = ($env:RMM -eq '1') +$script:Description = if (-not [string]::IsNullOrWhiteSpace($env:Description)) { $env:Description } + else { 'No description provided (automated condition trigger)' } + +# Transcript log path per CLAUDE.md: SYSTEM-context script +$script:ScriptLogName = 'lockhart-remediation.log' +if ($script:IsRmmMode -and -not [string]::IsNullOrWhiteSpace($env:RMMScriptPath)) { + $script:LogPath = Join-Path $env:RMMScriptPath "logs\$($script:ScriptLogName)" +} else { + $script:LogPath = Join-Path $env:WINDIR "logs\$($script:ScriptLogName)" +} + +# =================== constants =================== +$script:ServiceName = 'Lockhart' +$script:ParentAgentName = 'NinjaRMMAgent' +$script:MutexName = 'Global\DTC_NinjaOneBackup_LockhartRemediation_v8' +$script:CloudEndpoints = @( + @{ Host='app.ninjarmm.com'; Port=443 }, + @{ Host='s3.amazonaws.com'; Port=443 }, + @{ Host='s3.us-east-1.amazonaws.com'; Port=443 } +) +$script:OldStateFile = 'C:\ProgramData\DTC\lockhart_autoremediation.json' # legacy v5/v6 location + +# =================== logging =================== +function Write-DTCLog { + [Diagnostics.CodeAnalysis.SuppressMessageAttribute('PSAvoidUsingWriteHost', '', + Justification = 'NinjaOne captures Write-Host output for script result display; Write-Information is suppressed by default in NinjaOne agent context.')] + [CmdletBinding()] + param( + [ValidateSet('INFO','WARN','ERROR','DEBUG')][string]$Level, + [string]$Message + ) + Write-Host "[$((Get-Date).ToString('yyyy-MM-dd HH:mm:ss'))][$Level] $Message" +} + +# =================== transcript =================== +function Start-RemediationTranscript { + [Diagnostics.CodeAnalysis.SuppressMessageAttribute('PSUseShouldProcessForStateChangingFunctions', '', + Justification = 'Script runs unattended via NinjaOne; ShouldProcess support is dead code in this runtime.')] + [CmdletBinding()] + param() + try { + $dir = Split-Path $script:LogPath -Parent + if (-not (Test-Path $dir)) { New-Item -ItemType Directory -Path $dir -Force | Out-Null } + if ((Test-Path $script:LogPath) -and ((Get-Item $script:LogPath).Length -gt 10MB)) { + Move-Item -Path $script:LogPath -Destination "$($script:LogPath).old" -Force + } + Start-Transcript -Path $script:LogPath -Append -ErrorAction Stop | Out-Null + return $true + } catch { + Write-Verbose "Transcript start failed (continuing without): $_" + return $false + } +} + +# =================== device class =================== +function Get-DeviceClass { + try { + $os = Get-CimInstance Win32_OperatingSystem -ErrorAction Stop + if ($os.ProductType -in @(2,3)) { return 'Server' } + return 'Workstation' + } catch { + Write-Verbose "Get-DeviceClass failed, defaulting to Workstation: $_" + return 'Workstation' + } +} + +# =================== paths (resolved after device class detection) =================== +function Initialize-Path { + param([string]$DeviceClass) + $base = if ($DeviceClass -eq 'Server') { 'C:\DTC' } else { 'C:\ProgramData\DTC' } + $script:StateDir = $base + $script:StateFile = Join-Path $base 'lockhart_autoremediation.json' + $script:ForensicsDir = Join-Path $base 'Forensics' +} + +function Move-LegacyStateFile { + [Diagnostics.CodeAnalysis.SuppressMessageAttribute('PSUseShouldProcessForStateChangingFunctions', '', + Justification = 'Script runs unattended via NinjaOne; ShouldProcess support is dead code in this runtime.')] + [CmdletBinding()] + param() + if ($script:StateFile -eq $script:OldStateFile) { return } + if (Test-Path $script:StateFile) { return } + if (-not (Test-Path $script:OldStateFile)) { return } + try { + if (-not (Test-Path $script:StateDir)) { New-Item -Path $script:StateDir -ItemType Directory -Force | Out-Null } + Move-Item -Path $script:OldStateFile -Destination $script:StateFile -Force -ErrorAction Stop + Write-DTCLog INFO "Migrated state file from $($script:OldStateFile) to $($script:StateFile)" + } catch { + Write-Verbose "Legacy state migration failed (continuing with empty state): $_" + } +} + +# =================== business hours =================== +function Test-WithinBusinessHours { + [Diagnostics.CodeAnalysis.SuppressMessageAttribute('PSUseSingularNouns', '', + Justification = '"BusinessHours" is a compound noun for the configured operating window; singular changes meaning.')] + param([int]$StartHour, [int]$EndHour) + $h = (Get-Date).Hour + return ($h -ge $StartHour -and $h -lt $EndHour) +} + +# =================== state file =================== +function Get-State { + if (-not (Test-Path $script:StateDir)) { New-Item -Path $script:StateDir -ItemType Directory -Force | Out-Null } + if (Test-Path $script:StateFile) { + try { + return Get-Content $script:StateFile -Raw -ErrorAction Stop | ConvertFrom-Json -ErrorAction Stop + } catch { + Write-DTCLog WARN "State file unreadable, resetting: $_" + } + } + return [PSCustomObject]@{ + ConsecutiveAttempts = 0 + LastRun = $null + LastResult = 'Unknown' + History = @() + } +} + +function Set-State { + [Diagnostics.CodeAnalysis.SuppressMessageAttribute('PSUseShouldProcessForStateChangingFunctions', '', + Justification = 'Script runs unattended via NinjaOne; ShouldProcess support is dead code in this runtime.')] + [CmdletBinding()] + param($State) + if ($State.History.Count -gt $HistoryRetentionEntries) { + $State.History = @($State.History | Select-Object -First $HistoryRetentionEntries) + } + try { + $State | ConvertTo-Json -Depth 8 | Set-Content -Path $script:StateFile -Encoding UTF8 -ErrorAction Stop + } catch { + Write-DTCLog ERROR "Failed to write state file: $_" + } +} + +# =================== diagnostics =================== +function Get-SystemUptime { + try { + $os = Get-CimInstance Win32_OperatingSystem -ErrorAction Stop + return [math]::Round(((Get-Date) - $os.LastBootUpTime).TotalMinutes, 1) + } catch { + Write-Verbose "Get-SystemUptime failed: $_" + return $null + } +} + +function Test-PublicInternet { + [Diagnostics.CodeAnalysis.SuppressMessageAttribute('PSAvoidUsingComputerNameHardcoded', '', + Justification = '1.1.1.1 (Cloudflare DNS) is the intentional public internet reachability probe, not a managed device.')] + [CmdletBinding()] + param() + try { return [bool](Test-Connection -ComputerName '1.1.1.1' -Count 2 -Quiet -ErrorAction SilentlyContinue) } + catch { + Write-Verbose "Test-PublicInternet failed: $_" + return $false + } +} + +function Test-TcpEndpoint { + param([string]$TargetHost, [int]$Port, [int]$TimeoutMs) + $client = New-Object System.Net.Sockets.TcpClient + try { + $async = $client.BeginConnect($TargetHost, $Port, $null, $null) + if (-not $async.AsyncWaitHandle.WaitOne($TimeoutMs, $false)) { return $false } + $client.EndConnect($async) | Out-Null + return $true + } catch { + Write-Verbose "Test-TcpEndpoint $TargetHost`:$Port failed: $_" + return $false + } finally { + try { $client.Close() } catch { Write-Verbose "TcpClient close failed (expected during cleanup): $_" } + } +} + +function Test-DnsResolutionWithTimeout { + param([string]$TargetHost, [int]$TimeoutMs) + $ps = [PowerShell]::Create() + [void]$ps.AddScript({ param($h) try { [System.Net.Dns]::GetHostAddresses($h) | Out-Null; $true } catch { $false } }).AddArgument($TargetHost) + $async = $ps.BeginInvoke() + if ($async.AsyncWaitHandle.WaitOne($TimeoutMs)) { + try { return [bool]($ps.EndInvoke($async))[0] } + catch { + Write-Verbose "DNS EndInvoke failed for $TargetHost`: $_" + return $false + } finally { $ps.Dispose() } + } else { + try { $ps.Stop() } catch { Write-Verbose "PS.Stop failed for DNS timeout: $_" } + $ps.Dispose() + return $false + } +} + +$script:ServiceCache = @{} +function Get-ServiceState { + param([string]$Name, [switch]$Refresh) + if ($Refresh -or -not $script:ServiceCache.ContainsKey($Name)) { + $cim = Get-CimInstance Win32_Service -Filter "Name='$Name'" -ErrorAction SilentlyContinue + if (-not $cim) { + $script:ServiceCache[$Name] = [PSCustomObject]@{ + Exists=$false; State='NotInstalled'; ProcessId=0; PathName=$null; StartMode=$null + } + } else { + $script:ServiceCache[$Name] = [PSCustomObject]@{ + Exists=$true; State=$cim.State; ProcessId=$cim.ProcessId + PathName=$cim.PathName; StartMode=$cim.StartMode + } + } + } + return $script:ServiceCache[$Name] +} + +function Get-ExePathFromService { + param($SvcInfo) + if (-not $SvcInfo -or -not $SvcInfo.PathName) { return $null } + if ($SvcInfo.PathName.StartsWith('"')) { + return $SvcInfo.PathName.Substring(1).Split('"')[0] + } + return ($SvcInfo.PathName -split ' ')[0] +} + +function Get-BinaryVersion { + param([string]$Path) + try { + if (Test-Path $Path) { return (Get-Item $Path).VersionInfo.FileVersion } + } catch { + Write-Verbose "Get-BinaryVersion failed for $Path`: $_" + } + return $null +} + +function Test-AgentYaml { + param($LockhartSvcInfo) + $exe = Get-ExePathFromService -SvcInfo $LockhartSvcInfo + if (-not $exe) { return [PSCustomObject]@{ Exists=$false; Readable=$false; Valid=$false; Path=$null } } + $yamlPath = Join-Path (Split-Path $exe) 'agent.yaml' + if (-not (Test-Path $yamlPath)) { + return [PSCustomObject]@{ Exists=$false; Readable=$false; Valid=$false; Path=$yamlPath } + } + try { + $content = Get-Content $yamlPath -Raw -ErrorAction Stop + $valid = ($content -match 'logs:') -and ($content -match 'backup:') + return [PSCustomObject]@{ + Exists=$true; Readable=$true; Valid=$valid; Path=$yamlPath + SizeBytes=(Get-Item $yamlPath).Length + } + } catch { + Write-Verbose "Test-AgentYaml read failed: $_" + return [PSCustomObject]@{ Exists=$true; Readable=$false; Valid=$false; Path=$yamlPath } + } +} + +function Get-SystemDriveFreePct { + try { + $d = Get-PSDrive -Name ($env:SystemDrive[0]) -ErrorAction Stop + $total = $d.Used + $d.Free + if ($total -le 0) { return $null } + return [math]::Round(($d.Free / $total) * 100, 1) + } catch { + Write-Verbose "Get-SystemDriveFreePct failed: $_" + return $null + } +} + +function Measure-ProcessActivity { + param([int]$TargetPid, [int]$Seconds) + $p1 = Get-CimInstance Win32_Process -Filter "ProcessId=$TargetPid" -ErrorAction SilentlyContinue + if (-not $p1) { return [PSCustomObject]@{ DeltaMB=0; CpuDeltaSec=0; ProcessAlive=$false } } + $r1=[int64]$p1.ReadTransferCount; $w1=[int64]$p1.WriteTransferCount; $o1=[int64]$p1.OtherTransferCount + $k1=[int64]$p1.KernelModeTime; $u1=[int64]$p1.UserModeTime + Start-Sleep -Seconds $Seconds + $p2 = Get-CimInstance Win32_Process -Filter "ProcessId=$TargetPid" -ErrorAction SilentlyContinue + if (-not $p2) { return [PSCustomObject]@{ DeltaMB=0; CpuDeltaSec=0; ProcessAlive=$false } } + $deltaBytes = ([int64]$p2.ReadTransferCount - $r1) + ([int64]$p2.WriteTransferCount - $w1) + ([int64]$p2.OtherTransferCount - $o1) + $cpuTicks = ([int64]$p2.KernelModeTime - $k1) + ([int64]$p2.UserModeTime - $u1) + return [PSCustomObject]@{ + DeltaMB = [math]::Round($deltaBytes/1MB, 2) + CpuDeltaSec = [math]::Round($cpuTicks/10000000, 2) + ProcessAlive = $true + } +} + +function Get-NetworkForensics { + [Diagnostics.CodeAnalysis.SuppressMessageAttribute('PSAvoidUsingComputerNameHardcoded', '', + Justification = '1.1.1.1 (Cloudflare DNS) is the intentional public internet reachability probe.')] + [Diagnostics.CodeAnalysis.SuppressMessageAttribute('PSUseSingularNouns', '', + Justification = '"Forensics" is a collective noun describing aggregated diagnostic output; singular is grammatically incorrect.')] + [CmdletBinding()] + param() + $f = [ordered]@{} + try { + $gw = (Get-NetRoute -DestinationPrefix '0.0.0.0/0' -ErrorAction SilentlyContinue | + Sort-Object RouteMetric | Select-Object -First 1).NextHop + $f.DefaultGateway = $gw + if ($gw) { $f.GatewayReachable = [bool](Test-Connection -ComputerName $gw -Count 1 -Quiet -ErrorAction SilentlyContinue) } + } catch { + Write-Verbose "Default gateway probe failed: $_" + $f.DefaultGateway = $null + } + try { + $dns = (Get-DnsClientServerAddress -AddressFamily IPv4 -ErrorAction SilentlyContinue | + Where-Object { $_.ServerAddresses.Count -gt 0 -and $_.InterfaceAlias -notmatch 'Loopback|Bluetooth|isatap' } | + Select-Object -First 1).ServerAddresses + $f.DnsServers = ($dns -join ',') + if ($dns -and $dns.Count -gt 0) { + $f.DnsServerReachable = [bool](Test-Connection -ComputerName $dns[0] -Count 1 -Quiet -ErrorAction SilentlyContinue) + } + } catch { + Write-Verbose "DNS server probe failed: $_" + $f.DnsServers = $null + } + try { + $proxy = & netsh winhttp show proxy 2>$null | Out-String + $f.WinHttpProxy = if ($proxy -match 'Direct access') { 'Direct' } + elseif ($proxy -match 'Proxy Server\(s\)\s*:\s*(\S+)') { $matches[1] } + else { 'Unknown' } + } catch { + Write-Verbose "WinHTTP proxy query failed: $_" + $f.WinHttpProxy = 'QueryFailed' + } + try { + $f.NetworkProfile = (Get-NetConnectionProfile -ErrorAction SilentlyContinue | Select-Object -First 1).NetworkCategory + } catch { + Write-Verbose "Network profile query failed: $_" + $f.NetworkProfile = $null + } + try { + $f.PublicInternetReachable = [bool](Test-Connection -ComputerName '1.1.1.1' -Count 1 -Quiet -ErrorAction SilentlyContinue) + } catch { + Write-Verbose "Public internet test failed: $_" + $f.PublicInternetReachable = $null + } + return [PSCustomObject]$f +} + +function Get-TimeSyncAge { + try { + $out = & w32tm /query /status 2>$null | Out-String + if ($out -match 'Last Successful Sync Time:\s*(.+)') { + $lastSync = [datetime]::Parse($matches[1].Trim()) + return [math]::Round(((Get-Date) - $lastSync).TotalMinutes, 1) + } + } catch { + Write-Verbose "Time sync age query failed: $_" + } + return $null +} + +function Get-AvQuarantineEventsForLockhart { + try { + $cutoff = (Get-Date).AddHours(-24) + $events = Get-WinEvent -FilterHashtable @{ + LogName='Microsoft-Windows-Windows Defender/Operational' + Id=@(1116,1117) + StartTime=$cutoff + } -ErrorAction SilentlyContinue | + Where-Object { $_.Message -match 'lockhart|NinjaRMM|ninjarmmagent' } + if ($events) { + return @($events | ForEach-Object { + "$($_.TimeCreated.ToString('s')) ID=$($_.Id) - $($_.Message.Substring(0,[Math]::Min(200,$_.Message.Length)))" + }) + } + return @() + } catch { + Write-Verbose "AV event query failed: $_" + return @() + } +} + +# =================== repairs =================== +function Invoke-StartService { + param([string]$Name) + try { + Start-Service -Name $Name -ErrorAction Stop + Start-Sleep -Seconds 5 + return ((Get-Service -Name $Name).Status -eq 'Running') + } catch { + Write-DTCLog ERROR "Start-Service $Name failed: $_" + return $false + } +} + +function Invoke-RestartService { + param([string]$Name) + try { + Restart-Service -Name $Name -Force -ErrorAction Stop + Start-Sleep -Seconds 5 + return ((Get-Service -Name $Name).Status -eq 'Running') + } catch { + Write-DTCLog ERROR "Restart-Service $Name failed: $_" + return $false + } +} + +function Repair-StoppingService { + param([string]$Name) + $svc = Get-ServiceState -Name $Name -Refresh + if ($svc.State -notin @('Stop Pending','StopPending')) { return $true } + Write-DTCLog WARN "REPAIR: $Name stuck in StopPending - force-killing PID $($svc.ProcessId)" + if ($svc.ProcessId -gt 0) { + try { + Stop-Process -Id $svc.ProcessId -Force -ErrorAction Stop + Start-Sleep -Seconds 3 + return $true + } catch { + Write-DTCLog ERROR "Force-kill PID $($svc.ProcessId) failed: $_" + return $false + } + } + return $false +} + +function Repair-DnsCache { + Write-DTCLog WARN "REPAIR: Flushing local DNS cache" + try { Clear-DnsClientCache -ErrorAction SilentlyContinue; return $true } + catch { + Write-Verbose "Clear-DnsClientCache failed: $_" + return $false + } +} + +function Repair-DnsService { + Write-DTCLog WARN "REPAIR: Restarting Dnscache service (off-hours operation)" + try { + Restart-Service -Name 'Dnscache' -Force -ErrorAction Stop + Start-Sleep -Seconds 3 + return $true + } catch { + Write-DTCLog ERROR "Dnscache restart failed: $_" + return $false + } +} + +function Repair-ArpCache { + Write-DTCLog WARN "REPAIR: Clearing ARP cache (off-hours operation)" + try { + & arp.exe -d * 2>$null | Out-Null + return $true + } catch { + Write-Verbose "ARP clear failed: $_" + return $false + } +} + +function Repair-TimeSync { + Write-DTCLog WARN "REPAIR: w32tm resync" + try { + & w32tm /resync /force 2>&1 | Out-Null + Start-Sleep -Seconds 3 + return ($LASTEXITCODE -eq 0) + } catch { + Write-Verbose "w32tm resync failed: $_" + return $false + } +} + +function Repair-DiskSpace { + param([int]$MinAgeDays) + Write-DTCLog WARN "REPAIR: Cleaning Windows\Temp files older than $MinAgeDays days" + $cutoff = (Get-Date).AddDays(-$MinAgeDays) + $freed = 0 + $path = "$env:WinDir\Temp" + if (-not (Test-Path $path)) { return $true } + try { + Get-ChildItem -Path $path -File -Recurse -Force -ErrorAction SilentlyContinue | + Where-Object { $_.LastWriteTime -lt $cutoff } | + ForEach-Object { + $sz = $_.Length + try { + Remove-Item $_.FullName -Force -ErrorAction Stop + $freed += $sz + } catch { + Write-Verbose "Could not delete $($_.FullName): $_" + } + } + } catch { + Write-Verbose "Disk cleanup enumeration failed: $_" + } + Write-DTCLog INFO "Disk cleanup freed: $([math]::Round($freed/1MB,1)) MB" + return $true +} + +function Reset-VssStack { + [Diagnostics.CodeAnalysis.SuppressMessageAttribute('PSUseShouldProcessForStateChangingFunctions', '', + Justification = 'Script runs unattended via NinjaOne; ShouldProcess support is dead code in this runtime.')] + [CmdletBinding()] + param() + Write-DTCLog WARN "REPAIR: VSS stack reset" + try { + Stop-Service -Name 'VSS' -Force -ErrorAction SilentlyContinue + Stop-Service -Name 'swprv' -Force -ErrorAction SilentlyContinue + Start-Sleep -Seconds 3 + Start-Service -Name 'swprv' -ErrorAction Stop + Start-Service -Name 'VSS' -ErrorAction Stop + Start-Sleep -Seconds 5 + return $true + } catch { + Write-DTCLog ERROR "VSS reset failed: $_" + return $false + } +} + +function Reset-VssDllRegistration { + [Diagnostics.CodeAnalysis.SuppressMessageAttribute('PSUseShouldProcessForStateChangingFunctions', '', + Justification = 'Script runs unattended via NinjaOne; ShouldProcess support is dead code in this runtime.')] + [CmdletBinding()] + param() + # BookStack page 2976. Off-hours escalation when VSS stack reset alone doesn't recover. + Write-DTCLog WARN "REPAIR (ESCALATION): Re-registering VSS DLLs" + try { + Stop-Service -Name 'VSS' -Force -ErrorAction SilentlyContinue + Stop-Service -Name 'swprv' -Force -ErrorAction SilentlyContinue + Start-Sleep -Seconds 2 + $sys32 = "$env:WinDir\System32" + foreach ($d in @('ole32.dll','oleaut32.dll','vss_ps.dll')) { + $p = Join-Path $sys32 $d + if (Test-Path $p) { & regsvr32.exe /s $p 2>$null } + } + $swprvDll = Join-Path $sys32 'swprv.dll' + if (Test-Path $swprvDll) { & regsvr32.exe /s /i $swprvDll 2>$null } + $vssvc = Join-Path $sys32 'vssvc.exe' + if (Test-Path $vssvc) { & $vssvc /Register 2>$null } + Start-Sleep -Seconds 3 + Start-Service -Name 'swprv' -ErrorAction SilentlyContinue + Start-Service -Name 'VSS' -ErrorAction SilentlyContinue + Start-Sleep -Seconds 5 + return $true + } catch { + Write-DTCLog ERROR "VSS DLL re-registration failed: $_" + return $false + } +} + +function Restart-ParentAgent { + [Diagnostics.CodeAnalysis.SuppressMessageAttribute('PSUseShouldProcessForStateChangingFunctions', '', + Justification = 'Script runs unattended via NinjaOne; ShouldProcess support is dead code in this runtime.')] + [CmdletBinding()] + param() + Write-DTCLog WARN "REPAIR (ESCALATION): Restarting $script:ParentAgentName parent agent" + try { + Restart-Service -Name $script:ParentAgentName -Force -ErrorAction Stop + Start-Sleep -Seconds 15 + return ((Get-Service -Name $script:ParentAgentName).Status -eq 'Running') + } catch { + Write-DTCLog ERROR "Parent agent restart failed: $_" + return $false + } +} + +function Invoke-CollectLogsArchive { + Write-DTCLog INFO "FORENSIC: Running ninjarmmagent /collectlogs" + $parentSvc = Get-ServiceState -Name $script:ParentAgentName + $agentExe = Get-ExePathFromService -SvcInfo $parentSvc + if (-not $agentExe -or -not (Test-Path $agentExe)) { + Write-DTCLog WARN "Cannot locate ninjarmmagent.exe for /collectlogs" + return $null + } + try { + & $agentExe /collectlogs 2>&1 | Out-Null + Start-Sleep -Seconds 8 + $cabSrc = 'C:\Windows\Temp\ninjalogs.cab' + if (-not (Test-Path $cabSrc)) { + Write-DTCLog WARN "/collectlogs ran but ninjalogs.cab not produced" + return $null + } + if (-not (Test-Path $script:ForensicsDir)) { New-Item -Path $script:ForensicsDir -ItemType Directory -Force | Out-Null } + $destFile = Join-Path $script:ForensicsDir "lockhart_$($env:COMPUTERNAME)_$(Get-Date -Format 'yyyyMMdd_HHmmss').cab" + Copy-Item $cabSrc $destFile -Force + Write-DTCLog INFO "Forensic cab archived: $destFile" + return $destFile + } catch { + Write-DTCLog ERROR "/collectlogs forensic capture failed: $_" + return $null + } +} + +function Get-FullDiagnosis { + $lh = Get-ServiceState -Name $script:ServiceName -Refresh + $pa = Get-ServiceState -Name $script:ParentAgentName -Refresh + $d = [ordered]@{ + Lockhart = $lh + ParentAgent = $pa + VssSvc = Get-ServiceState -Name 'VSS' -Refresh + SwprvSvc = Get-ServiceState -Name 'swprv' -Refresh + FreeSpacePct = Get-SystemDriveFreePct + FailedDns = @() + FailedTcp = @() + TimeSyncAgeMin = Get-TimeSyncAge + AvEvents = Get-AvQuarantineEventsForLockhart + BinaryExists = $false + LockhartVersion = $null + AgentVersion = $null + AgentYaml = $null + } + if ($lh.Exists) { + $lockhartExe = Get-ExePathFromService -SvcInfo $lh + $d.BinaryExists = if ($lockhartExe) { Test-Path $lockhartExe } else { $false } + $d.LockhartVersion = Get-BinaryVersion -Path $lockhartExe + $d.AgentYaml = Test-AgentYaml -LockhartSvcInfo $lh + } + if ($pa.Exists) { + $d.AgentVersion = Get-BinaryVersion -Path (Get-ExePathFromService -SvcInfo $pa) + } + foreach ($ep in $script:CloudEndpoints) { + if (-not (Test-DnsResolutionWithTimeout -TargetHost $ep.Host -TimeoutMs $DnsTimeoutMs)) { + $d.FailedDns += $ep.Host + } + } + foreach ($ep in $script:CloudEndpoints) { + if ($d.FailedDns -contains $ep.Host) { continue } + if (-not (Test-TcpEndpoint -TargetHost $ep.Host -Port $ep.Port -TimeoutMs $NetTestTimeoutMs)) { + $d.FailedTcp += "$($ep.Host):$($ep.Port)" + } + } + return [PSCustomObject]$d +} + +# =================== completion (terminal helper) =================== +function Complete-Remediation { + param($State, [string]$Result, [int]$Code, [PSCustomObject]$Entry) + $State.LastRun = (Get-Date).ToString('o') + $State.LastResult = $Result + if ($Entry) { + $State.History = @(@($Entry) + @($State.History)) + } + Set-State $State + Write-DTCLog INFO "=== EXIT: $Result ===" + return $Code +} + +# =================== main flow (function-wrapped for Pester testability) =================== +function Invoke-LockhartRemediation { + [CmdletBinding()] + param() + + $runTs = Get-Date + Write-DTCLog INFO "=== NinjaOne Backup - Lockhart Remediation / Repair v8 ===" + Write-DTCLog INFO "Host: $env:COMPUTERNAME | PS: $($PSVersionTable.PSVersion) | PID: $PID" + + # Defense-in-depth: entry point already checks ^HV0 before transcript; + # this guards direct function invocation in future refactors. + if ($env:COMPUTERNAME -match '^HV0') { + Write-DTCLog INFO "Hyper-V host detected ($env:COMPUTERNAME matches ^HV0). Backup remediation does not apply to hypervisors. Skipping." + Write-DTCLog INFO "=== EXIT: HypervisorSkip ===" + return 0 + } + + # ============================================================ + # PHASE 1: CHECK + # ============================================================ + $inBusinessHours = Test-WithinBusinessHours -StartHour $BusinessHoursStartHour -EndHour $BusinessHoursEndHour + $disruptiveAllowed = (-not $inBusinessHours) -or $script:ForceDisruptiveResolved + Write-DTCLog INFO "Time: $((Get-Date).ToString('HH:mm')) | BusinessHours($BusinessHoursStartHour-$BusinessHoursEndHour): $inBusinessHours | DisruptiveAllowed: $disruptiveAllowed" + + $deviceClass = Get-DeviceClass + Initialize-Path -DeviceClass $deviceClass + Move-LegacyStateFile + Write-DTCLog INFO "DeviceClass: $deviceClass | StateFile: $($script:StateFile)" + + # ClearStateAndExit short-circuit (env var via NinjaOne checkbox, or CLI switch) + if ($script:ClearStateResolved) { + Write-DTCLog INFO "ClearStateAndExit requested (param=$($ClearStateAndExit.IsPresent), env='$($env:ClearStateAndExit)')" + if (Test-Path $script:StateFile) { + try { + Remove-Item $script:StateFile -Force -ErrorAction Stop + Write-DTCLog INFO "State file removed: $($script:StateFile)" + } catch { + Write-DTCLog ERROR "Failed to remove state file: $_" + return 1 + } + } else { + Write-DTCLog INFO "No state file to clear" + } + Write-DTCLog INFO "=== EXIT: StateCleared ===" + return 0 + } + + # Lockhart service must exist + $svc = Get-ServiceState -Name $script:ServiceName + if (-not $svc.Exists) { + Write-DTCLog INFO "Service '$script:ServiceName' not installed. NinjaOne Backup not enabled on this device. Skipping." + Write-DTCLog INFO "=== EXIT: NotApplicable ===" + return 0 + } + + # Device-offline short-circuits (sole reason, no counter increment) + $uptimeMin = Get-SystemUptime + $publicInternet = Test-PublicInternet + Write-DTCLog INFO "UptimeMin: $uptimeMin | PublicInternet: $publicInternet" + + $state = Get-State + + if (-not $publicInternet) { + Write-DTCLog WARN "Device cannot reach public internet (1.1.1.1). Sole reason for backup failure: device offline. No remediation attempted." + $entry = [PSCustomObject]@{ + Time=$runTs.ToString('o'); DeviceClass=$deviceClass; InBusinessHours=$inBusinessHours + Result='DeviceOffline_NoInternet'; Actions=''; Deferred=''; Failures='' + Reason='Device cannot reach public internet' + UptimeMin=$uptimeMin; PublicInternetReachable=$false + } + return Complete-Remediation -State $state -Result 'DeviceOffline_NoInternet' -Code 0 -Entry $entry + } + + if ($null -ne $uptimeMin -and $uptimeMin -lt $MinUptimeMinutes) { + Write-DTCLog WARN "Device uptime is $uptimeMin minutes (< $MinUptimeMinutes). Likely missed scheduled backup window due to recent reboot. Sole reason: device offline." + $entry = [PSCustomObject]@{ + Time=$runTs.ToString('o'); DeviceClass=$deviceClass; InBusinessHours=$inBusinessHours + Result='DeviceOffline_RecentReboot'; Actions=''; Deferred=''; Failures='' + Reason="Device uptime $uptimeMin min (< $MinUptimeMinutes); likely missed backup window" + UptimeMin=$uptimeMin; PublicInternetReachable=$true + } + return Complete-Remediation -State $state -Result 'DeviceOffline_RecentReboot' -Code 0 -Entry $entry + } + + # Counter auto-reset + if ($state.LastRun) { + $hoursSince = ((Get-Date) - [datetime]$state.LastRun).TotalHours + if ($hoursSince -gt $CounterResetHours) { + Write-DTCLog INFO "Last run $([math]::Round($hoursSince,1))h ago (> $CounterResetHours h) - resetting counter" + $state.ConsecutiveAttempts = 0 + } + } + Write-DTCLog INFO "State: Attempts=$($state.ConsecutiveAttempts) | LastResult=$($state.LastResult) | LastRun=$($state.LastRun)" + + # Guardrail - counter tracks interventions (successful or not) within the window + if ([int]$state.ConsecutiveAttempts -ge $MaxConsecutiveAttempts) { + $reason = "$($state.ConsecutiveAttempts) consecutive remediation interventions within the ${CounterResetHours}h window. Last result: $($state.LastResult). Repeated intervention - even when each one succeeds - indicates an underlying issue the script cannot fix. NOC must investigate manually." + Write-DTCLog ERROR $reason + return Complete-Remediation -State $state -Result 'MaxAttemptsReached' -Code 2 -Entry $null + } + + # ============================================================ + # PHASE 2: DIAGNOSE + # ============================================================ + Write-DTCLog INFO "--- Phase 2: DIAGNOSE ---" + $diag = Get-FullDiagnosis + Write-DTCLog INFO "Lockhart: $($diag.Lockhart.State) PID=$($diag.Lockhart.ProcessId) BinaryExists=$($diag.BinaryExists) Ver=$($diag.LockhartVersion)" + Write-DTCLog INFO "$script:ParentAgentName: $($diag.ParentAgent.State) Ver=$($diag.AgentVersion) | VSS: $($diag.VssSvc.State) | swprv: $($diag.SwprvSvc.State)" + Write-DTCLog INFO "Free disk: $($diag.FreeSpacePct)% | TimeSyncAge: $($diag.TimeSyncAgeMin) min | AvEvents: $($diag.AvEvents.Count)" + if ($diag.AgentYaml) { + Write-DTCLog INFO "agent.yaml: Exists=$($diag.AgentYaml.Exists) Readable=$($diag.AgentYaml.Readable) Valid=$($diag.AgentYaml.Valid)" + } + Write-DTCLog INFO "DNS failed: $($diag.FailedDns.Count) [$($diag.FailedDns -join ',')] | TCP failed: $($diag.FailedTcp.Count) [$($diag.FailedTcp -join ',')]" + + # Hard blockers (no remediation possible from script) + $hardBlocker = $null + $blockerResult = $null + if (-not $diag.BinaryExists -and $diag.Lockhart.PathName) { + $hardBlocker = "BinaryMissing: Lockhart service exists but binary not found at $($diag.Lockhart.PathName). Agent reinstall required." + $blockerResult = 'RemediationBlocked_BinaryMissing' + } elseif ($diag.AgentYaml -and $diag.AgentYaml.Exists -and -not $diag.AgentYaml.Valid) { + $hardBlocker = "agent.yaml at $($diag.AgentYaml.Path) is missing required sections. Agent reinstall required." + $blockerResult = 'RemediationBlocked_ConfigCorrupt' + } elseif ($diag.AgentYaml -and -not $diag.AgentYaml.Exists -and $diag.AgentYaml.Path) { + $hardBlocker = "agent.yaml not found at $($diag.AgentYaml.Path). Lockhart install incomplete - reinstall required." + $blockerResult = 'RemediationBlocked_ConfigMissing' + } elseif ($diag.AvEvents.Count -gt 0) { + $hardBlocker = "AV quarantined Lockhart in last 24h. AV exclusion required at vendor console. Events: $($diag.AvEvents -join ' || ')" + $blockerResult = 'RemediationBlocked_AvQuarantine' + } + if ($hardBlocker) { + Write-DTCLog ERROR $hardBlocker + $netForensics = Get-NetworkForensics + $cabPath = Invoke-CollectLogsArchive + $state.ConsecutiveAttempts = [int]$state.ConsecutiveAttempts + 1 + $entry = [PSCustomObject]@{ + Time=$runTs.ToString('o'); DeviceClass=$deviceClass; InBusinessHours=$inBusinessHours + Result=$blockerResult; Actions=''; Deferred='' + Failures=$blockerResult.Replace('RemediationBlocked_',''); Reason=$hardBlocker + Forensics=$netForensics; ForensicCab=$cabPath + LockhartVersion=$diag.LockhartVersion; AgentVersion=$diag.AgentVersion + AvEvents = if ($diag.AvEvents.Count -gt 0) { $diag.AvEvents } else { $null } + } + return Complete-Remediation -State $state -Result $blockerResult -Code 1 -Entry $entry + } + + # Live backup detection + $liveBackup = $null + if ($diag.Lockhart.State -eq 'Running' -and $diag.Lockhart.ProcessId -gt 0 -and (Get-Process -Id $diag.Lockhart.ProcessId -ErrorAction SilentlyContinue)) { + Write-DTCLog INFO "Sampling PID=$($diag.Lockhart.ProcessId) for $SampleSeconds seconds" + $liveBackup = Measure-ProcessActivity -TargetPid $diag.Lockhart.ProcessId -Seconds $SampleSeconds + Write-DTCLog INFO "Sample: IO=$($liveBackup.DeltaMB) MB | CPU=$($liveBackup.CpuDeltaSec)s | Alive=$($liveBackup.ProcessAlive)" + if ($liveBackup.ProcessAlive -and ($liveBackup.DeltaMB -ge $ActiveIoThresholdMB -or $liveBackup.CpuDeltaSec -ge $ActiveCpuThresholdSec)) { + Write-DTCLog INFO "LIVE BACKUP DETECTED - exiting without remediation" + $entry = [PSCustomObject]@{ + Time=$runTs.ToString('o'); DeviceClass=$deviceClass; InBusinessHours=$inBusinessHours + Result='LiveJobSkipped'; Actions=''; Deferred=''; Failures='' + Reason='Live backup in progress' + } + return Complete-Remediation -State $state -Result 'LiveJobSkipped' -Code 0 -Entry $entry + } + } + + # ============================================================ + # PHASE 3: REPAIR + # ============================================================ + Write-DTCLog INFO "--- Phase 3: REPAIR (DisruptiveAllowed=$disruptiveAllowed) ---" + $repairActions = @() + $deferredRepairs = @() + + # Anytime: DNS cache flush + if ($diag.FailedDns.Count -gt 0 -or $diag.FailedTcp.Count -gt 0) { + if (Repair-DnsCache) { $repairActions += 'DnsCacheFlushed' } + } + + # Anytime: time resync + if ($null -ne $diag.TimeSyncAgeMin -and $diag.TimeSyncAgeMin -gt $TimeSkewToleranceMinutes) { + if (Repair-TimeSync) { $repairActions += 'TimeResynced' } else { $repairActions += 'Failed_TimeResync' } + } + + # Anytime: disk cleanup + if ($null -ne $diag.FreeSpacePct -and $diag.FreeSpacePct -lt $MinFreeSpacePercent) { + if (Repair-DiskSpace -MinAgeDays $TempCleanupMinAgeDays) { $repairActions += 'DiskSpaceCleaned' } + } + + # Gated: Dnscache restart + if ($diag.FailedDns.Count -gt 0) { + if ($disruptiveAllowed) { + if (Repair-DnsService) { $repairActions += 'DnsServiceRestarted' } else { $repairActions += 'Failed_DnsServiceRestart' } + } else { + Write-DTCLog INFO "DEFER: Dnscache restart (business hours)" + $deferredRepairs += 'DnsServiceRestart' + } + } + + # Gated: ARP clear + if ($diag.FailedTcp.Count -gt 0) { + if ($disruptiveAllowed) { + if (Repair-ArpCache) { $repairActions += 'ArpCacheCleared' } + } else { + Write-DTCLog INFO "DEFER: ARP clear (business hours)" + $deferredRepairs += 'ArpCacheClear' + } + } + + # Anytime: parent agent start (Lockhart depends on it) + if ($diag.ParentAgent.Exists -and $diag.ParentAgent.State -ne 'Running') { + Write-DTCLog WARN "REPAIR: Starting $script:ParentAgentName" + if (Invoke-StartService -Name $script:ParentAgentName) { $repairActions += "Started_$script:ParentAgentName" } + else { $repairActions += "Failed_Start_$script:ParentAgentName" } + Start-Sleep -Seconds 3 + } + + # Anytime: VSS stack reset (if either service is non-normal) + $vssOk = $diag.VssSvc.State -in @('Running','Stopped') + $swprvOk = $diag.SwprvSvc.State -in @('Running','Stopped') + $vssWasBad = -not ($vssOk -and $swprvOk) + if ($vssWasBad) { + if (Reset-VssStack) { $repairActions += 'VssStackReset' } else { $repairActions += 'Failed_VssReset' } + } + + # Anytime: Lockhart action based on observed state + $currentSvc = Get-ServiceState -Name $script:ServiceName -Refresh + if ($currentSvc.State -in @('Stop Pending','StopPending','StartPending','Start Pending')) { + Write-DTCLog WARN "Lockhart wedged in $($currentSvc.State) - force-killing" + if (Repair-StoppingService -Name $script:ServiceName) { $repairActions += 'LockhartForceKilled' } + $currentSvc = Get-ServiceState -Name $script:ServiceName -Refresh + } + switch ($currentSvc.State) { + 'Stopped' { + if (Invoke-StartService -Name $script:ServiceName) { $repairActions += 'LockhartStarted' } + else { $repairActions += 'Failed_LockhartStart' } + } + 'Paused' { + if (Invoke-RestartService -Name $script:ServiceName) { $repairActions += 'LockhartRestarted' } + else { $repairActions += 'Failed_LockhartRestart' } + } + 'Running' { + # Single restart for every Running sub-state that reaches this point: + # - zombie PID / process missing + # - process died during the sample window + # - process alive but idle below both thresholds with backups failing 25h+ + # A genuinely active backup already exited earlier via LiveJobSkipped. + # Known accepted edge: a long-running backup sampled entirely within a + # quiet phase (dedupe/catalog/network stall) gets restarted; NinjaOne + # Backup resumes block-level on the next run (vendor-documented), so + # the cost is bounded and preferable to leaving stuck processes + # unremediated - the exact case the pilot validated. + if (Invoke-RestartService -Name $script:ServiceName) { $repairActions += 'LockhartRestarted' } + else { $repairActions += 'Failed_LockhartRestart' } + } + default { + if (Invoke-RestartService -Name $script:ServiceName) { $repairActions += 'LockhartRestarted' } + else { $repairActions += 'Failed_LockhartRestart' } + } + } + + # ============================================================ + # MID-CONFIRM (decide if escalation needed) + # ============================================================ + Start-Sleep -Seconds 3 + $mid = Get-FullDiagnosis + $vssStillBad = -not (($mid.VssSvc.State -in @('Running','Stopped')) -and ($mid.SwprvSvc.State -in @('Running','Stopped'))) + $lockhartStillBad = $mid.Lockhart.State -ne 'Running' + + # ============================================================ + # ESCALATION REPAIRS (off-hours only) + # ============================================================ + if ($vssWasBad -and $vssStillBad) { + if ($disruptiveAllowed) { + Write-DTCLog WARN "VSS still bad after stack reset - escalating to DLL re-registration" + if (Reset-VssDllRegistration) { $repairActions += 'VssDllReRegistered' } + else { $repairActions += 'Failed_VssDllReReg' } + } else { + Write-DTCLog INFO "DEFER: VSS DLL re-registration (escalation, business hours)" + $deferredRepairs += 'VssDllReRegistration' + } + } + if ($lockhartStillBad) { + if ($disruptiveAllowed) { + Write-DTCLog WARN "Lockhart still not Running after restart - escalating to parent agent restart" + if (Restart-ParentAgent) { + $repairActions += 'ParentAgentRestarted' + Start-Sleep -Seconds 10 + $finalSvc = Get-ServiceState -Name $script:ServiceName -Refresh + if ($finalSvc.State -ne 'Running') { + if (Invoke-StartService -Name $script:ServiceName) { $repairActions += 'LockhartStarted_PostAgentRestart' } + } + } else { $repairActions += 'Failed_ParentAgentRestart' } + } else { + Write-DTCLog INFO "DEFER: NinjaRMMAgent restart (escalation, business hours)" + $deferredRepairs += 'ParentAgentRestart' + } + } + + # ============================================================ + # PHASE 4: FINAL CONFIRM + # ============================================================ + Write-DTCLog INFO "--- Phase 4: CONFIRM ---" + Start-Sleep -Seconds 3 + $post = Get-FullDiagnosis + $confirmFailures = @() + if ($post.Lockhart.State -ne 'Running') { $confirmFailures += "Lockhart_Not_Running($($post.Lockhart.State))" } + if ($post.ParentAgent.Exists -and $post.ParentAgent.State -ne 'Running') { $confirmFailures += "ParentAgent_Not_Running($($post.ParentAgent.State))" } + if ($post.FailedDns.Count -gt 0) { $confirmFailures += "DNS_Still_Failing($($post.FailedDns -join ','))" } + if ($post.FailedTcp.Count -gt 0) { $confirmFailures += "Cloud_Still_Unreachable($($post.FailedTcp -join ','))" } + if ($null -ne $post.FreeSpacePct -and $post.FreeSpacePct -lt $MinFreeSpacePercent) { + $confirmFailures += "DiskSpace_Still_Low($($post.FreeSpacePct)%)" + } + Write-DTCLog INFO "Post-repair: Lockhart=$($post.Lockhart.State) | DNS-fail=$($post.FailedDns.Count) | TCP-fail=$($post.FailedTcp.Count) | Disk=$($post.FreeSpacePct)%" + + $forensics = $null + $cabPath = $null + if ($confirmFailures.Count -gt 0) { + $forensics = Get-NetworkForensics + Write-DTCLog INFO "Forensics: Gateway=$($forensics.DefaultGateway) reach=$($forensics.GatewayReachable) | DNS=$($forensics.DnsServers) reach=$($forensics.DnsServerReachable) | Proxy=$($forensics.WinHttpProxy) | Internet=$($forensics.PublicInternetReachable)" + } + + # ============================================================ + # PHASE 5: STATE (record outcome) + # ============================================================ + $deferredCouldFix = ($deferredRepairs.Count -gt 0) -and ($confirmFailures.Count -gt 0) + $result = $null + + if ($confirmFailures.Count -eq 0) { + if ($repairActions.Count -eq 0) { + $result = 'AlreadyHealthy_CounterCleared' + $state.ConsecutiveAttempts = 0 + Write-DTCLog INFO "Lockhart fully healthy, no repairs needed. Counter cleared." + } else { + # Intentional: successful interventions still count toward the + # guardrail. Needing to repair Lockhart every cycle is a recurring + # problem worth NOC eyes even when each individual repair works. + # AlreadyHealthy_CounterCleared (above) is the recovery path once + # the device holds healthy between runs. + $result = 'RemediationSucceeded' + $state.ConsecutiveAttempts = [int]$state.ConsecutiveAttempts + 1 + Write-DTCLog INFO "Repairs confirmed healthy. Actions: $($repairActions -join ', ')" + } + } elseif ($deferredCouldFix) { + $result = 'RemediationDeferred' + Write-DTCLog WARN "Confirm failed but disruptive repairs deferred. Counter NOT incremented. Deferred: $($deferredRepairs -join ', ') | Unresolved: $($confirmFailures -join ' | ')" + } else { + $result = 'RemediationFailed' + $state.ConsecutiveAttempts = [int]$state.ConsecutiveAttempts + 1 + Write-DTCLog ERROR "Repair did not fully confirm. Unresolved: $($confirmFailures -join ' | ')" + $cabPath = Invoke-CollectLogsArchive + } + + $entry = [PSCustomObject]@{ + Time = $runTs.ToString('o') + DeviceClass = $deviceClass + InBusinessHours = $inBusinessHours + Result = $result + Actions = ($repairActions -join ',') + Deferred = ($deferredRepairs -join ',') + Failures = ($confirmFailures -join ',') + Reason = if ($confirmFailures.Count -gt 0) { $confirmFailures -join ' | ' } else { 'OK' } + Forensics = $forensics + ForensicCab = $cabPath + LockhartVersion = $diag.LockhartVersion + AgentVersion = $diag.AgentVersion + } + + Write-DTCLog INFO "Final: ConsecutiveAttempts=$($state.ConsecutiveAttempts) | Result=$result" + + $exitCode = switch ($result) { + 'AlreadyHealthy_CounterCleared' { 0 } + 'RemediationSucceeded' { 0 } + 'RemediationDeferred' { 0 } + 'RemediationFailed' { 1 } + default { 1 } + } + return Complete-Remediation -State $state -Result $result -Code $exitCode -Entry $entry +} + +# ================================================================ +# ENTRY POINT (SCRIPT LOGIC SECTION) +# ================================================================ +# HV0 exclusion FIRST - before transcript, mutex, or any file writes. +# Hypervisors take zero side effects from this script. +if ($env:COMPUTERNAME -match '^HV0') { + Write-DTCLog INFO "Hyper-V host detected ($env:COMPUTERNAME matches ^HV0). Backup remediation does not apply to hypervisors. Skipping." + Write-DTCLog INFO "=== EXIT: HypervisorSkip ===" + exit 0 +} + +$script:TranscriptActive = Start-RemediationTranscript +Write-DTCLog INFO "Description: $($script:Description) | RMM mode: $($script:IsRmmMode) | LogPath: $($script:LogPath)" +Write-DTCLog INFO "Flags: ForceDisruptiveRepairs param=$($ForceDisruptiveRepairs.IsPresent) env='$($env:ForceDisruptiveRepairs)' resolved=$($script:ForceDisruptiveResolved) | ClearStateAndExit param=$($ClearStateAndExit.IsPresent) env='$($env:ClearStateAndExit)' resolved=$($script:ClearStateResolved)" + +$scriptPid = $PID +$scriptTimeout = $MaxRuntimeSeconds +$runtimeKiller = Start-Job -ScriptBlock { + Start-Sleep -Seconds $using:scriptTimeout + try { Stop-Process -Id $using:scriptPid -Force -ErrorAction SilentlyContinue } catch { Write-Verbose "Runtime killer Stop-Process failed: $_" } +} + +$script:mutex = $null +$exitCode = 1 +try { + $script:mutex = New-Object System.Threading.Mutex($false, $script:MutexName) + if (-not $script:mutex.WaitOne(0)) { + Write-DTCLog WARN "Another instance already running. Exiting." + $exitCode = 0 + } else { + $exitCode = Invoke-LockhartRemediation + } +} catch { + Write-DTCLog ERROR "Unhandled exception: $_" + Write-DTCLog ERROR $_.ScriptStackTrace + $exitCode = 1 +} finally { + if ($script:mutex) { + try { $script:mutex.ReleaseMutex() } catch { Write-Verbose "Mutex release failed: $_" } + try { $script:mutex.Dispose() } catch { Write-Verbose "Mutex dispose failed: $_" } + } + Stop-Job $runtimeKiller -ErrorAction SilentlyContinue | Out-Null + Remove-Job $runtimeKiller -Force -ErrorAction SilentlyContinue | Out-Null + if ($script:TranscriptActive) { + try { Stop-Transcript | Out-Null } catch { Write-Verbose "Stop-Transcript failed: $_" } + } +} + +exit $exitCode \ No newline at end of file diff --git a/rmm-ninja/tests/lockhart-remediation.Tests.ps1 b/rmm-ninja/tests/lockhart-remediation.Tests.ps1 new file mode 100644 index 0000000..0280d35 --- /dev/null +++ b/rmm-ninja/tests/lockhart-remediation.Tests.ps1 @@ -0,0 +1,137 @@ +<# +.SYNOPSIS + Pester tests for lockhart-remediation.ps1 +.NOTES + Repo: dtc-inc/msp-script-library + Path: rmm-ninja/tests/lockhart-remediation.Tests.ps1 + Target: Pester 5+ + Run: Invoke-Pester -Path .\rmm-ninja\tests\ +#> + +BeforeAll { + $script:ScriptPath = (Resolve-Path (Join-Path $PSScriptRoot '..\lockhart-remediation.ps1')).Path + if (-not (Test-Path $script:ScriptPath)) { throw "Script under test not found: $script:ScriptPath" } + # Fast-run args for child invocations that proceed past the short-circuits + $script:FastArgs = @('-SampleSeconds','1','-NetTestTimeoutMs','500','-DnsTimeoutMs','500','-MinUptimeMinutes','0') +} + +Describe 'lockhart-remediation.ps1 - structural' { + It 'parses without syntax errors' { + $errors = $null + $null = [System.Management.Automation.Language.Parser]::ParseFile( + $script:ScriptPath, [ref]$null, [ref]$errors) + $errors | Should -BeNullOrEmpty + } + + It 'has comment-based help with required sections' { + $help = Get-Help $script:ScriptPath -Full -ErrorAction SilentlyContinue + $help.Synopsis | Should -Not -BeNullOrEmpty + $help.Description | Should -Not -BeNullOrEmpty + $help.Parameters.Parameter.Count | Should -BeGreaterThan 0 + } + + It 'declares all required parameters with safe defaults' { + $ast = [System.Management.Automation.Language.Parser]::ParseFile( + $script:ScriptPath, [ref]$null, [ref]$null) + $paramBlock = $ast.ParamBlock + $paramBlock | Should -Not -BeNullOrEmpty + $mandatory = $paramBlock.Parameters | Where-Object { + $_.Attributes.NamedArguments | Where-Object { $_.ArgumentName -eq 'Mandatory' -and $_.Argument.Value -eq $true } + } + $mandatory | Should -BeNullOrEmpty -Because 'NinjaOne runs unattended; mandatory params would hang the agent' + } + + It 'sets ErrorActionPreference = Stop' { + $content = Get-Content $script:ScriptPath -Raw + $content | Should -Match "\`$ErrorActionPreference\s*=\s*'Stop'" + } + + It 'contains the RMM variable declaration block (template section 1)' { + $content = Get-Content $script:ScriptPath -Raw + $content | Should -Match "## PLEASE COMMENT YOUR VARIABLES DIRECTLY BELOW HERE IF YOU'RE RUNNING FROM A RMM" + } + + It 'reads RMM checkbox variables via $env: (CLAUDE.md convention)' { + $content = Get-Content $script:ScriptPath -Raw + $content | Should -Match "Test-RmmFlag 'ForceDisruptiveRepairs'" + $content | Should -Match "Test-RmmFlag 'ClearStateAndExit'" + } +} + +Describe 'lockhart-remediation.ps1 - HV0 host exclusion (failure-path)' { + It 'exits 0 with HypervisorSkip when COMPUTERNAME matches ^HV0' { + $originalName = $env:COMPUTERNAME + try { + $env:COMPUTERNAME = 'HV01-TESTPATTERN' + $output = & pwsh -NoProfile -File $script:ScriptPath 2>&1 + $LASTEXITCODE | Should -Be 0 + ($output -join "`n") | Should -Match 'HypervisorSkip' + } finally { + $env:COMPUTERNAME = $originalName + } + } + + It 'exits 0 with HypervisorSkip when COMPUTERNAME is HV0-{servicetag}' { + $originalName = $env:COMPUTERNAME + try { + $env:COMPUTERNAME = 'HV0-ABC1234' + $output = & pwsh -NoProfile -File $script:ScriptPath 2>&1 + $LASTEXITCODE | Should -Be 0 + ($output -join "`n") | Should -Match 'HypervisorSkip' + } finally { + $env:COMPUTERNAME = $originalName + } + } + + It 'does NOT skip when COMPUTERNAME merely contains HV0 (e.g. SERVER-HV0)' { + $originalName = $env:COMPUTERNAME + try { + $env:COMPUTERNAME = 'SERVER-HV0' + $output = & pwsh -NoProfile -File $script:ScriptPath @($script:FastArgs) 2>&1 + ($output -join "`n") | Should -Not -Match 'HypervisorSkip' + } finally { + $env:COMPUTERNAME = $originalName + } + } +} + +Describe 'lockhart-remediation.ps1 - RMM environment variable binding' { + It 'honors $env:ClearStateAndExit=1 (NinjaOne checkbox path) without a CLI switch' { + $stateFiles = @( + 'C:\DTC\lockhart_autoremediation.json', + 'C:\ProgramData\DTC\lockhart_autoremediation.json' + ) + # Back up any real state, then seed sentinels + $backups = @{} + foreach ($f in $stateFiles) { + if (Test-Path $f) { $backups[$f] = Get-Content $f -Raw } + $dir = Split-Path $f -Parent + if (-not (Test-Path $dir)) { New-Item -ItemType Directory -Path $dir -Force | Out-Null } + '{"ConsecutiveAttempts":2,"LastResult":"TestSentinel","History":[]}' | Set-Content -Path $f -Force + } + try { + $env:ClearStateAndExit = '1' + $output = & pwsh -NoProfile -File $script:ScriptPath 2>&1 + $LASTEXITCODE | Should -Be 0 + ($output -join "`n") | Should -Match 'StateCleared' + } finally { + Remove-Item Env:\ClearStateAndExit -ErrorAction SilentlyContinue + foreach ($f in $stateFiles) { + if ($backups.ContainsKey($f)) { $backups[$f] | Set-Content -Path $f -Force } + else { Remove-Item $f -Force -ErrorAction SilentlyContinue } + } + } + } +} + +Describe 'lockhart-remediation.ps1 - happy-path (NotApplicable on host without Lockhart)' { + It 'exits 0 with NotApplicable when Lockhart service does not exist' { + $lockhartInstalled = $null -ne (Get-CimInstance Win32_Service -Filter "Name='Lockhart'" -ErrorAction SilentlyContinue) + if ($lockhartInstalled) { + Set-ItResult -Skipped -Because 'Lockhart is installed on this test host; cannot validate NotApplicable path here' + } + $output = & pwsh -NoProfile -File $script:ScriptPath @($script:FastArgs) 2>&1 + $LASTEXITCODE | Should -Be 0 + ($output -join "`n") | Should -Match 'NotApplicable' + } +} \ No newline at end of file