From 5b1b16c4b19816343145138941c3c5fa51a095e8 Mon Sep 17 00:00:00 2001 From: Nate Smith <40123869+Gumbees@users.noreply.github.com> Date: Mon, 11 May 2026 13:14:04 -0400 Subject: [PATCH 01/10] improvement(docs): sync CLAUDE.md with DTC canonical change-taxonomy (#67) * improvement(docs): sync CLAUDE.md with DTC canonical change-taxonomy Replace the deprecated enhancement/ + problem/ two-prefix model with the canonical four-prefix taxonomy (bug/, refactor/, improvement/, feature/) and two-tier GitHub labels (type:* + category:*). Mirrors https://kb.dtctoday.com/books/developer-operations-devops/page/change-taxonomy. - Rewrite Git Workflow section to reflect main-as-release (this repo has no development branch; the prior section described a model that did not match the actual repo). - Add Halo Type / Category / GitHub labels / Branch prefix / Default semver mapping table. - Note BREAKING: PR title override as future-state (no semver yet). - Add dependabot categorization rule and 'name the branch after the change' guidance. - Mark legacy enhancement/ and problem/ prefixes accepted on existing branches; new work uses the four-prefix model. - Add db-* to the Category Organization list (db-mysql/ exists on disk). * docs(taxonomy): add Enhancement/Refactor row + clarify Refactor spans both types --- CLAUDE.md | 106 +++++++++++++++++++++++++++++++++++++++++++++++++----- 1 file changed, 97 insertions(+), 9 deletions(-) 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. From 58bf60b5b64270e59f7197c82dc195da7627e8bc Mon Sep 17 00:00:00 2001 From: iBeTylerD Date: Mon, 11 May 2026 15:30:39 -0500 Subject: [PATCH 02/10] Add NinjaRMM GUID to Halo PSA GUID Sync Script (#49) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * Add NinjaRMM GUID to Halo PSA GUID Sync Script Syncs DTC Org GUID from NinjaRMM custom fields to DTC Client GUID field in Halo PSA for all matching clients by name. * Update ninja-halo-guid-sync.ps1 Fix Halo custom field name to CFDtcClientGuid * Fix NinjaClientSecret placeholder comment typo * Remove hardcoded credentials — replace with placeholders Secrets were exposed in script body. Rotated all credentials. Real values stored in NinjaRMM script body (never leaves Ninja) and 1Password. GitHub copy uses placeholders only. * Update script — use Script Variable injection for secrets Refactored to inject client secrets via NinjaRMM Script Variables at runtime. Client IDs hardcoded (not secrets). Secrets never stored in repo. Validated working in production. * Fix: Bring script into compliance with DTC PowerShell engineering standard Addresses CodeRabbit review concerns on PR #49: - Add RMM variable declaration block per script-template-powershell.ps1 - Add Start-Transcript/Stop-Transcript with C:\ProgramData\DTC\Logs\ output - Add $ScriptLogName and $Description audit trail variables - Replace Write-Host with Write-Information per output stream standard - Add $ErrorActionPreference = 'Stop' and $ProgressPreference = 'SilentlyContinue' - Add explicit exit codes (0/1/2 per DTC convention) - Wrap all work in try/catch/finally per page 3299 --- integrations/ninja-halo-guid-sync.ps1 | 177 ++++++++++++++++++++++++++ 1 file changed, 177 insertions(+) create mode 100644 integrations/ninja-halo-guid-sync.ps1 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 +} From 9dd06651395626c45c56dd3a11245f6a13103086 Mon Sep 17 00:00:00 2001 From: Zach Boogher <129975920+AlrightLad@users.noreply.github.com> Date: Fri, 5 Jun 2026 14:02:04 -0400 Subject: [PATCH 03/10] feat(rmm-ninja): add Lockhart remediation script (#82) * feat(rmm-ninja): add Lockhart remediation script Automated remediation for NinjaOne Backup Lockhart service failures. Responds to the '(SEC) Last Successful Backup' condition on policy 58 (servers). Diagnose -> Repair -> Confirm flow with business-hours gating for disruptive operations. HV0 hypervisor host exclusion. DeviceOffline short-circuit. Forensic capture on remediation failure. Pilot tested on internal DTC server 2026-04-23. Full cycle validated: idle classification, service restart, PID rotation, state file written, counter incremented correctly. Includes Pester test coverage for structural validation, HV0 hypervisor host exclusion, and NotApplicable short-circuit path. Tests file force-added: .gitignore line 25 (*test*) excludes Pester source files - flagging as separate concern for repo cleanup. * fix(rmm-ninja): RMM env-var binding + template compliance per review - ForceDisruptiveRepairs/ClearStateAndExit now resolve from env vars (NinjaOne preset variable path) with CLI switch fallback for interactive runs; all numeric tunables gain env-var overrides (review comment 1) - Three-section template structure per CLAUDE.md: RMM variable block, input handling with env:RMM detection and env:Description audit capture, Start/Stop-Transcript to RMMScriptPath/WINDIR logs with 10MB rotation. Documented deviation: no Read-Host (condition-triggered automation must never block on input) - Collapsed dead Running-case conditional to single restart with explanatory comment (review comment 2) - Counter semantics: docs and MaxAttemptsReached log now say 'interventions within window' matching the intentional increment-on-success behavior (review comment 3) - .NOTES Path corrected to rmm-ninja/ (review comment 4) - HV0 exclusion moved to entry point before transcript so hypervisors take zero file writes; Pester test added for env:ClearStateAndExit checkbox binding --- rmm-ninja/lockhart-remediation.ps1 | 1296 +++++++++++++++++ .../tests/lockhart-remediation.Tests.ps1 | 137 ++ 2 files changed, 1433 insertions(+) create mode 100644 rmm-ninja/lockhart-remediation.ps1 create mode 100644 rmm-ninja/tests/lockhart-remediation.Tests.ps1 diff --git a/rmm-ninja/lockhart-remediation.ps1 b/rmm-ninja/lockhart-remediation.ps1 new file mode 100644 index 0000000..1c6c9df --- /dev/null +++ b/rmm-ninja/lockhart-remediation.ps1 @@ -0,0 +1,1296 @@ +<# +.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='backup.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 From 210f2942e1c5d336f3ed48fcba2177fba4261b43 Mon Sep 17 00:00:00 2001 From: mnelsondtc Date: Wed, 10 Jun 2026 13:13:17 -0400 Subject: [PATCH 04/10] fix(rmm-ninja): remove stale backup.ninjarmm.com endpoint from Lockhart remediation (#83) backup.ninjarmm.com is no longer used by the NinjaOne backup service, so it fails DNS resolution and TCP connect on every run. This (1) fired unnecessary disruptive network repairs (DNS cache flush, Dnscache restart, ARP clear) and (2) poisoned the post-repair verdict via DNS_Still_Failing / Cloud_Still_Unreachable, marking the remediation FAILED even when Lockhart was successfully repaired. Remaining endpoints (app.ninjarmm.com, s3.amazonaws.com, s3.us-east-1.amazonaws.com) still cover the agent control channel and backup storage targets. Co-authored-by: Claude Opus 4.8 (1M context) --- rmm-ninja/lockhart-remediation.ps1 | 1 - 1 file changed, 1 deletion(-) diff --git a/rmm-ninja/lockhart-remediation.ps1 b/rmm-ninja/lockhart-remediation.ps1 index 1c6c9df..b4b664c 100644 --- a/rmm-ninja/lockhart-remediation.ps1 +++ b/rmm-ninja/lockhart-remediation.ps1 @@ -257,7 +257,6 @@ $script:ParentAgentName = 'NinjaRMMAgent' $script:MutexName = 'Global\DTC_NinjaOneBackup_LockhartRemediation_v8' $script:CloudEndpoints = @( @{ Host='app.ninjarmm.com'; Port=443 }, - @{ Host='backup.ninjarmm.com'; Port=443 }, @{ Host='s3.amazonaws.com'; Port=443 }, @{ Host='s3.us-east-1.amazonaws.com'; Port=443 } ) From e1318c73731d4ab85491606ae9ea5a08eff002d5 Mon Sep 17 00:00:00 2001 From: Nate Smith <40123869+Gumbees@users.noreply.github.com> Date: Mon, 22 Jun 2026 22:29:19 -0400 Subject: [PATCH 05/10] Suspend BitLocker: parameterize RebootCount (default 1), fix suspend-forever bug (#89) The script hardcoded `-RebootCount 0`, which suspends BitLocker indefinitely until a manual resume. Before a scheduled reboot we want it suspended for exactly the next restart, then auto-resumed. - RebootCount is now a parameter and an env var; resolution order: -RebootCount param > $env:RebootCount > default 1. - Suspend volumes with ProtectionStatus 'On' (actively protected) and verify none remain protected afterward. - Fix inverted $RMMScriptPath null check for the RMM log path. Claude-Session: https://claude.ai/code/session_01TmgU4CfUbvYkZdm548A5fx Co-authored-by: Claude Opus 4.8 (1M context) --- .../msft-windows-suspend-bitlocker.ps1 | 49 +++++++++++++------ 1 file changed, 35 insertions(+), 14 deletions(-) diff --git a/msft-windows/msft-windows-suspend-bitlocker.ps1 b/msft-windows/msft-windows-suspend-bitlocker.ps1 index 42f26fc..dba6c7c 100644 --- a/msft-windows/msft-windows-suspend-bitlocker.ps1 +++ b/msft-windows/msft-windows-suspend-bitlocker.ps1 @@ -1,7 +1,20 @@ +param( + # Number of reboots BitLocker stays suspended for before it auto-resumes. + # Resolution order: -RebootCount parameter > $env:RebootCount > default 1. + # 1 = suspend for exactly the next reboot, then BitLocker re-protects itself. + [int]$RebootCount = 0 +) + # Getting input from user if not running from RMM else set variables from RMM. $ScriptLogName = "bitlocker-suspend.log" +# Resolve RebootCount: parameter wins; else environment variable; else default 1. +if ($RebootCount -le 0 -and $env:RebootCount) { + [int]::TryParse($env:RebootCount, [ref]$RebootCount) | Out-Null +} +if ($RebootCount -le 0) { $RebootCount = 1 } + if ($RMM -ne 1) { $ValidInput = 0 # Checking for valid input. @@ -17,23 +30,22 @@ if ($RMM -ne 1) { } $LogPath = "$ENV:WINDIR\logs\$ScriptLogName" -} else { +} else { # Store the logs in the RMMScriptPath - if ($null -eq $RMMScriptPath) { + if ($null -ne $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-Transcript -Path $LogPath @@ -41,22 +53,31 @@ Start-Transcript -Path $LogPath Write-Host "Description: $Description" Write-Host "Log path: $LogPath" Write-Host "RMM: $RMM" +Write-Host "RebootCount: $RebootCount" -# Function to suspend BitLocker encryption on all volumes +# Function to suspend BitLocker on all actively protected volumes for $RebootCount reboot(s). function Suspend-AllBitLocker { + param([int]$RebootCount = 1) try { - # Get all BitLocker encrypted volumes - $bitLockerVolumes = Get-BitLockerVolume | Where-Object { $_.VolumeStatus -eq 'FullyEncrypted' } + # Volumes with protection currently ON are the ones that need suspending. + $bitLockerVolumes = Get-BitLockerVolume | Where-Object { $_.ProtectionStatus -eq 'On' } if ($bitLockerVolumes.Count -gt 0) { foreach ($volume in $bitLockerVolumes) { - # Suspend BitLocker encryption - Suspend-BitLocker -MountPoint $volume.MountPoint -RebootCount 0 -Verbose + # Suspend BitLocker for the configured number of reboots. + Suspend-BitLocker -MountPoint $volume.MountPoint -RebootCount $RebootCount -Verbose + + Write-Output "BitLocker on volume $($volume.MountPoint) suspended for $RebootCount reboot(s)." + } - Write-Output "BitLocker encryption on volume $($volume.MountPoint) has been suspended." + # Verify nothing is still actively protected. + $stillOn = Get-BitLockerVolume | Where-Object { $_.ProtectionStatus -eq 'On' } + if ($stillOn) { + Write-Error "Volume(s) still protected after suspend: $($stillOn.MountPoint -join ', ')" + exit 1 } } else { - Write-Output "No BitLocker encrypted volumes found." + Write-Output "No actively protected BitLocker volumes found; nothing to suspend." } Exit 0 } @@ -67,7 +88,7 @@ function Suspend-AllBitLocker { } # Call the function to suspend BitLocker on all volumes -Suspend-AllBitLocker +Suspend-AllBitLocker -RebootCount $RebootCount From 1d4be82e8f3cabaae6f614bf8569374152c7b760 Mon Sep 17 00:00:00 2001 From: Nate Smith <40123869+Gumbees@users.noreply.github.com> Date: Mon, 22 Jun 2026 22:35:20 -0400 Subject: [PATCH 06/10] Suspend only the system volume, not every volume (#90) Only the OS/system volume can trigger the pre-boot BitLocker recovery prompt; fixed data volumes auto-unlock from the (suspended-but-bootable) system drive once Windows is up. So Suspend-BitLocker on $env:SystemDrive covers the reboot without touching data volumes. Verifies protection is off before returning. Claude-Session: https://claude.ai/code/session_01TmgU4CfUbvYkZdm548A5fx Co-authored-by: Claude Opus 4.8 (1M context) --- .../msft-windows-suspend-bitlocker.ps1 | 35 +++++++++---------- 1 file changed, 17 insertions(+), 18 deletions(-) diff --git a/msft-windows/msft-windows-suspend-bitlocker.ps1 b/msft-windows/msft-windows-suspend-bitlocker.ps1 index dba6c7c..37b83de 100644 --- a/msft-windows/msft-windows-suspend-bitlocker.ps1 +++ b/msft-windows/msft-windows-suspend-bitlocker.ps1 @@ -55,29 +55,28 @@ Write-Host "Log path: $LogPath" Write-Host "RMM: $RMM" Write-Host "RebootCount: $RebootCount" -# Function to suspend BitLocker on all actively protected volumes for $RebootCount reboot(s). -function Suspend-AllBitLocker { +# Function to suspend BitLocker on the system volume for $RebootCount reboot(s). +# Only the OS/system volume can trigger the pre-boot recovery prompt; fixed data +# volumes auto-unlock once Windows is up off the (suspended-but-bootable) system +# drive, so suspending $env:SystemDrive is sufficient to cover the reboot. +function Suspend-SystemBitLocker { param([int]$RebootCount = 1) try { - # Volumes with protection currently ON are the ones that need suspending. - $bitLockerVolumes = Get-BitLockerVolume | Where-Object { $_.ProtectionStatus -eq 'On' } + $mp = $env:SystemDrive + $vol = Get-BitLockerVolume -MountPoint $mp -ErrorAction Stop - if ($bitLockerVolumes.Count -gt 0) { - foreach ($volume in $bitLockerVolumes) { - # Suspend BitLocker for the configured number of reboots. - Suspend-BitLocker -MountPoint $volume.MountPoint -RebootCount $RebootCount -Verbose + if ($vol.ProtectionStatus -eq 'On') { + Suspend-BitLocker -MountPoint $mp -RebootCount $RebootCount -Verbose | Out-Null - Write-Output "BitLocker on volume $($volume.MountPoint) suspended for $RebootCount reboot(s)." - } - - # Verify nothing is still actively protected. - $stillOn = Get-BitLockerVolume | Where-Object { $_.ProtectionStatus -eq 'On' } - if ($stillOn) { - Write-Error "Volume(s) still protected after suspend: $($stillOn.MountPoint -join ', ')" + # Verify protection is actually off before we rely on it. + $vol = Get-BitLockerVolume -MountPoint $mp + if ($vol.ProtectionStatus -eq 'On') { + Write-Error "System volume $mp still protected after suspend." exit 1 } + Write-Output "BitLocker on system volume $mp suspended for $RebootCount reboot(s)." } else { - Write-Output "No actively protected BitLocker volumes found; nothing to suspend." + Write-Output "System volume $mp is not actively protected; nothing to suspend." } Exit 0 } @@ -87,8 +86,8 @@ function Suspend-AllBitLocker { } } -# Call the function to suspend BitLocker on all volumes -Suspend-AllBitLocker -RebootCount $RebootCount +# Call the function to suspend BitLocker on the system volume +Suspend-SystemBitLocker -RebootCount $RebootCount From 8b7c1f14292b683e9e6bca5571ade63561377cfa Mon Sep 17 00:00:00 2001 From: Nate Smith <40123869+Gumbees@users.noreply.github.com> Date: Mon, 22 Jun 2026 22:53:19 -0400 Subject: [PATCH 07/10] Fix RMM hang: $env:RMM convention, drop param() block (#91) The version on development still read bare $RMM and used a top-level param() block. NinjaRMM passes preset variables as ENVIRONMENT variables, so bare $RMM is $null in RMM mode and the script fell through to the interactive Read-Host and hung waiting for input. - Detect context via $env:RMM -ne "1". - Read RebootCount/Description/RMMScriptPath via $env:; remove the param() block that broke RMM variable injection. - Keep system-volume-only suspend; single Stop-Transcript + exit-code path. Claude-Session: https://claude.ai/code/session_01TmgU4CfUbvYkZdm548A5fx Co-authored-by: Claude Opus 4.8 (1M context) --- .../msft-windows-suspend-bitlocker.ps1 | 88 ++++++++----------- 1 file changed, 39 insertions(+), 49 deletions(-) diff --git a/msft-windows/msft-windows-suspend-bitlocker.ps1 b/msft-windows/msft-windows-suspend-bitlocker.ps1 index 37b83de..1d2b4cb 100644 --- a/msft-windows/msft-windows-suspend-bitlocker.ps1 +++ b/msft-windows/msft-windows-suspend-bitlocker.ps1 @@ -1,26 +1,23 @@ -param( - # Number of reboots BitLocker stays suspended for before it auto-resumes. - # Resolution order: -RebootCount parameter > $env:RebootCount > default 1. - # 1 = suspend for exactly the next reboot, then BitLocker re-protects itself. - [int]$RebootCount = 0 -) - -# 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) $ScriptLogName = "bitlocker-suspend.log" -# Resolve RebootCount: parameter wins; else environment variable; else default 1. -if ($RebootCount -le 0 -and $env:RebootCount) { - [int]::TryParse($env:RebootCount, [ref]$RebootCount) | Out-Null +# 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 ($RebootCount -le 0) { $RebootCount = 1 } -if ($RMM -ne 1) { +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 @@ -28,67 +25,60 @@ 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 -ne $RMMScriptPath) { - $LogPath = "$RMMScriptPath\logs\$ScriptLogName" - + # 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 "RMM: $env:RMM" Write-Host "RebootCount: $RebootCount" -# Function to suspend BitLocker on the system volume for $RebootCount reboot(s). -# Only the OS/system volume can trigger the pre-boot recovery prompt; fixed data -# volumes auto-unlock once Windows is up off the (suspended-but-bootable) system -# drive, so suspending $env:SystemDrive is sufficient to cover the reboot. +# 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 try { - $mp = $env:SystemDrive $vol = Get-BitLockerVolume -MountPoint $mp -ErrorAction Stop + if ($vol.ProtectionStatus -ne 'On') { + Write-Output "System volume $mp is not actively protected; nothing to suspend." + return 0 + } + Suspend-BitLocker -MountPoint $mp -RebootCount $RebootCount -Verbose | Out-Null + # Verify protection is actually off before we rely on it. + $vol = Get-BitLockerVolume -MountPoint $mp if ($vol.ProtectionStatus -eq 'On') { - Suspend-BitLocker -MountPoint $mp -RebootCount $RebootCount -Verbose | Out-Null - - # Verify protection is actually off before we rely on it. - $vol = Get-BitLockerVolume -MountPoint $mp - if ($vol.ProtectionStatus -eq 'On') { - Write-Error "System volume $mp still protected after suspend." - exit 1 - } - Write-Output "BitLocker on system volume $mp suspended for $RebootCount reboot(s)." - } else { - Write-Output "System volume $mp is not actively protected; nothing to suspend." + Write-Error "System volume $mp still protected after suspend." + return 1 } - Exit 0 + Write-Output "BitLocker on system volume $mp suspended for $RebootCount reboot(s)." + return 0 } catch { Write-Error "An error occurred: $_" - exit 1 + return 1 } } -# Call the function to suspend BitLocker on the system volume -Suspend-SystemBitLocker -RebootCount $RebootCount - - +$exitCode = Suspend-SystemBitLocker -RebootCount $RebootCount Stop-Transcript +exit $exitCode From f02d791cc6063dcd971f65aa80265ae543ebeb49 Mon Sep 17 00:00:00 2001 From: Nate Smith <40123869+Gumbees@users.noreply.github.com> Date: Mon, 22 Jun 2026 22:59:56 -0400 Subject: [PATCH 08/10] BitLocker suspend: verbose step logging + clear exit codes (#92) Built on the env:RMM fix already in development. Make the run auditable from the RMM activity output and unambiguous about outcome: - Logs each step: current VolumeStatus/ProtectionStatus, the suspend action, and the post-suspend re-check. - Single RESULT line + explicit exit code: 0 = system volume protection OFF (suspended, or no BitLocker) -> safe to reboot 1 = system volume STILL protected (suspend failed) -> NOT safe Claude-Session: https://claude.ai/code/session_01TmgU4CfUbvYkZdm548A5fx Co-authored-by: Claude Opus 4.8 (1M context) --- .../msft-windows-suspend-bitlocker.ps1 | 58 +++++++++++++------ 1 file changed, 40 insertions(+), 18 deletions(-) diff --git a/msft-windows/msft-windows-suspend-bitlocker.ps1 b/msft-windows/msft-windows-suspend-bitlocker.ps1 index 1d2b4cb..7f7e735 100644 --- a/msft-windows/msft-windows-suspend-bitlocker.ps1 +++ b/msft-windows/msft-windows-suspend-bitlocker.ps1 @@ -3,6 +3,9 @@ ## $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" @@ -44,10 +47,12 @@ if ($env:RMM -ne "1") { Start-Transcript -Path $LogPath -Write-Host "Description: $Description" -Write-Host "Log path: $LogPath" -Write-Host "RMM: $env:RMM" -Write-Host "RebootCount: $RebootCount" +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 "===================================================" # 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 @@ -55,30 +60,47 @@ Write-Host "RebootCount: $RebootCount" function Suspend-SystemBitLocker { param([int]$RebootCount = 1) $mp = $env:SystemDrive + + Write-Host "[1/3] Checking BitLocker on system volume $mp ..." try { $vol = Get-BitLockerVolume -MountPoint $mp -ErrorAction Stop - if ($vol.ProtectionStatus -ne 'On') { - Write-Output "System volume $mp is not actively protected; nothing to suspend." - return 0 - } - Suspend-BitLocker -MountPoint $mp -RebootCount $RebootCount -Verbose | Out-Null + } 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)" - # Verify protection is actually off before we rely on it. - $vol = Get-BitLockerVolume -MountPoint $mp - if ($vol.ProtectionStatus -eq 'On') { - Write-Error "System volume $mp still protected after suspend." - return 1 - } - Write-Output "BitLocker on system volume $mp suspended for $RebootCount reboot(s)." + 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: $_" + + 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 } + + 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 From b03f480df6ca9eacd74e15b5f04e8dc10cd01c47 Mon Sep 17 00:00:00 2001 From: Zach Boogher <129975920+AlrightLad@users.noreply.github.com> Date: Wed, 24 Jun 2026 08:05:08 -0400 Subject: [PATCH 09/10] refactor: consolidate power-management + shutdown-disable into msft-win-uptime-lockdown (#88) --- msft-windows/msft-win-shutdown-disable.ps1 | 102 ---- msft-windows/msft-win-uptime-lockdown.ps1 | 509 ++++++++++++++++++ .../msft-windows-power-management-config.ps1 | Bin 52022 -> 0 bytes 3 files changed, 509 insertions(+), 102 deletions(-) delete mode 100644 msft-windows/msft-win-shutdown-disable.ps1 create mode 100644 msft-windows/msft-win-uptime-lockdown.ps1 delete mode 100644 msft-windows/msft-windows-power-management-config.ps1 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-power-management-config.ps1 b/msft-windows/msft-windows-power-management-config.ps1 deleted file mode 100644 index 8de3304c5d4bcf3b3d4e304f35b6007d051183ce..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 52022 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 Nnk^zh9 Date: Wed, 24 Jun 2026 16:28:17 -0500 Subject: [PATCH 10/10] feature: add Windows Server edition NinjaRMM inventory script Adds msft-windows-os-edition-inventory.ps1, which reports the Windows Server edition to a NinjaRMM custom field (serverEdition). Resolves the edition from the registry EditionID with WMI Caption as a fallback, distinguishes server from workstation via ProductType, and explicitly calls out Standard, Datacenter, and Evaluation editions. Co-Authored-By: Claude Opus 4.8 --- .../msft-windows-os-edition-inventory.ps1 | 135 ++++++++++++++++++ 1 file changed, 135 insertions(+) create mode 100644 msft-windows/msft-windows-os-edition-inventory.ps1 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