diff --git a/msft-windows/msft-detect-duplicate-profiles.ps1 b/msft-windows/msft-detect-duplicate-profiles.ps1 new file mode 100644 index 0000000..c780d34 --- /dev/null +++ b/msft-windows/msft-detect-duplicate-profiles.ps1 @@ -0,0 +1,305 @@ +## 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 +## $RMM +## $deleteOldest - Set to 1 to automatically delete the oldest duplicate profile registry entry (default: 0, detection only) + +# Getting input from user if not running from RMM else set variables from RMM. + +$ScriptLogName = "msft-detect-duplicate-profiles.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." + } + } + + # Set default value for deleteOldest in interactive mode + if ([string]::IsNullOrEmpty($deleteOldest)) { + $deleteOldest = 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" + } + + # Set default value for deleteOldest if not provided by RMM + if ([string]::IsNullOrEmpty($deleteOldest)) { + $deleteOldest = 0 + } +} + +# 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 "Delete Oldest: $deleteOldest" + +<# +.SYNOPSIS + Detects duplicate ProfileImagePath entries in Windows user profile registry. + +.DESCRIPTION + Scans HKLM:\SOFTWARE\Microsoft\Windows NT\CurrentVersion\ProfileList for duplicate + ProfileImagePath values which can cause user profile corruption and login issues. + + Common causes: + - Failed domain migrations + - Profile corruption during Windows upgrades + - Manual profile manipulation + - SID conflicts + +.NOTES + Author: Nathaniel Smith / Claude Code + Registry Path: HKLM:\SOFTWARE\Microsoft\Windows NT\CurrentVersion\ProfileList +#> + +### ————— PROFILE LIST DETECTION ————— +Write-Output "`n========================================" +Write-Output "Windows Profile Duplicate Detection" +Write-Output "========================================`n" + +$profileListPath = "HKLM:\SOFTWARE\Microsoft\Windows NT\CurrentVersion\ProfileList" + +Write-Output "Scanning registry: $profileListPath" +Write-Output "" + +try { + # Get all profile subkeys (SIDs) + $profileKeys = Get-ChildItem -Path $profileListPath -ErrorAction Stop + + if ($profileKeys.Count -eq 0) { + Write-Output "No profile keys found in ProfileList." + Stop-Transcript + exit 0 + } + + Write-Output "Found $($profileKeys.Count) profile entries. Analyzing for duplicates..." + Write-Output "" + + # Build a hashtable of ProfileImagePath -> List of SIDs + $profilePaths = @{} + $totalProfiles = 0 + + foreach ($key in $profileKeys) { + $sid = $key.PSChildName + + try { + $profileImagePath = (Get-ItemProperty -Path $key.PSPath -Name "ProfileImagePath" -ErrorAction SilentlyContinue).ProfileImagePath + + if ($profileImagePath) { + $totalProfiles++ + + # Normalize path (case-insensitive comparison) + $normalizedPath = $profileImagePath.ToLower() + + if (-not $profilePaths.ContainsKey($normalizedPath)) { + $profilePaths[$normalizedPath] = @() + } + + $profilePaths[$normalizedPath] += @{ + SID = $sid + Path = $profileImagePath + KeyPath = $key.PSPath + } + } + } catch { + Write-Warning "Could not read ProfileImagePath for SID: $sid. Error: $($_.Exception.Message)" + } + } + + Write-Output "Total profiles with ProfileImagePath: $totalProfiles" + Write-Output "Unique profile paths: $($profilePaths.Count)" + Write-Output "" + + # Find duplicates + $duplicates = $profilePaths.GetEnumerator() | Where-Object { $_.Value.Count -gt 1 } + + if ($duplicates.Count -eq 0) { + Write-Output "========================================`n" + Write-Output "RESULT: No duplicate profile paths found." + Write-Output "" + Write-Output "All user profiles have unique ProfileImagePath values." + Write-Output "Profile registry appears healthy." + Stop-Transcript + exit 0 + } + + # Report duplicates + Write-Output "========================================`n" + Write-Output "WARNING: DUPLICATE PROFILE PATHS DETECTED" + Write-Output "" + Write-Output "Found $($duplicates.Count) profile path(s) with multiple SID entries:" + Write-Output "" + + $duplicateCount = 0 + $deletedCount = 0 + + foreach ($duplicate in $duplicates) { + $duplicateCount++ + $path = $duplicate.Value[0].Path + $sids = $duplicate.Value + + Write-Output "[$duplicateCount] Duplicate Path: $path" + Write-Output " Number of SIDs pointing to this path: $($sids.Count)" + Write-Output "" + + # Determine which profile is oldest if deletion is enabled + $oldestEntry = $null + $oldestTimestamp = $null + + foreach ($entry in $sids) { + Write-Output " - SID: $($entry.SID)" + Write-Output " Registry: $($entry.KeyPath)" + + # Try to get additional profile info + try { + $state = (Get-ItemProperty -Path $entry.KeyPath -Name "State" -ErrorAction SilentlyContinue).State + $stateText = switch ($state) { + 0 { "Normal" } + 1 { "Mandatory" } + 2 { "Backup" } + 4 { "Temporary" } + 8 { "Roaming" } + default { "Unknown ($state)" } + } + Write-Output " State: $stateText" + + # Get last load time to determine oldest + $loadTimeLow = (Get-ItemProperty -Path $entry.KeyPath -Name "LocalProfileLoadTimeLow" -ErrorAction SilentlyContinue).LocalProfileLoadTimeLow + $loadTimeHigh = (Get-ItemProperty -Path $entry.KeyPath -Name "LocalProfileLoadTimeHigh" -ErrorAction SilentlyContinue).LocalProfileLoadTimeHigh + + if ($loadTimeLow -or $loadTimeHigh) { + Write-Output " Has been loaded: Yes" + + # Combine high and low DWORD to get 64-bit timestamp + if ($loadTimeHigh -and $loadTimeLow) { + $timestamp = ([Int64]$loadTimeHigh -shl 32) -bor $loadTimeLow + Write-Output " Load timestamp: $timestamp" + + # Track oldest for potential deletion + if ($deleteOldest -eq 1) { + if ($null -eq $oldestTimestamp -or $timestamp -lt $oldestTimestamp) { + $oldestTimestamp = $timestamp + $oldestEntry = $entry + } + } + } + } else { + Write-Output " Has been loaded: No (or no timestamp)" + + # If no load time, consider it oldest (never loaded) + if ($deleteOldest -eq 1 -and $null -eq $oldestEntry) { + $oldestEntry = $entry + } + } + + } catch { + # Silently continue if we can't get extra info + } + Write-Output "" + } + + # Delete the oldest duplicate if enabled + if ($deleteOldest -eq 1 -and $oldestEntry) { + Write-Output " ACTION: Deleting oldest duplicate profile registry entry..." + Write-Output " Target SID: $($oldestEntry.SID)" + Write-Output " Registry: $($oldestEntry.KeyPath)" + + try { + # Backup registry key to a .reg file before deletion + $backupPath = "$env:TEMP\ProfileList_Backup_$($oldestEntry.SID)_$(Get-Date -Format 'yyyyMMdd_HHmmss').reg" + Write-Output " Creating backup: $backupPath" + + $regPath = $oldestEntry.KeyPath -replace "Microsoft.PowerShell.Core\\Registry::", "" + $exportResult = reg export "$regPath" "$backupPath" 2>&1 + + if ($LASTEXITCODE -eq 0) { + Write-Output " Backup created successfully." + + # Delete the registry key + Write-Output " Deleting registry key..." + Remove-Item -Path $oldestEntry.KeyPath -Recurse -Force -ErrorAction Stop + Write-Output " SUCCESS: Registry key deleted." + $deletedCount++ + + } else { + Write-Warning " Failed to backup registry key. Skipping deletion for safety." + Write-Output " Export error: $exportResult" + } + + } catch { + Write-Warning " Failed to delete registry key: $($_.Exception.Message)" + } + Write-Output "" + } + } + + Write-Output "========================================`n" + + if ($deleteOldest -eq 1) { + Write-Output "DELETION SUMMARY:" + Write-Output "" + Write-Output "Deleted $deletedCount duplicate profile registry entries." + Write-Output "Registry backups saved to: $env:TEMP\ProfileList_Backup_*.reg" + Write-Output "" + Write-Output "NEXT STEPS:" + Write-Output "1. Verify user can log in successfully" + Write-Output "2. Check that the correct profile is being loaded" + Write-Output "3. If issues occur, restore from backup .reg file" + Write-Output "4. Consider deleting orphaned profile folders if they exist" + Write-Output "" + } else { + Write-Output "RECOMMENDATIONS:" + Write-Output "" + Write-Output "1. Identify the correct SID for each user account" + Write-Output "2. Back up the ProfileList registry key before making changes" + Write-Output "3. Delete or rename the incorrect/duplicate registry entries" + Write-Output "4. Consider renaming the duplicate profile folder if necessary" + Write-Output "5. Test user logins after remediation" + Write-Output "" + Write-Output "Common Resolution:" + Write-Output "- Domain accounts: Keep the SID that matches the domain SID" + Write-Output "- Local accounts: Keep the SID with most recent profile data" + Write-Output "- .bak profiles: Delete the .bak SID registry entry" + Write-Output "" + Write-Output "AUTOMATIC DELETION:" + Write-Output "To automatically delete the oldest duplicate, run with:" + Write-Output "`$deleteOldest = 1" + Write-Output "" + } + +} catch { + Write-Error "Failed to scan ProfileList registry: $($_.Exception.Message)" + Stop-Transcript + exit 1 +} + +Write-Output "Profile duplicate detection completed." +Write-Output "" + +Stop-Transcript +exit 0 diff --git a/msft-windows/msft-disable-print-to-pdf.ps1 b/msft-windows/msft-disable-print-to-pdf.ps1 new file mode 100644 index 0000000..58f200c --- /dev/null +++ b/msft-windows/msft-disable-print-to-pdf.ps1 @@ -0,0 +1,194 @@ +## 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 +## $RMM + +# Getting input from user if not running from RMM else set variables from RMM. + +$ScriptLogName = "msft-disable-print-to-pdf.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." + } + } + $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" + +<# +.SYNOPSIS + Disables Microsoft Print to PDF feature to resolve Windows 11 upgrade compatibility issues. + +.DESCRIPTION + Removes the Microsoft Print to PDF Windows optional feature which is a known blocker + for Windows 11 upgrades. This script disables the feature without forcing an immediate + reboot, but a reboot is REQUIRED before starting the Windows 11 upgrade. + + Known Issues: + - Microsoft Print to PDF drivers can cause Windows 11 upgrade failures + - Compatibility scan detects Print to PDF as blocker + - Feature must be fully removed (requires reboot) before upgrade attempt + +.NOTES + Author: Nathaniel Smith / Claude Code + IMPORTANT: System MUST be rebooted after running this script before attempting upgrade. + + Feature Names: + - Printing-PrintToPDFServices-Features (Print to PDF) + + Workflow for Windows 11 Upgrade: + 1. Run this script to disable Print to PDF + 2. REBOOT the system + 3. Run Windows 11 upgrade script +#> + +### ————— CHECK ELEVATION ————— +if (-not ([Security.Principal.WindowsPrincipal][Security.Principal.WindowsIdentity]::GetCurrent()).IsInRole([Security.Principal.WindowsBuiltInRole]::Administrator)) { + Write-Error "This script must be run as Administrator." + Stop-Transcript + exit 1 +} + +### ————— DETECT PRINT TO PDF FEATURE STATE ————— +Write-Output "`n========================================" +Write-Output "Microsoft Print to PDF Removal" +Write-Output "========================================`n" + +$featureName = "Printing-PrintToPDFServices-Features" + +Write-Output "Checking Print to PDF feature status..." + +try { + $feature = Get-WindowsOptionalFeature -Online -FeatureName $featureName -ErrorAction Stop + + Write-Output "Feature Name: $($feature.FeatureName)" + Write-Output "Display Name: $($feature.DisplayName)" + Write-Output "Current State: $($feature.State)" + Write-Output "" + + if ($feature.State -eq "Disabled") { + Write-Output "RESULT: Microsoft Print to PDF is already disabled." + Write-Output "" + Write-Output "IMPORTANT: If this system has not been rebooted since disabling," + Write-Output "a reboot is still required before attempting Windows 11 upgrade." + Write-Output "The drivers remain loaded in memory until reboot." + Write-Output "" + Stop-Transcript + exit 0 + } + + if ($feature.State -eq "DisablePending") { + Write-Output "RESULT: Microsoft Print to PDF is pending removal." + Write-Output "" + Write-Output "CRITICAL: System MUST be rebooted to complete removal." + Write-Output "Do NOT attempt Windows 11 upgrade until after reboot." + Write-Output "The feature is not fully removed until reboot completes." + Write-Output "" + Stop-Transcript + exit 0 + } + +} catch { + Write-Error "Failed to query Windows optional feature: $($_.Exception.Message)" + Stop-Transcript + exit 1 +} + +### ————— DISABLE PRINT TO PDF ————— +Write-Output "Disabling Microsoft Print to PDF feature..." +Write-Output "This may take a few minutes..." +Write-Output "" + +try { + # Disable the feature without forcing immediate reboot + $result = Disable-WindowsOptionalFeature -Online -FeatureName $featureName -NoRestart -ErrorAction Stop + + if ($result.RestartNeeded) { + Write-Output "========================================`n" + Write-Output "SUCCESS: Microsoft Print to PDF has been disabled." + Write-Output "" + Write-Output "CRITICAL - REBOOT REQUIRED:" + Write-Output "" + Write-Output "The feature has been disabled but is NOT fully removed until reboot." + Write-Output "Print to PDF drivers are still loaded in memory." + Write-Output "" + Write-Output "BEFORE attempting Windows 11 upgrade:" + Write-Output " 1. Reboot this system" + Write-Output " 2. Verify feature is fully disabled (run this script again)" + Write-Output " 3. Then proceed with Windows 11 upgrade" + Write-Output "" + Write-Output "Windows Setup compatibility scan will fail if system is not rebooted." + Write-Output "The loaded drivers will be detected as a blocker even though disabled." + Write-Output "" + Write-Output "========================================`n" + + } else { + Write-Output "========================================`n" + Write-Output "SUCCESS: Microsoft Print to PDF has been disabled." + Write-Output "" + Write-Output "No reboot indicated as required by Windows, but for Windows 11 upgrade:" + Write-Output "It is still recommended to reboot before attempting upgrade to ensure" + Write-Output "all driver components are fully unloaded from memory." + Write-Output "" + Write-Output "========================================`n" + } + + # Show final state + Write-Output "Verifying final state..." + $finalState = Get-WindowsOptionalFeature -Online -FeatureName $featureName -ErrorAction Stop + Write-Output "Final State: $($finalState.State)" + Write-Output "" + +} catch { + Write-Error "Failed to disable Print to PDF feature: $($_.Exception.Message)" + Write-Output "" + Write-Output "This may occur if:" + Write-Output "- System files are corrupted (run sfc /scannow)" + Write-Output "- Windows Update is in progress" + Write-Output "- Insufficient permissions" + Write-Output "- Feature is already being modified" + Write-Output "" + Stop-Transcript + exit 1 +} + +Write-Output "Print to PDF removal completed." +Write-Output "" +Write-Output "NEXT STEPS:" +Write-Output "1. Schedule a reboot for this system" +Write-Output "2. After reboot, verify feature is disabled (run this script again)" +Write-Output "3. Proceed with Windows 11 upgrade" +Write-Output "" + +Stop-Transcript +exit 0 diff --git a/msft-windows/msft-win-bitlocker-inventory.ps1 b/msft-windows/msft-win-bitlocker-inventory.ps1 index 0324fd7..7d55260 100644 --- a/msft-windows/msft-win-bitlocker-inventory.ps1 +++ b/msft-windows/msft-win-bitlocker-inventory.ps1 @@ -1,10 +1,24 @@ -## PLEASE COMMENT YOUR VARIALBES DIRECTLY BELOW HERE IF YOU'RE RUNNING FROM A 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 +## $RMM +## $Description + +### ————— MSP RMM VARIABLE INITIALIZATION GOES HERE ————— +# Example for NinjaRMM: +# $RMM = 1 +# $Description = Ninja custom field or automatic +# +# Example for ConnectWise Automate: +# $RMM = 1 +# $Description = %description% +# +# Example for Datto RMM: +# $RMM = 1 +# $Description = $env:description +### ————— END RMM VARIABLE INITIALIZATION ————— # Getting input from user if not running from RMM else set variables from RMM. -# This script should only be run from a RMM - $ScriptLogName = "msft-win-bitlocker-inventory.log" if ($RMM -ne 1) { @@ -20,25 +34,23 @@ if ($RMM -ne 1) { Write-Host "Invalid input. Please try again." } } + $LogPath = "$ENV:WINDIR\logs\$ScriptLogName" -} else { +} 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. @@ -49,147 +61,564 @@ Write-Host "Description: $Description" Write-Host "Log path: $LogPath" Write-Host "RMM: $RMM" -# Function to check if the system is joined to a domain -function Get-DomainJoinStatus { - if (Test-ComputerSecureChannel) { - return $true - } else { +<# +.SYNOPSIS + Inventories BitLocker status and ensures both recovery passwords and keys exist. + +.DESCRIPTION + This script performs comprehensive BitLocker inventory and recovery protector management: + + Features: + - Scans all volumes for BitLocker encryption status + - Automatically creates BOTH recovery passwords (48-digit) AND recovery keys (.BEK) if missing + - Backs up BOTH passwords and keys to Active Directory (domain-joined systems) + - Stores recovery passwords in RMM custom fields for quick access + - Configures registry settings for automatic AD backup (domain-joined systems) + - Provides clear, user-friendly output showing status of each volume + - Generates summary report of actions taken + + Recovery Protector Types Created: + - Recovery Password: 48-digit numerical password (e.g., 123456-789012-...) + - Recovery Key: 256-bit .BEK file stored in Active Directory + + Storage Locations: + - Active Directory: Both passwords AND keys (domain-joined systems only) + - RMM Custom Fields: Passwords only (for quick access) + - Transcript Log: Full execution details including all passwords + + The script is safe to run multiple times - it only creates protectors if they're missing, + and only backs up protectors that aren't already stored. + +.NOTES + Author: Nathaniel Smith / Claude Code + Requires: Administrator privileges + Requires: BitLocker enabled on at least one volume to perform recovery protector actions +#> + +Write-Output "`n==========================================" +Write-Output "BITLOCKER INVENTORY & RECOVERY KEY MANAGEMENT" +Write-Output "==========================================`n" + +### ————— HELPER FUNCTIONS ————— + +function Test-DomainJoined { + <# + .SYNOPSIS + Checks if the system is joined to an Active Directory domain. + #> + try { + $result = Test-ComputerSecureChannel -ErrorAction Stop + return $result + } catch { return $false - } + } } -# Function to check if BitLocker is enabled on a volume -function Get-BitLockerStatus { +function Get-VolumeEncryptionStatus { + <# + .SYNOPSIS + Gets the BitLocker encryption status for a specific volume. + #> param( - [string]$DriveLetter + [Parameter(Mandatory = $true)] + [string]$MountPoint ) - $status = Get-BitLockerVolume -MountPoint $DriveLetter - return $status.ProtectionStatus -eq "On" + + try { + $volume = Get-BitLockerVolume -MountPoint $MountPoint -ErrorAction Stop + return @{ + IsProtected = ($volume.ProtectionStatus -eq "On") + EncryptionPercentage = $volume.EncryptionPercentage + VolumeStatus = $volume.VolumeStatus + KeyProtectors = $volume.KeyProtector + } + } catch { + Write-Warning "Could not get BitLocker status for $MountPoint : $_" + return $null + } } -# Function to get or generate a BitLocker recovery password for a volume -function Get-OrGenerateRecoveryPassword { +function Get-RecoveryProtectorStatus { + <# + .SYNOPSIS + Checks if recovery passwords and recovery keys exist for a volume. + #> param( - [string]$DriveLetter + [Parameter(Mandatory = $true)] + [string]$MountPoint ) - $recoveryPasswords = Get-BitLockerVolume -MountPoint $DriveLetter | Select-Object -ExpandProperty KeyProtector | Where-Object { $_.KeyProtectorType -eq 'RecoveryPassword' } + try { + $volume = Get-BitLockerVolume -MountPoint $MountPoint -ErrorAction Stop + $recoveryPasswords = $volume.KeyProtector | Where-Object { $_.KeyProtectorType -eq 'RecoveryPassword' } + $recoveryKeys = $volume.KeyProtector | Where-Object { $_.KeyProtectorType -eq 'ExternalKey' } + + return @{ + Passwords = @{ + Exists = ($null -ne $recoveryPasswords) + Count = @($recoveryPasswords).Count + Values = $recoveryPasswords.RecoveryPassword + KeyProtectorIds = $recoveryPasswords.KeyProtectorId + } + Keys = @{ + Exists = ($null -ne $recoveryKeys) + Count = @($recoveryKeys).Count + KeyProtectorIds = $recoveryKeys.KeyProtectorId + } + } + } catch { + Write-Warning "Could not check recovery protector status for $MountPoint : $_" + return $null + } +} - if ($recoveryPasswords) { - return $recoveryPasswords.RecoveryPassword - } else { - # If no recovery password is found, generate a new one - Add-BitLockerKeyProtector -MountPoint $DriveLetter -RecoveryPasswordProtector - $recoveryPasswords = Get-BitLockerVolume -MountPoint $DriveLetter | Select-Object -ExpandProperty KeyProtector | Where-Object { $_.KeyProtectorType -eq 'RecoveryPassword' } - return $recoveryPasswords.RecoveryPassword +function New-RecoveryPassword { + <# + .SYNOPSIS + Creates a new recovery password (48-digit) for a BitLocker volume. + #> + param( + [Parameter(Mandatory = $true)] + [string]$MountPoint + ) + + try { + Write-Output " → Creating new recovery password..." + Add-BitLockerKeyProtector -MountPoint $MountPoint -RecoveryPasswordProtector -ErrorAction Stop + Write-Output " ✓ Recovery password created successfully" + return @{ Success = $true } + } catch { + Write-Warning " ✗ Failed to create recovery password: $_" + return @{ Success = $false; Error = $_.Exception.Message } } } -# Function to backup the recovery password to AD or AAD -function BackupRecoveryPassword { +function New-RecoveryKey { + <# + .SYNOPSIS + Creates a new recovery key (.BEK file) for a BitLocker volume. + #> param( - [string]$DriveLetter + [Parameter(Mandatory = $true)] + [string]$MountPoint ) - $domainJoined = Get-DomainJoinStatus + try { + Write-Output " → Creating new recovery key..." + # Note: Recovery keys are typically saved to a specific path + # For AD backup, we just create the protector without saving to file + Add-BitLockerKeyProtector -MountPoint $MountPoint -RecoveryKeyProtector -RecoveryKeyPath "$env:TEMP" -ErrorAction Stop + Write-Output " ✓ Recovery key created successfully" + return @{ Success = $true } + } catch { + Write-Warning " ✗ Failed to create recovery key: $_" + return @{ Success = $false; Error = $_.Exception.Message } + } +} + +function Backup-RecoveryProtectorsToAD { + <# + .SYNOPSIS + Backs up all recovery passwords and keys for a volume to Active Directory. + #> + param( + [Parameter(Mandatory = $true)] + [string]$MountPoint + ) - if ($domainJoined) { - # Store bitlocker keys in Active Directory computer object - $KeyProtectorsToBackup = Get-BitlockerVolume -MountPoint $DriveLetter | Select -Expand KeyProtector | Where { $_.KeyProtectorType -eq 'RecoveryPassword' } | Select -Expand KeyProtectorId - $KeyProtectorsToBackup | ForEach-Object { Backup-BitLockerKeyProtector -MountPoint $DriveLetter -KeyProtectorId $_ } + $isDomainJoined = Test-DomainJoined - # The below is commented out as the key protector needs to Azure AD aware for a computer/user object or a group. This functionality is limited at the moment. - # } else { - # Add-BitLockerKeyProtector -MountPoint $DriveLetter -AdAccountOrGroupProtector + if (-not $isDomainJoined) { + Write-Output " ℹ System is not domain-joined, skipping AD backup" + return @{ Skipped = $true; Reason = "Not domain-joined" } + } + + try { + $protectorStatus = Get-RecoveryProtectorStatus -MountPoint $MountPoint + + if (-not $protectorStatus.Passwords.Exists -and -not $protectorStatus.Keys.Exists) { + Write-Warning " ✗ No recovery protectors found to backup" + return @{ Success = $false; Reason = "No recovery protectors" } + } + + $totalProtectors = $protectorStatus.Passwords.Count + $protectorStatus.Keys.Count + Write-Output " → Backing up $totalProtectors recovery protector(s) to Active Directory..." + + $successCount = 0 + + # Backup recovery passwords + if ($protectorStatus.Passwords.Exists) { + foreach ($keyId in $protectorStatus.Passwords.KeyProtectorIds) { + try { + Backup-BitLockerKeyProtector -MountPoint $MountPoint -KeyProtectorId $keyId -ErrorAction Stop + $successCount++ + } catch { + Write-Warning " ✗ Failed to backup password protector $keyId : $_" + } + } + } + + # Backup recovery keys + if ($protectorStatus.Keys.Exists) { + foreach ($keyId in $protectorStatus.Keys.KeyProtectorIds) { + try { + Backup-BitLockerKeyProtector -MountPoint $MountPoint -KeyProtectorId $keyId -ErrorAction Stop + $successCount++ + } catch { + Write-Warning " ✗ Failed to backup key protector $keyId : $_" + } + } + } + + if ($successCount -gt 0) { + Write-Output " ✓ Backed up $successCount recovery protector(s) to AD" + return @{ Success = $true; Count = $successCount } + } else { + Write-Warning " ✗ Failed to backup any recovery protectors" + return @{ Success = $false } + } + + } catch { + Write-Warning " ✗ Error during AD backup: $_" + return @{ Success = $false; Error = $_.Exception.Message } } } -# Function to configure windows for Active Directory Backup -function Set-BitLockerADBackupSettings { +function Set-BitLockerADBackupPolicy { + <# + .SYNOPSIS + Configures registry settings to enable automatic BitLocker recovery key backup to Active Directory. + #> param ( [switch]$Force ) - # Define the registry path for BitLocker settings + Write-Output "`nConfiguring BitLocker AD backup policy..." + $registryPath = "HKLM:\SOFTWARE\Policies\Microsoft\FVE" - # Define the registry values and their corresponding settings + # Registry values for AD backup configuration $registryValues = @{ "ActiveDirectoryBackup" = 1 "ActiveDirectoryInfoToStore" = 1 - "FDVActiveDirectoryBackup" = 1 - "FDVActiveDirectoryInfoToStore" = 1 - "FDVHideRecoveryPage" = 1 - "FDVManageDRA" = 1 - "FDVRecovery" = 1 - "FDVRecoveryKey" = 2 - "FDVRecoveryPassword" = 1 - "FDVRequireActiveDirectoryBackup" = 1 + "RequireActiveDirectoryBackup" = 1 "OSActiveDirectoryBackup" = 1 "OSActiveDirectoryInfoToStore" = 1 - "OSHideRecoveryPage" = 1 - "OSManageDRA" = 1 "OSRecovery" = 1 - "OSRecoveryKey" = 2 "OSRecoveryPassword" = 1 + "OSRecoveryKey" = 2 + "OSHideRecoveryPage" = 1 + "OSManageDRA" = 1 "OSRequireActiveDirectoryBackup" = 1 - "RequireActiveDirectoryBackup" = 1 + "FDVActiveDirectoryBackup" = 1 + "FDVActiveDirectoryInfoToStore" = 1 + "FDVRecovery" = 1 + "FDVRecoveryPassword" = 1 + "FDVRecoveryKey" = 2 + "FDVHideRecoveryPage" = 1 + "FDVManageDRA" = 1 + "FDVRequireActiveDirectoryBackup" = 1 "RDVActiveDirectoryBackup" = 1 "RDVActiveDirectoryInfoToStore" = 1 - "RDVHideRecoveryPage" = 1 - "RDVManageDRA" = 1 "RDVRecovery" = 1 - "RDVRecoveryKey" = 2 "RDVRecoveryPassword" = 1 + "RDVRecoveryKey" = 2 + "RDVHideRecoveryPage" = 1 + "RDVManageDRA" = 1 "RDVRequireActiveDirectoryBackup" = 1 } - # Create the registry path if it doesn't exist + # Create registry path if it doesn't exist if (-not (Test-Path $registryPath)) { - New-Item -Path $registryPath -Force + try { + New-Item -Path $registryPath -Force -ErrorAction Stop | Out-Null + Write-Output " → Created registry path: $registryPath" + } catch { + Write-Warning " ✗ Failed to create registry path: $_" + return $false + } } - # Iterate through each registry value and set it + # Set each registry value + $setCount = 0 + $skipCount = 0 + foreach ($name in $registryValues.Keys) { $value = $registryValues[$name] - - if (-not (Get-ItemProperty -Path $registryPath -Name $name -ErrorAction SilentlyContinue) -or $Force) { - Write-Output "Setting $name to $value in $registryPath" - New-ItemProperty -Path $registryPath -Name $name -Value $value -PropertyType DWORD -Force | Out-Null - } else { - Write-Output "Registry key $name already exists with the desired value." + + try { + $existingValue = Get-ItemProperty -Path $registryPath -Name $name -ErrorAction SilentlyContinue + + if ($null -eq $existingValue -or $Force) { + New-ItemProperty -Path $registryPath -Name $name -Value $value -PropertyType DWORD -Force -ErrorAction Stop | Out-Null + $setCount++ + } else { + $skipCount++ + } + } catch { + Write-Warning " ✗ Failed to set $name : $_" } } - Write-Output "BitLocker AD Backup settings have been configured." + Write-Output " ✓ Set $setCount registry value(s), skipped $skipCount existing value(s)" + return $true } +### ————— MAIN SCRIPT LOGIC ————— + +# Check if running with admin privileges +$isAdmin = ([Security.Principal.WindowsPrincipal] [Security.Principal.WindowsIdentity]::GetCurrent()).IsInRole([Security.Principal.WindowsBuiltInRole] "Administrator") -# Main script -$volumes = Get-BitLockerVolume -$recoveryPasswords = @{} +if (-not $isAdmin) { + Write-Error "This script requires administrator privileges. Please run as administrator." + Stop-Transcript + exit 1 +} -# Configure Bitlocker Active Directory backup if endpoint is joined to an Active Directory domain. -if (Get-DomainJoinStatus) { - Set-BitLockerADBackupSettings -Force +# Check domain join status +$isDomainJoined = Test-DomainJoined +if ($isDomainJoined) { + Write-Output "System Status: Domain-joined (AD backup available)" + Set-BitLockerADBackupPolicy -Force +} else { + Write-Output "System Status: Not domain-joined (AD backup unavailable)" } -# Generate Bitlocker recovery passwords and, or store in Active Directory / Entra ID. +Write-Output "`n==========================================" +Write-Output "SCANNING VOLUMES" +Write-Output "==========================================`n" + +# Get all volumes +try { + $volumes = Get-BitLockerVolume -ErrorAction Stop +} catch { + Write-Error "Failed to enumerate BitLocker volumes: $_" + Stop-Transcript + exit 1 +} + +if ($volumes.Count -eq 0) { + Write-Output "No BitLocker volumes found on this system." + Stop-Transcript + exit 0 +} + +Write-Output "Found $($volumes.Count) volume(s) to check`n" + +# Track actions taken +$actionsSummary = @{ + VolumesScanned = 0 + VolumesEncrypted = 0 + RecoveryKeysCreated = 0 + RecoveryKeysBackedUp = 0 + Errors = 0 +} + +# Collect all recovery passwords for RMM storage +$allRecoveryPasswords = @{} + +# Process each volume foreach ($volume in $volumes) { - $driveLetter = $volume.MountPoint + $mountPoint = $volume.MountPoint + $actionsSummary.VolumesScanned++ - if (Get-BitLockerStatus -DriveLetter $driveLetter) { - $password = Get-OrGenerateRecoveryPassword -DriveLetter $driveLetter - $recoveryPasswords[$driveLetter] = $password - BackupRecoveryPassword -DriveLetter $driveLetter - } else { - Write-Output "BitLocker is not enabled on $driveLetter" + Write-Output "—————————————————————————————————————————" + Write-Output "Volume: $mountPoint" + Write-Output "—————————————————————————————————————————" + + # Get encryption status + $encryptionStatus = Get-VolumeEncryptionStatus -MountPoint $mountPoint + + if ($null -eq $encryptionStatus) { + Write-Warning " ✗ Could not determine encryption status, skipping" + $actionsSummary.Errors++ + Write-Output "" + continue + } + + # Check if BitLocker is enabled + if (-not $encryptionStatus.IsProtected) { + Write-Output " Status: BitLocker is NOT enabled" + Write-Output " Action: Skipping (no encryption active)`n" + continue + } + + $actionsSummary.VolumesEncrypted++ + Write-Output " Status: BitLocker is ENABLED" + Write-Output " Encryption: $($encryptionStatus.EncryptionPercentage)%" + Write-Output " Volume Status: $($encryptionStatus.VolumeStatus)" + + # Check for recovery protectors (passwords and keys) + $protectorStatus = Get-RecoveryProtectorStatus -MountPoint $mountPoint + + if ($null -eq $protectorStatus) { + Write-Warning " ✗ Could not check recovery protector status" + $actionsSummary.Errors++ + Write-Output "" + continue } + + # Display current status + Write-Output " Recovery Password: $(if ($protectorStatus.Passwords.Exists) { "EXISTS ($($protectorStatus.Passwords.Count) found)" } else { "MISSING" })" + Write-Output " Recovery Key: $(if ($protectorStatus.Keys.Exists) { "EXISTS ($($protectorStatus.Keys.Count) found)" } else { "MISSING" })" + + # Create recovery password if missing + if (-not $protectorStatus.Passwords.Exists) { + $passwordResult = New-RecoveryPassword -MountPoint $mountPoint + if ($passwordResult.Success) { + $actionsSummary.RecoveryKeysCreated++ + } else { + $actionsSummary.Errors++ + } + } + + # Create recovery key if missing + if (-not $protectorStatus.Keys.Exists) { + $keyResult = New-RecoveryKey -MountPoint $mountPoint + if ($keyResult.Success) { + $actionsSummary.RecoveryKeysCreated++ + } else { + $actionsSummary.Errors++ + } + } + + # Refresh protector status after creation + $protectorStatus = Get-RecoveryProtectorStatus -MountPoint $mountPoint + + # Store recovery passwords for RMM (passwords only, not keys) + if ($protectorStatus.Passwords.Exists) { + $allRecoveryPasswords[$mountPoint] = $protectorStatus.Passwords.Values + } + + # Backup to AD if domain-joined (backup both passwords and keys) + if ($isDomainJoined -and ($protectorStatus.Passwords.Exists -or $protectorStatus.Keys.Exists)) { + $backupResult = Backup-RecoveryProtectorsToAD -MountPoint $mountPoint + + if ($backupResult.Success) { + $actionsSummary.RecoveryKeysBackedUp += $backupResult.Count + } elseif (-not $backupResult.Skipped) { + $actionsSummary.Errors++ + } + } + + Write-Output "" +} + +### ————— SUMMARY REPORT ————— + +Write-Output "==========================================" +Write-Output "SUMMARY REPORT" +Write-Output "==========================================`n" + +Write-Output "Volumes scanned: $($actionsSummary.VolumesScanned)" +Write-Output "Volumes encrypted: $($actionsSummary.VolumesEncrypted)" +Write-Output "Recovery protectors created: $($actionsSummary.RecoveryKeysCreated)" + +if ($isDomainJoined) { + Write-Output "Protectors backed up to AD: $($actionsSummary.RecoveryKeysBackedUp)" +} + +Write-Output "Errors encountered: $($actionsSummary.Errors)" + +Write-Output "`n==========================================" + +if ($actionsSummary.RecoveryKeysCreated -gt 0) { + Write-Output "✓ Successfully created $($actionsSummary.RecoveryKeysCreated) recovery protector(s) (passwords and/or keys)" +} + +if ($actionsSummary.RecoveryKeysBackedUp -gt 0) { + Write-Output "✓ Successfully backed up $($actionsSummary.RecoveryKeysBackedUp) protector(s) to Active Directory" } -# Output the recovery passwords (Disabled for now) -# $recoveryPasswords +if ($actionsSummary.Errors -eq 0) { + Write-Output "✓ All operations completed successfully" +} else { + Write-Warning "⚠ Completed with $($actionsSummary.Errors) error(s) - review log for details" +} + +Write-Output "==========================================" + +### ————— FORMAT RECOVERY PASSWORDS FOR RMM ————— + +# Format all recovery passwords into a string for RMM storage +$recoveryPasswordsFormatted = "" +if ($allRecoveryPasswords.Count -gt 0) { + Write-Output "`nFormatting recovery passwords for RMM storage..." + + $passwordLines = @() + foreach ($mountPoint in ($allRecoveryPasswords.Keys | Sort-Object)) { + $passwords = $allRecoveryPasswords[$mountPoint] + + if ($passwords -is [array]) { + # Multiple passwords for this volume + for ($i = 0; $i -lt $passwords.Count; $i++) { + if ($passwords.Count -gt 1) { + $passwordLines += "$mountPoint (Key $($i + 1)): $($passwords[$i])" + } else { + $passwordLines += "$mountPoint $($passwords[$i])" + } + } + } else { + # Single password + $passwordLines += "$mountPoint $passwords" + } + } + + $recoveryPasswordsFormatted = $passwordLines -join " | " + + Write-Output " ✓ Formatted $($allRecoveryPasswords.Count) volume(s) with recovery passwords" + Write-Output "`nRecovery Passwords (for RMM storage):" + Write-Output $recoveryPasswordsFormatted +} -Stop-Transcript +### ————— TUNNEL OUTPUT VARIABLE TO YOUR RMM HERE ————— +# Available output variables for RMM custom fields: +# +# STATISTICS (numeric values): +# - $actionsSummary.VolumesScanned : Total volumes checked +# - $actionsSummary.VolumesEncrypted : Number of BitLocker-enabled volumes +# - $actionsSummary.RecoveryKeysCreated : Number of new recovery protectors created (passwords + keys) +# - $actionsSummary.RecoveryKeysBackedUp : Number of protectors backed up to AD +# - $actionsSummary.Errors : Number of errors encountered +# +# RECOVERY PASSWORDS (string - SENSITIVE DATA): +# - $recoveryPasswordsFormatted : All recovery passwords (48-digit) formatted as: +# "C: 123456-789012... | D: 234567-890123..." +# +# IMPORTANT SECURITY NOTES: +# - Recovery passwords are HIGHLY SENSITIVE credentials that can decrypt drives +# - Store them in secure custom fields with restricted access in your RMM platform +# - Recovery KEYS (.BEK files) are stored in Active Directory only, NOT in RMM +# - RMM stores PASSWORDS only for quick access when unlocking systems +# +# STORAGE SUMMARY: +# - Active Directory: Both passwords AND keys (full backup) +# - RMM Custom Fields: Passwords only (quick access) +# - Transcript Log: Full details including all passwords +# +# Example for NinjaRMM: +# if (Get-Command 'Ninja-Property-Set' -ErrorAction SilentlyContinue) { +# Ninja-Property-Set -Name 'bitlockerVolumesEncrypted' -Value $actionsSummary.VolumesEncrypted +# Ninja-Property-Set -Name 'bitlockerProtectorsCreated' -Value $actionsSummary.RecoveryKeysCreated +# # NOTE: Create 'bitlockerRecoveryPasswords' as a WYSIWYG or secure text custom field in NinjaRMM +# Ninja-Property-Set -Name 'bitlockerRecoveryPasswords' -Value $recoveryPasswordsFormatted +# } +# +# Example for ConnectWise Automate: +# Set-ItemProperty -Path "HKLM:\SOFTWARE\LabTech\Service" -Name "BitLockerVolumes" -Value $actionsSummary.VolumesEncrypted +# Set-ItemProperty -Path "HKLM:\SOFTWARE\LabTech\Service" -Name "BitLockerPasswords" -Value $recoveryPasswordsFormatted +# +# Example for Datto RMM: +# Write-Host "<-Start Result->" +# Write-Host "ENCRYPTED_VOLUMES: $($actionsSummary.VolumesEncrypted)" +# Write-Host "RECOVERY_PASSWORDS: $recoveryPasswordsFormatted" +# Write-Host "<-End Result->" +# +# NOTE: Some RMM platforms have character limits on custom fields (often 10,000-50,000 chars). +# Recovery passwords are 48 chars each plus formatting, so plan accordingly for systems +# with many volumes. Consider using a secure note/documentation field for very large datasets. +### ————— END RMM OUTPUT TUNNEL ————— + +Stop-Transcript \ No newline at end of file diff --git a/msft-windows/msft-win11-upgrade.ps1 b/msft-windows/msft-win11-upgrade.ps1 index a5aa50b..08ee098 100644 --- a/msft-windows/msft-win11-upgrade.ps1 +++ b/msft-windows/msft-win11-upgrade.ps1 @@ -52,14 +52,28 @@ Write-Host "RMM: $RMM" <# .SYNOPSIS - Quiet in-place upgrade Win10 → Win11 with staged NinjaRMM (or host) progress. + Fully automated in-place upgrade Win10 → Win11 with pre-flight checks and enhanced setup flags. .DESCRIPTION + Optimized for RMM execution under SYSTEM with minimal user interaction. + Reports progress at 0 %, 25 %, 50 %, 75 %: 0 % – script start 25 % – download beginning 50 % – download finished & ISO mounted 75 % – setup.exe launched + + Pre-flight checks: + - Validates 20GB minimum free disk space + - Checks for pending reboots + - Cleans temporary files + + Enhanced setup.exe arguments for automation: + - /DynamicUpdate Disable (prevents mid-upgrade hangs) + - /Compat IgnoreWarning (bypasses compatibility warnings) + - /ShowOOBE None (skips post-upgrade setup screens) + - /quiet (minimizes UI interactions) + Then exits, letting Windows Setup reboot and complete the upgrade. #> @@ -136,6 +150,36 @@ if ([string]::IsNullOrWhiteSpace($isoUrl)) { exit 1 } +### ————— PRE-FLIGHT CHECKS ————— +Write-Output "Running pre-flight checks..." + +# Check free disk space (need 20GB minimum for upgrade) +$systemDrive = $env:SystemDrive +$disk = Get-PSDrive -Name $systemDrive.TrimEnd(':') +$freeSpaceGB = [math]::Round($disk.Free / 1GB, 2) +Write-Output "Free disk space on ${systemDrive}: ${freeSpaceGB}GB" +if ($freeSpaceGB -lt 20) { + Write-Error "CRITICAL: Insufficient disk space. ${freeSpaceGB}GB free, but 20GB minimum required for upgrade." + Show-Progress -Percent 0 -Stage "InsufficientDiskSpace" + Stop-Transcript + exit 1 +} + +# Check for pending reboots +$pendingReboot = Test-Path "HKLM:\SOFTWARE\Microsoft\Windows\CurrentVersion\Component Based Servicing\RebootPending" +if ($pendingReboot) { + Write-Warning "WARNING: Pending reboot detected. This may interfere with upgrade. Consider rebooting first." +} + +# Clean temporary files to free up space +Write-Output "Cleaning temporary files to free up disk space..." +try { + Remove-Item "$env:TEMP\*" -Recurse -Force -ErrorAction SilentlyContinue + Write-Output "Temporary files cleaned successfully" +} catch { + Write-Output "Warning: Could not clean all temporary files: $_" +} + ### ————— DOWNLOAD ISO (Using BITS for speed + compatibility) ————— Show-Progress -Percent 25 -Stage "Downloading" Write-Output "Downloading ISO from $isoUrl..." @@ -167,20 +211,55 @@ Show-Progress -Percent 50 -Stage "DownloadedAndMounted" ### ————— LAUNCH SETUP ————— Show-Progress -Percent 75 -Stage "SetupStart" -# Build argument list based on forceUpgrade flag -$setupArgs = @("/auto Upgrade", "/eula accept") +# Build comprehensive argument list for fully automated upgrade +$setupArgs = @( + "/auto Upgrade", # Automated upgrade mode + "/eula accept", # Accept EULA automatically + "/quiet", # Minimize UI interactions + "/DynamicUpdate Disable", # Prevent downloading updates during setup (common hang point) + "/Compat IgnoreWarning", # Bypass compatibility warnings + "/ShowOOBE None", # Skip Out-of-Box Experience after upgrade + "/Telemetry Enable" # Enable telemetry (sometimes required for upgrade) +) + +# Add /product server flag if force upgrade is enabled if ($forceUpgrade -eq 1) { $setupArgs += "/product server" - Write-Output "Launching Windows 11 setup (quiet, no reboot) with /product server flag..." + Write-Output "Launching Windows 11 setup with /product server flag (fully automated)..." } else { - Write-Output "Launching Windows 11 setup (quiet, no reboot)..." + Write-Output "Launching Windows 11 setup (fully automated)..." } -Start-Process ` - -FilePath "$driveLetter\setup.exe" ` - -ArgumentList $setupArgs +Write-Output "Setup arguments: $($setupArgs -join ' ')" + +# Launch setup.exe with high priority and hidden window +try { + $setupProcess = Start-Process ` + -FilePath "$driveLetter\setup.exe" ` + -ArgumentList $setupArgs ` + -WindowStyle Hidden ` + -PassThru ` + -ErrorAction Stop + + # Wait 10 seconds to confirm setup process started successfully + Start-Sleep -Seconds 10 + + if ($setupProcess.HasExited) { + Write-Error "CRITICAL: Setup.exe exited prematurely (Exit Code: $($setupProcess.ExitCode)). Check logs for details." + Show-Progress -Percent 0 -Stage "SetupFailed" + Stop-Transcript + exit 1 + } else { + Write-Output "Setup process confirmed running (PID: $($setupProcess.Id))" + Write-Output "Setup launched successfully; the machine will reboot and complete the upgrade." + } +} catch { + Write-Error "Failed to launch setup.exe: $_" + Show-Progress -Percent 0 -Stage "SetupLaunchFailed" + Stop-Transcript + exit 1 +} -Write-Output "Setup launched; the machine will reboot and complete the upgrade." # script ends here; Setup handles reboot & ISO cleanup Stop-Transcript diff --git a/msft-windows/msft-windows-enable-bitlocker.ps1 b/msft-windows/msft-windows-enable-bitlocker.ps1 new file mode 100644 index 0000000..0bf37f0 --- /dev/null +++ b/msft-windows/msft-windows-enable-bitlocker.ps1 @@ -0,0 +1,941 @@ +## 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 +## $RMM +## $Description + +### ————— MSP RMM VARIABLE INITIALIZATION GOES HERE ————— +# Example for NinjaRMM: +# $RMM = 1 +# $Description = Ninja custom field or automatic +# +# Example for ConnectWise Automate: +# $RMM = 1 +# $Description = %description% +# +# Example for Datto RMM: +# $RMM = 1 +# $Description = $env:description +### ————— END RMM VARIABLE INITIALIZATION ————— + +# Getting input from user if not running from RMM else set variables from RMM. + +$ScriptLogName = "msft-windows-enable-bitlocker.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." + } + } + + $LogPath = "$ENV:WINDIR\logs\$ScriptLogName" + +} else { + # Store the logs in the 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 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" + +<# +.SYNOPSIS + Enables BitLocker with TPM on OS volume and auto-unlock on data volumes. + +.DESCRIPTION + This script enables BitLocker encryption on all fixed drives with the following features: + + Features: + - Validates TPM presence and readiness before attempting encryption + - Enables BitLocker using TPM on OS volume for automatic unlock (no user interaction at boot) + - Enables BitLocker with automatic unlock on data volumes (unlocked when OS boots) + - Automatically creates BOTH recovery passwords (48-digit) AND recovery keys (.BEK) for ALL volumes + - Backs up BOTH passwords and keys to Active Directory (domain-joined systems) + - Stores recovery passwords in RMM custom fields for quick access + - Configures registry settings for automatic AD backup (domain-joined systems) + - Provides clear, user-friendly output showing status of each volume + - Generates summary report of actions taken + + TPM Requirements: + - TPM 1.2 or 2.0 must be present and enabled in BIOS/UEFI + - TPM must be ready (initialized and owned) + - If no TPM is available, script exits without enabling BitLocker + + Encryption Strategy: + - OS Volume: TPM protector (automatic unlock at boot) + - Data Volumes: Password protector + Automatic unlock (unlocked when OS boots) + - All Volumes: Recovery password + recovery key protectors + + Recovery Protector Types Created: + - Recovery Password: 48-digit numerical password (e.g., 123456-789012-...) + - Recovery Key: 256-bit .BEK file stored in Active Directory + + Storage Locations: + - Active Directory: Both passwords AND keys for ALL volumes (domain-joined systems only) + - RMM Custom Fields: Passwords only for ALL volumes (for quick access) + - Transcript Log: Full execution details including all passwords for ALL volumes + + The script is safe to run multiple times - it will skip volumes that are already encrypted. + +.NOTES + Author: Nathaniel Smith / Claude Code + Requires: Administrator privileges + Requires: TPM 1.2 or 2.0 present and ready + Requires: Windows 10/11 or Windows Server 2016+ +#> + +Write-Output "`n==========================================" +Write-Output "BITLOCKER ENABLEMENT WITH TPM" +Write-Output "==========================================`n" + +### ————— HELPER FUNCTIONS ————— + +function Test-DomainJoined { + <# + .SYNOPSIS + Checks if the system is joined to an Active Directory domain. + #> + try { + $result = Test-ComputerSecureChannel -ErrorAction Stop + return $result + } catch { + return $false + } +} + +function Test-TPMPresent { + <# + .SYNOPSIS + Checks if TPM is present and ready for BitLocker. + #> + try { + $tpm = Get-WmiObject -Namespace "Root\CIMv2\Security\MicrosoftTpm" -Class Win32_Tpm -ErrorAction Stop + + if ($null -eq $tpm) { + return @{ Present = $false; Ready = $false; Message = "No TPM found" } + } + + $isEnabled = $tpm.IsEnabled().IsEnabled + $isActivated = $tpm.IsActivated().IsActivated + $isOwned = $tpm.IsOwned().IsOwned + + if ($isEnabled -and $isActivated -and $isOwned) { + return @{ Present = $true; Ready = $true; Message = "TPM is present and ready" } + } else { + $issues = @() + if (-not $isEnabled) { $issues += "not enabled" } + if (-not $isActivated) { $issues += "not activated" } + if (-not $isOwned) { $issues += "not owned" } + return @{ Present = $true; Ready = $false; Message = "TPM is present but $($issues -join ', ')" } + } + } catch { + return @{ Present = $false; Ready = $false; Message = "Could not query TPM: $_" } + } +} + +function Get-VolumeEncryptionStatus { + <# + .SYNOPSIS + Gets the BitLocker encryption status for a specific volume. + #> + param( + [Parameter(Mandatory = $true)] + [string]$MountPoint + ) + + try { + $volume = Get-BitLockerVolume -MountPoint $MountPoint -ErrorAction Stop + + # Check for existing protector types + $hasTpmProtector = ($volume.KeyProtector | Where-Object { $_.KeyProtectorType -eq 'Tpm' }) -ne $null + $hasPasswordProtector = ($volume.KeyProtector | Where-Object { $_.KeyProtectorType -eq 'Password' }) -ne $null + + return @{ + IsProtected = ($volume.ProtectionStatus -eq "On") + EncryptionPercentage = $volume.EncryptionPercentage + VolumeStatus = $volume.VolumeStatus + KeyProtectors = $volume.KeyProtector + HasTpmProtector = $hasTpmProtector + HasPasswordProtector = $hasPasswordProtector + } + } catch { + Write-Warning "Could not get BitLocker status for $MountPoint : $_" + return $null + } +} + +function Enable-BitLockerWithTPM { + <# + .SYNOPSIS + Enables BitLocker on OS volume using TPM as the key protector. + #> + param( + [Parameter(Mandatory = $true)] + [string]$MountPoint + ) + + try { + # Check if TPM protector already exists + $volume = Get-BitLockerVolume -MountPoint $MountPoint -ErrorAction Stop + $hasTpmProtector = ($volume.KeyProtector | Where-Object { $_.KeyProtectorType -eq 'Tpm' }) -ne $null + + if ($hasTpmProtector) { + Write-Output " ℹ TPM protector already exists, skipping TPM protector creation" + Write-Output " → Starting BitLocker encryption (if not already started)..." + + # Try to resume or start protection + try { + Resume-BitLocker -MountPoint $MountPoint -ErrorAction SilentlyContinue | Out-Null + } catch { + # Resume might fail if already enabled, that's okay + } + + Write-Output " ✓ BitLocker encryption in progress with existing TPM protector" + return @{ Success = $true; AlreadyConfigured = $true } + } + + Write-Output " → Enabling BitLocker with TPM protector (OS Volume)..." + + # Enable BitLocker with TPM + Enable-BitLocker -MountPoint $MountPoint -EncryptionMethod XtsAes256 -TpmProtector -SkipHardwareTest -ErrorAction Stop + + Write-Output " ✓ BitLocker enabled successfully with TPM" + return @{ Success = $true; AlreadyConfigured = $false } + } catch { + Write-Warning " ✗ Failed to enable BitLocker: $_" + return @{ Success = $false; Error = $_.Exception.Message } + } +} + +function Enable-BitLockerWithAutoUnlock { + <# + .SYNOPSIS + Enables BitLocker on a data volume using password protector and enables automatic unlock. + #> + param( + [Parameter(Mandatory = $true)] + [string]$MountPoint + ) + + try { + # Check if password protector already exists + $volume = Get-BitLockerVolume -MountPoint $MountPoint -ErrorAction Stop + $hasPasswordProtector = ($volume.KeyProtector | Where-Object { $_.KeyProtectorType -eq 'Password' }) -ne $null + + if ($hasPasswordProtector) { + Write-Output " ℹ Password protector already exists, skipping password protector creation" + Write-Output " → Starting BitLocker encryption (if not already started)..." + + # Try to resume protection + try { + Resume-BitLocker -MountPoint $MountPoint -ErrorAction SilentlyContinue | Out-Null + } catch { + # Resume might fail if already enabled, that's okay + } + + # Try to enable auto-unlock if not already enabled + Write-Output " → Enabling automatic unlock..." + try { + Enable-BitLockerAutoUnlock -MountPoint $MountPoint -ErrorAction Stop + Write-Output " ✓ Automatic unlock enabled" + } catch { + Write-Output " ℹ Automatic unlock already enabled or not applicable" + } + + Write-Output " ✓ BitLocker encryption in progress with existing password protector" + return @{ Success = $true; AlreadyConfigured = $true } + } + + Write-Output " → Enabling BitLocker with password protector (Data Volume)..." + + # Enable BitLocker with a password protector (will be hidden by auto-unlock) + # We use a random password that will be managed by auto-unlock + $password = ConvertTo-SecureString -String ([System.Guid]::NewGuid().ToString()) -AsPlainText -Force + Enable-BitLocker -MountPoint $MountPoint -EncryptionMethod XtsAes256 -PasswordProtector -Password $password -SkipHardwareTest -ErrorAction Stop + + Write-Output " ✓ BitLocker enabled successfully" + + # Enable automatic unlock (relies on OS volume being encrypted) + Write-Output " → Enabling automatic unlock..." + Enable-BitLockerAutoUnlock -MountPoint $MountPoint -ErrorAction Stop + Write-Output " ✓ Automatic unlock enabled" + + return @{ Success = $true; AlreadyConfigured = $false } + } catch { + Write-Warning " ✗ Failed to enable BitLocker with auto-unlock: $_" + return @{ Success = $false; Error = $_.Exception.Message } + } +} + +function New-RecoveryPassword { + <# + .SYNOPSIS + Creates a new recovery password (48-digit) for a BitLocker volume. + #> + param( + [Parameter(Mandatory = $true)] + [string]$MountPoint + ) + + try { + Write-Output " → Creating recovery password..." + Add-BitLockerKeyProtector -MountPoint $MountPoint -RecoveryPasswordProtector -ErrorAction Stop + Write-Output " ✓ Recovery password created successfully" + return @{ Success = $true } + } catch { + Write-Warning " ✗ Failed to create recovery password: $_" + return @{ Success = $false; Error = $_.Exception.Message } + } +} + +function New-RecoveryKey { + <# + .SYNOPSIS + Creates a new recovery key (.BEK file) for a BitLocker volume. + #> + param( + [Parameter(Mandatory = $true)] + [string]$MountPoint + ) + + try { + Write-Output " → Creating recovery key..." + Add-BitLockerKeyProtector -MountPoint $MountPoint -RecoveryKeyProtector -RecoveryKeyPath "$env:TEMP" -ErrorAction Stop + Write-Output " ✓ Recovery key created successfully" + return @{ Success = $true } + } catch { + Write-Warning " ✗ Failed to create recovery key: $_" + return @{ Success = $false; Error = $_.Exception.Message } + } +} + +function Get-RecoveryProtectorStatus { + <# + .SYNOPSIS + Checks if recovery passwords and recovery keys exist for a volume. + #> + param( + [Parameter(Mandatory = $true)] + [string]$MountPoint + ) + + try { + $volume = Get-BitLockerVolume -MountPoint $MountPoint -ErrorAction Stop + $recoveryPasswords = $volume.KeyProtector | Where-Object { $_.KeyProtectorType -eq 'RecoveryPassword' } + $recoveryKeys = $volume.KeyProtector | Where-Object { $_.KeyProtectorType -eq 'ExternalKey' } + + return @{ + Passwords = @{ + Exists = ($null -ne $recoveryPasswords) + Count = @($recoveryPasswords).Count + Values = $recoveryPasswords.RecoveryPassword + KeyProtectorIds = $recoveryPasswords.KeyProtectorId + } + Keys = @{ + Exists = ($null -ne $recoveryKeys) + Count = @($recoveryKeys).Count + KeyProtectorIds = $recoveryKeys.KeyProtectorId + } + } + } catch { + Write-Warning "Could not check recovery protector status for $MountPoint : $_" + return $null + } +} + +function Backup-RecoveryProtectorsToAD { + <# + .SYNOPSIS + Backs up all recovery passwords and keys for a volume to Active Directory. + #> + param( + [Parameter(Mandatory = $true)] + [string]$MountPoint + ) + + $isDomainJoined = Test-DomainJoined + + if (-not $isDomainJoined) { + Write-Output " ℹ System is not domain-joined, skipping AD backup" + return @{ Skipped = $true; Reason = "Not domain-joined" } + } + + try { + $protectorStatus = Get-RecoveryProtectorStatus -MountPoint $MountPoint + + if (-not $protectorStatus.Passwords.Exists -and -not $protectorStatus.Keys.Exists) { + Write-Warning " ✗ No recovery protectors found to backup" + return @{ Success = $false; Reason = "No recovery protectors" } + } + + $totalProtectors = $protectorStatus.Passwords.Count + $protectorStatus.Keys.Count + Write-Output " → Backing up $totalProtectors recovery protector(s) to Active Directory..." + + $successCount = 0 + + # Backup recovery passwords + if ($protectorStatus.Passwords.Exists) { + foreach ($keyId in $protectorStatus.Passwords.KeyProtectorIds) { + try { + Backup-BitLockerKeyProtector -MountPoint $MountPoint -KeyProtectorId $keyId -ErrorAction Stop + $successCount++ + } catch { + Write-Warning " ✗ Failed to backup password protector $keyId : $_" + } + } + } + + # Backup recovery keys + if ($protectorStatus.Keys.Exists) { + foreach ($keyId in $protectorStatus.Keys.KeyProtectorIds) { + try { + Backup-BitLockerKeyProtector -MountPoint $MountPoint -KeyProtectorId $keyId -ErrorAction Stop + $successCount++ + } catch { + Write-Warning " ✗ Failed to backup key protector $keyId : $_" + } + } + } + + if ($successCount -gt 0) { + Write-Output " ✓ Backed up $successCount recovery protector(s) to AD" + return @{ Success = $true; Count = $successCount } + } else { + Write-Warning " ✗ Failed to backup any recovery protectors" + return @{ Success = $false } + } + + } catch { + Write-Warning " ✗ Error during AD backup: $_" + return @{ Success = $false; Error = $_.Exception.Message } + } +} + +function Set-BitLockerADBackupPolicy { + <# + .SYNOPSIS + Configures registry settings to enable automatic BitLocker recovery key backup to Active Directory. + #> + param ( + [switch]$Force + ) + + Write-Output "`nConfiguring BitLocker AD backup policy..." + + $registryPath = "HKLM:\SOFTWARE\Policies\Microsoft\FVE" + + # Registry values for AD backup configuration + $registryValues = @{ + "ActiveDirectoryBackup" = 1 + "ActiveDirectoryInfoToStore" = 1 + "RequireActiveDirectoryBackup" = 1 + "OSActiveDirectoryBackup" = 1 + "OSActiveDirectoryInfoToStore" = 1 + "OSRecovery" = 1 + "OSRecoveryPassword" = 1 + "OSRecoveryKey" = 2 + "OSHideRecoveryPage" = 1 + "OSManageDRA" = 1 + "OSRequireActiveDirectoryBackup" = 1 + "FDVActiveDirectoryBackup" = 1 + "FDVActiveDirectoryInfoToStore" = 1 + "FDVRecovery" = 1 + "FDVRecoveryPassword" = 1 + "FDVRecoveryKey" = 2 + "FDVHideRecoveryPage" = 1 + "FDVManageDRA" = 1 + "FDVRequireActiveDirectoryBackup" = 1 + "RDVActiveDirectoryBackup" = 1 + "RDVActiveDirectoryInfoToStore" = 1 + "RDVRecovery" = 1 + "RDVRecoveryPassword" = 1 + "RDVRecoveryKey" = 2 + "RDVHideRecoveryPage" = 1 + "RDVManageDRA" = 1 + "RDVRequireActiveDirectoryBackup" = 1 + } + + # Create registry path if it doesn't exist + if (-not (Test-Path $registryPath)) { + try { + New-Item -Path $registryPath -Force -ErrorAction Stop | Out-Null + Write-Output " → Created registry path: $registryPath" + } catch { + Write-Warning " ✗ Failed to create registry path: $_" + return $false + } + } + + # Set each registry value + $setCount = 0 + $skipCount = 0 + + foreach ($name in $registryValues.Keys) { + $value = $registryValues[$name] + + try { + $existingValue = Get-ItemProperty -Path $registryPath -Name $name -ErrorAction SilentlyContinue + + if ($null -eq $existingValue -or $Force) { + New-ItemProperty -Path $registryPath -Name $name -Value $value -PropertyType DWORD -Force -ErrorAction Stop | Out-Null + $setCount++ + } else { + $skipCount++ + } + } catch { + Write-Warning " ✗ Failed to set $name : $_" + } + } + + Write-Output " ✓ Set $setCount registry value(s), skipped $skipCount existing value(s)" + return $true +} + +### ————— MAIN SCRIPT LOGIC ————— + +# Check if running with admin privileges +$isAdmin = ([Security.Principal.WindowsPrincipal] [Security.Principal.WindowsIdentity]::GetCurrent()).IsInRole([Security.Principal.WindowsBuiltInRole] "Administrator") + +if (-not $isAdmin) { + Write-Error "This script requires administrator privileges. Please run as administrator." + Stop-Transcript + exit 1 +} + +# Check TPM presence and readiness +Write-Output "Checking TPM availability..." +$tpmStatus = Test-TPMPresent + +Write-Output " TPM Status: $($tpmStatus.Message)" + +if (-not $tpmStatus.Present) { + Write-Error "TPM not found. BitLocker with TPM requires a Trusted Platform Module. Exiting without enabling BitLocker." + Stop-Transcript + exit 1 +} + +if (-not $tpmStatus.Ready) { + Write-Error "TPM is not ready. Please enable and initialize TPM in BIOS/UEFI settings. Exiting without enabling BitLocker." + Stop-Transcript + exit 1 +} + +Write-Output " ✓ TPM is ready for BitLocker encryption" + +# Check domain join status +$isDomainJoined = Test-DomainJoined + +if ($isDomainJoined) { + Write-Output "`nSystem Status: Domain-joined (AD backup available)" + Set-BitLockerADBackupPolicy -Force +} else { + Write-Output "`nSystem Status: Not domain-joined (AD backup unavailable)" +} + +Write-Output "`n==========================================" +Write-Output "SCANNING VOLUMES FOR ENCRYPTION" +Write-Output "==========================================`n" + +# Get all fixed data volumes +try { + $allVolumes = Get-Volume -ErrorAction Stop | Where-Object { $_.DriveType -eq 'Fixed' -and $null -ne $_.DriveLetter } +} catch { + Write-Error "Failed to enumerate volumes: $_" + Stop-Transcript + exit 1 +} + +if ($allVolumes.Count -eq 0) { + Write-Output "No fixed volumes found on this system." + Stop-Transcript + exit 0 +} + +# Identify OS volume +$osVolume = $allVolumes | Where-Object { $_.DriveLetter -eq $env:SystemDrive.Trim(':') } +$dataVolumes = $allVolumes | Where-Object { $_.DriveLetter -ne $env:SystemDrive.Trim(':') } + +if ($null -eq $osVolume) { + Write-Error "Could not identify OS volume. Exiting." + Stop-Transcript + exit 1 +} + +Write-Output "Found OS Volume: $($osVolume.DriveLetter):" +if ($dataVolumes.Count -gt 0) { + Write-Output "Found $($dataVolumes.Count) data volume(s): $(($dataVolumes | ForEach-Object { "$($_.DriveLetter):" }) -join ', ')" +} +Write-Output "" + +# Track actions taken +$actionsSummary = @{ + VolumesScanned = 0 + VolumesEncrypted = 0 + VolumesAlreadyEncrypted = 0 + RecoveryProtectorsCreated = 0 + RecoveryProtectorsBackedUp = 0 + Errors = 0 +} + +# Collect all recovery passwords for RMM storage +$allRecoveryPasswords = @{} + +# Variable to track if OS volume is encrypted (needed for auto-unlock) +$osVolumeEncrypted = $false + +### ————— PROCESS OS VOLUME FIRST ————— + +Write-Output "==========================================" +Write-Output "STEP 1: PROCESS OS VOLUME" +Write-Output "==========================================`n" + +$mountPoint = "$($osVolume.DriveLetter):" +$actionsSummary.VolumesScanned++ + +Write-Output "—————————————————————————————————————————" +Write-Output "Volume: $mountPoint ($($osVolume.FileSystemLabel)) [OS VOLUME]" +Write-Output "—————————————————————————————————————————" + +# Get encryption status +$encryptionStatus = Get-VolumeEncryptionStatus -MountPoint $mountPoint + +if ($null -eq $encryptionStatus) { + Write-Error "Could not determine encryption status for OS volume. Cannot continue." + Stop-Transcript + exit 1 +} + +# Check if already encrypted or has TPM protector +if ($encryptionStatus.IsProtected) { + Write-Output " Status: BitLocker is ALREADY ENABLED" + Write-Output " Encryption: $($encryptionStatus.EncryptionPercentage)%" + Write-Output " Volume Status: $($encryptionStatus.VolumeStatus)" + $actionsSummary.VolumesAlreadyEncrypted++ + $osVolumeEncrypted = $true +} elseif ($encryptionStatus.HasTpmProtector) { + # Has TPM protector but not fully enabled yet (encryption in progress) + Write-Output " Status: BitLocker encryption IN PROGRESS" + Write-Output " Encryption: $($encryptionStatus.EncryptionPercentage)%" + Write-Output " Volume Status: $($encryptionStatus.VolumeStatus)" + Write-Output " ℹ TPM protector detected - continuing with existing configuration" + $actionsSummary.VolumesAlreadyEncrypted++ + $osVolumeEncrypted = $true +} else { + # Enable BitLocker with TPM on OS volume + Write-Output " Status: BitLocker is NOT enabled" + $enableResult = Enable-BitLockerWithTPM -MountPoint $mountPoint + + if (-not $enableResult.Success) { + Write-Error "Failed to enable BitLocker on OS volume. Cannot continue with data volumes." + Stop-Transcript + exit 1 + } + + $actionsSummary.VolumesEncrypted++ + $osVolumeEncrypted = $true +} + +# Check for existing recovery protectors first +$protectorStatus = Get-RecoveryProtectorStatus -MountPoint $mountPoint + +# Create recovery protectors only if missing +Write-Output " → Checking recovery protectors..." + +if (-not $protectorStatus.Passwords.Exists) { + Write-Output " → Creating recovery password..." + $passwordResult = New-RecoveryPassword -MountPoint $mountPoint + if ($passwordResult.Success) { + $actionsSummary.RecoveryProtectorsCreated++ + } else { + $actionsSummary.Errors++ + } +} else { + Write-Output " ℹ Recovery password already exists ($($protectorStatus.Passwords.Count) found)" +} + +if (-not $protectorStatus.Keys.Exists) { + Write-Output " → Creating recovery key..." + $keyResult = New-RecoveryKey -MountPoint $mountPoint + if ($keyResult.Success) { + $actionsSummary.RecoveryProtectorsCreated++ + } else { + $actionsSummary.Errors++ + } +} else { + Write-Output " ℹ Recovery key already exists ($($protectorStatus.Keys.Count) found)" +} + +# Refresh protector status after creation +$protectorStatus = Get-RecoveryProtectorStatus -MountPoint $mountPoint + +# Store recovery passwords for RMM (passwords only, not keys) +if ($protectorStatus.Passwords.Exists) { + $allRecoveryPasswords[$mountPoint] = $protectorStatus.Passwords.Values +} + +# Backup to AD if domain-joined (backup both passwords and keys) +if ($isDomainJoined -and ($protectorStatus.Passwords.Exists -or $protectorStatus.Keys.Exists)) { + $backupResult = Backup-RecoveryProtectorsToAD -MountPoint $mountPoint + + if ($backupResult.Success) { + $actionsSummary.RecoveryProtectorsBackedUp += $backupResult.Count + } elseif (-not $backupResult.Skipped) { + $actionsSummary.Errors++ + } +} + +Write-Output "" + +### ————— PROCESS DATA VOLUMES ————— + +if ($dataVolumes.Count -gt 0) { + Write-Output "==========================================" + Write-Output "STEP 2: PROCESS DATA VOLUMES" + Write-Output "==========================================`n" + + foreach ($vol in $dataVolumes) { + $mountPoint = "$($vol.DriveLetter):" + $actionsSummary.VolumesScanned++ + + Write-Output "—————————————————————————————————————————" + Write-Output "Volume: $mountPoint ($($vol.FileSystemLabel)) [DATA VOLUME]" + Write-Output "—————————————————————————————————————————" + + # Get encryption status + $encryptionStatus = Get-VolumeEncryptionStatus -MountPoint $mountPoint + + if ($null -eq $encryptionStatus) { + Write-Warning " ✗ Could not determine encryption status, skipping" + $actionsSummary.Errors++ + Write-Output "" + continue + } + + # Check if already encrypted or has password protector + if ($encryptionStatus.IsProtected) { + Write-Output " Status: BitLocker is ALREADY ENABLED" + Write-Output " Encryption: $($encryptionStatus.EncryptionPercentage)%" + Write-Output " Volume Status: $($encryptionStatus.VolumeStatus)" + $actionsSummary.VolumesAlreadyEncrypted++ + } elseif ($encryptionStatus.HasPasswordProtector) { + # Has password protector but not fully enabled yet (encryption in progress) + Write-Output " Status: BitLocker encryption IN PROGRESS" + Write-Output " Encryption: $($encryptionStatus.EncryptionPercentage)%" + Write-Output " Volume Status: $($encryptionStatus.VolumeStatus)" + Write-Output " ℹ Password protector detected - continuing with existing configuration" + $actionsSummary.VolumesAlreadyEncrypted++ + } else { + # Enable BitLocker with auto-unlock on data volume + Write-Output " Status: BitLocker is NOT enabled" + $enableResult = Enable-BitLockerWithAutoUnlock -MountPoint $mountPoint + + if (-not $enableResult.Success) { + Write-Warning " ✗ Failed to enable BitLocker, skipping this volume" + $actionsSummary.Errors++ + Write-Output "" + continue + } + + $actionsSummary.VolumesEncrypted++ + } + + # Check for existing recovery protectors first + $protectorStatus = Get-RecoveryProtectorStatus -MountPoint $mountPoint + + # Create recovery protectors only if missing + Write-Output " → Checking recovery protectors..." + + if (-not $protectorStatus.Passwords.Exists) { + Write-Output " → Creating recovery password..." + $passwordResult = New-RecoveryPassword -MountPoint $mountPoint + if ($passwordResult.Success) { + $actionsSummary.RecoveryProtectorsCreated++ + } else { + $actionsSummary.Errors++ + } + } else { + Write-Output " ℹ Recovery password already exists ($($protectorStatus.Passwords.Count) found)" + } + + if (-not $protectorStatus.Keys.Exists) { + Write-Output " → Creating recovery key..." + $keyResult = New-RecoveryKey -MountPoint $mountPoint + if ($keyResult.Success) { + $actionsSummary.RecoveryProtectorsCreated++ + } else { + $actionsSummary.Errors++ + } + } else { + Write-Output " ℹ Recovery key already exists ($($protectorStatus.Keys.Count) found)" + } + + # Refresh protector status after creation + $protectorStatus = Get-RecoveryProtectorStatus -MountPoint $mountPoint + + # Store recovery passwords for RMM (passwords only, not keys) + if ($protectorStatus.Passwords.Exists) { + $allRecoveryPasswords[$mountPoint] = $protectorStatus.Passwords.Values + } + + # Backup to AD if domain-joined (backup both passwords and keys) + if ($isDomainJoined -and ($protectorStatus.Passwords.Exists -or $protectorStatus.Keys.Exists)) { + $backupResult = Backup-RecoveryProtectorsToAD -MountPoint $mountPoint + + if ($backupResult.Success) { + $actionsSummary.RecoveryProtectorsBackedUp += $backupResult.Count + } elseif (-not $backupResult.Skipped) { + $actionsSummary.Errors++ + } + } + + Write-Output "" + } +} else { + Write-Output "No data volumes found. Skipping Step 2.`n" +} + +### ————— SUMMARY REPORT ————— + +Write-Output "==========================================" +Write-Output "SUMMARY REPORT" +Write-Output "==========================================`n" + +Write-Output "Volumes scanned: $($actionsSummary.VolumesScanned)" +Write-Output "Volumes already encrypted: $($actionsSummary.VolumesAlreadyEncrypted)" +Write-Output "Volumes newly encrypted: $($actionsSummary.VolumesEncrypted)" +Write-Output "Recovery protectors created: $($actionsSummary.RecoveryProtectorsCreated)" + +if ($isDomainJoined) { + Write-Output "Protectors backed up to AD: $($actionsSummary.RecoveryProtectorsBackedUp)" +} + +Write-Output "Errors encountered: $($actionsSummary.Errors)" + +Write-Output "`n==========================================" + +if ($actionsSummary.VolumesEncrypted -gt 0) { + Write-Output "✓ Successfully enabled BitLocker on $($actionsSummary.VolumesEncrypted) volume(s) with TPM" +} + +if ($actionsSummary.RecoveryProtectorsCreated -gt 0) { + Write-Output "✓ Successfully created $($actionsSummary.RecoveryProtectorsCreated) recovery protector(s) (passwords and keys)" +} + +if ($actionsSummary.RecoveryProtectorsBackedUp -gt 0) { + Write-Output "✓ Successfully backed up $($actionsSummary.RecoveryProtectorsBackedUp) protector(s) to Active Directory" +} + +if ($actionsSummary.Errors -eq 0) { + Write-Output "✓ All operations completed successfully" +} else { + Write-Warning "⚠ Completed with $($actionsSummary.Errors) error(s) - review log for details" +} + +Write-Output "==========================================" + +### ————— FORMAT RECOVERY PASSWORDS FOR RMM ————— + +# Format all recovery passwords into a string for RMM storage +$recoveryPasswordsFormatted = "" + +if ($allRecoveryPasswords.Count -gt 0) { + Write-Output "`nFormatting recovery passwords for RMM storage..." + + $passwordLines = @() + foreach ($mountPoint in ($allRecoveryPasswords.Keys | Sort-Object)) { + $passwords = $allRecoveryPasswords[$mountPoint] + + if ($passwords -is [array]) { + # Multiple passwords for this volume + for ($i = 0; $i -lt $passwords.Count; $i++) { + if ($passwords.Count -gt 1) { + $passwordLines += "$mountPoint (Password $($i + 1)): $($passwords[$i])" + } else { + $passwordLines += "$mountPoint $($passwords[$i])" + } + } + } else { + # Single password + $passwordLines += "$mountPoint $passwords" + } + } + + $recoveryPasswordsFormatted = $passwordLines -join " | " + + Write-Output " ✓ Formatted $($allRecoveryPasswords.Count) volume(s) with recovery passwords" + Write-Output "`nRecovery Passwords (for RMM storage):" + Write-Output $recoveryPasswordsFormatted +} + +### ————— TUNNEL OUTPUT VARIABLE TO YOUR RMM HERE ————— +# Available output variables for RMM custom fields: +# +# STATISTICS (numeric values): +# - $actionsSummary.VolumesScanned : Total volumes checked +# - $actionsSummary.VolumesAlreadyEncrypted : Volumes that were already encrypted +# - $actionsSummary.VolumesEncrypted : Volumes newly encrypted +# - $actionsSummary.RecoveryProtectorsCreated : Number of new recovery protectors created +# - $actionsSummary.RecoveryProtectorsBackedUp : Number of protectors backed up to AD +# - $actionsSummary.Errors : Number of errors encountered +# +# RECOVERY PASSWORDS (string - SENSITIVE DATA): +# - $recoveryPasswordsFormatted : All recovery passwords (48-digit) formatted as: +# "C: 123456-789012... | D: 234567-890123..." +# +# IMPORTANT SECURITY NOTES: +# - Recovery passwords are HIGHLY SENSITIVE credentials that can decrypt drives +# - Store them in secure custom fields with restricted access in your RMM platform +# - Recovery KEYS (.BEK files) are stored in Active Directory only, NOT in RMM +# - RMM stores PASSWORDS only for quick access when unlocking systems +# +# STORAGE SUMMARY: +# - Active Directory: Both passwords AND keys (full backup) +# - RMM Custom Fields: Passwords only (quick access) +# - Transcript Log: Full details including all passwords +# +# Example for NinjaRMM: +# if (Get-Command 'Ninja-Property-Set' -ErrorAction SilentlyContinue) { +# Ninja-Property-Set -Name 'bitlockerVolumesEncrypted' -Value $actionsSummary.VolumesEncrypted +# Ninja-Property-Set -Name 'bitlockerProtectorsCreated' -Value $actionsSummary.RecoveryProtectorsCreated +# # NOTE: Create 'bitlockerRecoveryPasswords' as a WYSIWYG or secure text custom field in NinjaRMM +# Ninja-Property-Set -Name 'bitlockerRecoveryPasswords' -Value $recoveryPasswordsFormatted +# } +# +# Example for ConnectWise Automate: +# Set-ItemProperty -Path "HKLM:\SOFTWARE\LabTech\Service" -Name "BitLockerVolumes" -Value $actionsSummary.VolumesEncrypted +# Set-ItemProperty -Path "HKLM:\SOFTWARE\LabTech\Service" -Name "BitLockerPasswords" -Value $recoveryPasswordsFormatted +# +# Example for Datto RMM: +# Write-Host "<-Start Result->" +# Write-Host "ENCRYPTED_VOLUMES: $($actionsSummary.VolumesEncrypted)" +# Write-Host "RECOVERY_PASSWORDS: $recoveryPasswordsFormatted" +# Write-Host "<-End Result->" +# +# NOTE: Encryption will begin in the background. Initial encryption may take several hours +# depending on drive size and system performance. The system remains usable during encryption. +### ————— END RMM OUTPUT TUNNEL ————— + +Stop-Transcript diff --git a/msft-windows/msft-windows-upgrade-diagnostics.ps1 b/msft-windows/msft-windows-upgrade-diagnostics.ps1 index 7454c1c..6e8db5c 100644 --- a/msft-windows/msft-windows-upgrade-diagnostics.ps1 +++ b/msft-windows/msft-windows-upgrade-diagnostics.ps1 @@ -76,25 +76,31 @@ Write-Host "RMM: $RMM" <# .SYNOPSIS - Reads Windows Panther setup error logs and sends them to Anthropic AI for diagnostic analysis. + Reads Windows Panther setup error logs and XML compatibility data, sends to Anthropic AI for diagnostic analysis. .DESCRIPTION - This script detects Windows upgrade/installation errors by reading the Panther logs, - then sends them to Claude (Anthropic AI) for intelligent diagnostic reasoning. + This script detects Windows upgrade/installation errors by reading the Panther logs and XML compatibility + scan results, then sends them to Claude (Anthropic AI) for intelligent diagnostic reasoning. + + Files analyzed: + - setuperr.log: Setup error messages and failure codes + - ScanResult.xml: Compatibility scan summary + - CompatData_*.xml: Detailed hardware/software compatibility assessment data The AI provides: - - Most likely root cause with high confidence - - Detailed reasoning and evidence + - Most likely root cause with high confidence based on logs and XML compatibility data + - Detailed reasoning and evidence including specific compatibility blockers - Actionable next steps to resolve the issue - Brief summary of other possible causes .NOTES Author: Nathaniel Smith / Claude Code Requires: Anthropic API key - Panther Log Locations: - - C:\Windows\Panther\setuperr.log - - C:\Windows\Panther\setupact.log - - C:\$Windows.~BT\Sources\Panther\setuperr.log + Diagnostic File Location: + - C:\$Windows.~BT\Sources\Panther\ + - setuperr.log + - ScanResult.xml + - CompatData_*.xml #> ### ————— VALIDATE REQUIRED VARIABLES ————— @@ -105,52 +111,81 @@ if ([string]::IsNullOrWhiteSpace($anthropicApiKey)) { } ### ————— PANTHER LOG LOCATIONS ————— -$pantherLocations = @( - "$env:SystemRoot\Panther\setuperr.log", - "$env:SystemRoot\Panther\setupact.log", - "$env:SystemDrive\`$Windows.~BT\Sources\Panther\setuperr.log", - "$env:SystemDrive\`$Windows.~BT\Sources\Panther\setupact.log" -) +$pantherDir = "$env:SystemDrive\`$Windows.~BT\Sources\Panther" +$setuperrLog = "$pantherDir\setuperr.log" -Write-Output "Searching for Windows Panther setup logs..." +Write-Output "Searching for Windows upgrade diagnostic files..." -### ————— FIND AND READ PANTHER LOGS ————— +### ————— FIND AND READ PANTHER LOGS AND XML FILES ————— $logContent = "" $foundLogs = @() -foreach ($logPath in $pantherLocations) { - if (Test-Path $logPath) { - Write-Output "Found log: $logPath" - $foundLogs += $logPath +# Read setuperr.log +if (Test-Path $setuperrLog) { + Write-Output "Found log: $setuperrLog" + $foundLogs += $setuperrLog + try { + $content = Get-Content -Path $setuperrLog -Raw -ErrorAction Stop + if ($content) { + $logContent += "`n`n=== SETUP ERROR LOG: $setuperrLog ===`n`n" + $logContent += $content + } + } catch { + Write-Warning "Could not read ${setuperrLog}: $($_)" + } +} + +# Read XML compatibility files (ScanResult.xml and CompatData_*.xml) +if (Test-Path $pantherDir) { + $xmlFiles = @() + + # Look for ScanResult.xml + $scanResultXml = Join-Path $pantherDir "ScanResult.xml" + if (Test-Path $scanResultXml) { + $xmlFiles += $scanResultXml + } + + # Look for CompatData_*.xml files + $compatDataXmls = Get-ChildItem -Path $pantherDir -Filter "CompatData_*.xml" -ErrorAction SilentlyContinue + if ($compatDataXmls) { + $xmlFiles += $compatDataXmls.FullName + } + + # Read XML files + foreach ($xmlPath in $xmlFiles) { + Write-Output "Found XML: $xmlPath" + $foundLogs += $xmlPath try { - $content = Get-Content -Path $logPath -Raw -ErrorAction Stop + $content = Get-Content -Path $xmlPath -Raw -ErrorAction Stop if ($content) { - $logContent += "`n`n=== LOG: $logPath ===`n`n" + $logContent += "`n`n=== COMPATIBILITY DATA: $xmlPath ===`n`n" $logContent += $content } } catch { - Write-Warning "Could not read ${logPath}: $($_)" + Write-Warning "Could not read ${xmlPath}: $($_)" } } } if ($foundLogs.Count -eq 0) { - Write-Output "No Panther logs found. This may indicate:" + Write-Output "No Windows upgrade diagnostic files found in: $pantherDir" + Write-Output "This may indicate:" Write-Output " - No Windows upgrade has been attempted recently" Write-Output " - Upgrade completed successfully without errors" - Write-Output " - Logs have been cleaned up" + Write-Output " - Upgrade has not yet started (files appear during upgrade process)" + Write-Output " - Files have been cleaned up" Stop-Transcript exit 0 } if ([string]::IsNullOrWhiteSpace($logContent)) { - Write-Output "Panther logs exist but are empty or unreadable." + Write-Output "Diagnostic files exist but are empty or unreadable." Stop-Transcript exit 0 } -Write-Output "`nFound $($foundLogs.Count) log file(s) with content." -Write-Output "Total log size: $($logContent.Length) characters" +Write-Output "`nFound $($foundLogs.Count) diagnostic file(s) with content." +Write-Output "Total content size: $($logContent.Length) characters" ### ————— PREPARE API REQUEST ————— Write-Output "`nPreparing diagnostic request to Anthropic AI..." @@ -163,9 +198,13 @@ if ($logContent.Length -gt $maxLogSize) { } $prompt = @" -You are a Windows upgrade diagnostics expert. I need you to analyze Windows Panther setup error logs and provide diagnostic reasoning. +You are a Windows upgrade diagnostics expert. I need you to analyze Windows Panther setup error logs and XML compatibility data to provide diagnostic reasoning. + +ANALYZE THESE WINDOWS SETUP LOGS AND COMPATIBILITY DATA: -ANALYZE THESE WINDOWS SETUP LOGS: +The data includes: +- Setup error logs (setuperr.log) showing upgrade errors and failures +- XML compatibility scan results (ScanResult.xml, CompatData_*.xml) showing hardware/software compatibility assessments $logContent @@ -175,14 +214,16 @@ Please provide: 1. **PRIMARY DIAGNOSIS** (Most Likely Root Cause): - State your highest confidence diagnosis with a confidence level (e.g., "HIGH CONFIDENCE: 85%") - - Provide detailed reasoning based on specific error codes, patterns, and log evidence + - Provide detailed reasoning based on specific error codes, patterns, log evidence, and XML compatibility data - Explain WHY this is the most likely cause - - Quote specific error messages or codes that support this diagnosis + - Quote specific error messages, codes, or XML elements that support this diagnosis + - If XML data shows compatibility blocks, identify the specific hardware/software causing issues 2. **RECOMMENDED ACTIONS** (Next Steps): - Provide 3-5 actionable steps to resolve the primary diagnosis - - Be specific with commands, registry paths, file locations, etc. + - Be specific with commands, registry paths, file locations, driver updates, etc. - Prioritize steps by likelihood of success + - Reference specific compatibility blockers found in XML data 3. **ALTERNATIVE POSSIBILITIES** (Brief): - List 2-3 other possible causes with lower confidence levels diff --git a/oem/dell-command-configure.ps1 b/oem/dell-command-configure.ps1 new file mode 100644 index 0000000..c6608fd --- /dev/null +++ b/oem/dell-command-configure.ps1 @@ -0,0 +1,417 @@ +## 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 +## $dellCommandConfigureURL - URL to Dell Command Configure installer +## $enableWakeOnLan - Set to 1 to enable Wake on LAN in BIOS (default: 1) +## $disableSleep - Set to 1 to disable sleep/standby modes (default: 1) +## $enableSecureBoot - Set to 1 to enable Secure Boot on UEFI systems (default: 0, only works on UEFI not Legacy BIOS) +## $additionalCctkCommands - Optional: comma-separated CCTK commands to run (e.g., "fastboot=thorough,secureboot=disabled") + +# Getting input from user if not running from RMM else set variables from RMM. + +$ScriptLogName = "dell-command-configure.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." + } + } + + # Set default values for interactive mode + if ([string]::IsNullOrEmpty($enableWakeOnLan)) { + $enableWakeOnLan = 1 + } + if ([string]::IsNullOrEmpty($disableSleep)) { + $disableSleep = 1 + } + if ([string]::IsNullOrEmpty($enableSecureBoot)) { + $enableSecureBoot = 0 + } + + $LogPath = "$ENV:WINDIR\logs\$ScriptLogName" + +} else { + # Store the logs in the 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" + } + + # Set default values if not provided by RMM + if ([string]::IsNullOrEmpty($enableWakeOnLan)) { + $enableWakeOnLan = 1 + } + if ([string]::IsNullOrEmpty($disableSleep)) { + $disableSleep = 1 + } + if ([string]::IsNullOrEmpty($enableSecureBoot)) { + $enableSecureBoot = 0 + } +} + +# 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 "Enable Wake on LAN: $enableWakeOnLan" +Write-Host "Disable Sleep: $disableSleep" +Write-Host "Enable Secure Boot: $enableSecureBoot" + +# Function to check if running as administrator +function Test-Administrator { + $currentUser = [Security.Principal.WindowsIdentity]::GetCurrent() + $principal = New-Object Security.Principal.WindowsPrincipal($currentUser) + return $principal.IsInRole([Security.Principal.WindowsBuiltInRole]::Administrator) +} + +# Function to detect Dell hardware +function Test-DellHardware { + try { + $manufacturer = (Get-WmiObject -Class Win32_ComputerSystem).Manufacturer + Write-Host "Detected manufacturer: $manufacturer" + + if ($manufacturer -like "*Dell*") { + return $true + } else { + return $false + } + } catch { + Write-Host "ERROR: Unable to detect manufacturer. $($_.Exception.Message)" + return $false + } +} + +# Function to check if Dell Command Configure is installed +function Test-DellCommandConfigure { + # Check common installation paths + $cctkPaths = @( + "C:\Program Files\Dell\Command Configure\X86_64\cctk.exe", + "C:\Program Files (x86)\Dell\Command Configure\X86_64\cctk.exe", + "C:\Program Files\Dell\Command Configure\cctk.exe" + ) + + foreach ($path in $cctkPaths) { + if (Test-Path -Path $path) { + Write-Host "Dell Command Configure found at: $path" + return $path + } + } + + return $null +} + +# Function to download and install Dell Command Configure +function Install-DellCommandConfigure { + param( + [string]$DownloadURL + ) + + Write-Host "Starting Dell Command Configure installation..." + + # Use default download URL if not provided + if ([string]::IsNullOrEmpty($DownloadURL)) { + # Default to Dell Command Configure 5.2.0 hosted on Backblaze B2 + # Latest version can be downloaded from: https://www.dell.com/support/kbdoc/en-us/000178000/dell-command-configure + Write-Host "No download URL provided, using default Dell Command Configure 5.2.0 installer..." + $DownloadURL = "https://s3.us-west-002.backblazeb2.com/public-dtc/repo/apps/dell/Dell-Command-Configure-Application_MD8CJ_WIN64_5.2.0.9_A00.EXE" + } + + $installerPath = "$env:TEMP\DellCommandConfigure.exe" + + try { + # Download installer + Write-Host "Downloading Dell Command Configure from: $DownloadURL" + Invoke-WebRequest -Uri $DownloadURL -OutFile $installerPath -UseBasicParsing + + if (-not (Test-Path -Path $installerPath)) { + Write-Host "ERROR: Failed to download installer." + return $false + } + + Write-Host "Download complete. Installing..." + + # Install silently + $installProcess = Start-Process -FilePath $installerPath -ArgumentList "/s" -Wait -PassThru -NoNewWindow + + if ($installProcess.ExitCode -eq 0) { + Write-Host "Dell Command Configure installed successfully." + + # Clean up installer + Remove-Item -Path $installerPath -Force -ErrorAction SilentlyContinue + + # Wait a moment for installation to complete + Start-Sleep -Seconds 5 + + return $true + } else { + Write-Host "ERROR: Installation failed with exit code: $($installProcess.ExitCode)" + return $false + } + + } catch { + Write-Host "ERROR: Exception during installation: $($_.Exception.Message)" + return $false + } +} + +# Function to run CCTK command +function Invoke-CctkCommand { + param( + [string]$CctkPath, + [string]$Command + ) + + try { + Write-Host "Running CCTK command: $Command" + $result = & $CctkPath $Command 2>&1 + Write-Host "CCTK Output: $result" + + if ($LASTEXITCODE -eq 0) { + Write-Host "Command executed successfully." + return $true + } else { + Write-Host "WARNING: Command returned exit code: $LASTEXITCODE" + return $false + } + } catch { + Write-Host "ERROR: Failed to execute CCTK command: $($_.Exception.Message)" + return $false + } +} + +# Function to configure Wake on LAN +function Set-WakeOnLan { + param( + [string]$CctkPath, + [int]$Enable + ) + + if ($Enable -eq 1) { + Write-Host "Enabling Wake on LAN..." + + # Enable Wake on LAN in BIOS + Invoke-CctkCommand -CctkPath $CctkPath -Command "--WakeOnLan=LanOnly" + + # Enable Deep Sleep Control to allow WOL during sleep + Invoke-CctkCommand -CctkPath $CctkPath -Command "--DeepSleepCtrl=Disabled" + + # Enable LAN/WLAN switching + Invoke-CctkCommand -CctkPath $CctkPath -Command "--WakeOnLanLanOnly=Enabled" + + Write-Host "Wake on LAN configuration applied." + } else { + Write-Host "Wake on LAN configuration skipped (disabled by variable)." + } +} + +# Function to disable sleep modes +function Set-SleepConfiguration { + param( + [string]$CctkPath, + [int]$DisableSleep + ) + + if ($DisableSleep -eq 1) { + Write-Host "Disabling sleep modes..." + + # Disable Block Sleep (S3 state) + Invoke-CctkCommand -CctkPath $CctkPath -Command "--BlockSleep=Enabled" + + # Disable AC Power Recovery + # Note: This sets the system to stay on after power loss + Invoke-CctkCommand -CctkPath $CctkPath -Command "--AcPwrRcvry=On" + + Write-Host "Sleep configuration applied." + } else { + Write-Host "Sleep configuration skipped (not disabled by variable)." + } +} + +# Function to detect UEFI vs Legacy BIOS boot mode +function Test-UefiBootMode { + try { + # Check if firmware type is UEFI + $firmwareType = $env:firmware_type + if ($firmwareType -eq "UEFI") { + return $true + } + + # Alternative method: Check for EFI system partition + $efiPartition = Get-Partition | Where-Object { $_.GptType -eq '{c12a7328-f81f-11d2-ba4b-00a0c93ec93b}' } + if ($efiPartition) { + Write-Host "Detected UEFI boot mode (EFI system partition found)" + return $true + } + + # Alternative method: Check registry + $secureBootCapable = Get-ItemProperty -Path "HKLM:\System\CurrentControlSet\Control\SecureBoot\State" -Name "UEFISecureBootEnabled" -ErrorAction SilentlyContinue + if ($secureBootCapable) { + Write-Host "Detected UEFI boot mode (SecureBoot registry key found)" + return $true + } + + Write-Host "Detected Legacy BIOS boot mode" + return $false + + } catch { + Write-Host "WARNING: Could not determine boot mode. Assuming Legacy BIOS. Error: $($_.Exception.Message)" + return $false + } +} + +# Function to enable Secure Boot (UEFI only) +function Set-SecureBoot { + param( + [string]$CctkPath, + [int]$Enable + ) + + if ($Enable -eq 1) { + # Check if system is booted in UEFI mode + if (-not (Test-UefiBootMode)) { + Write-Host "WARNING: Secure Boot can only be enabled on UEFI systems." + Write-Host "This system is booted in Legacy BIOS mode. Skipping Secure Boot configuration." + return + } + + Write-Host "System is booted in UEFI mode. Proceeding with Secure Boot configuration..." + + # Enable Secure Boot + Write-Host "Enabling Secure Boot..." + Invoke-CctkCommand -CctkPath $CctkPath -Command "--SecureBoot=Enabled" + + # Set UEFI boot mode (ensure not in Legacy) + Invoke-CctkCommand -CctkPath $CctkPath -Command "--BootMode=Uefi" + + Write-Host "Secure Boot configuration applied." + Write-Host "NOTE: A system reboot is required for Secure Boot changes to take effect." + + } else { + Write-Host "Secure Boot configuration skipped (disabled by variable)." + } +} + +# Function to apply additional custom CCTK commands +function Set-AdditionalCommands { + param( + [string]$CctkPath, + [string]$CommandString + ) + + if (-not [string]::IsNullOrEmpty($CommandString)) { + Write-Host "Applying additional CCTK commands..." + + # Split by comma and process each command + $commands = $CommandString -split ',' + + foreach ($cmd in $commands) { + $cmd = $cmd.Trim() + if (-not [string]::IsNullOrEmpty($cmd)) { + Invoke-CctkCommand -CctkPath $CctkPath -Command "--$cmd" + } + } + + Write-Host "Additional commands applied." + } else { + Write-Host "No additional commands specified." + } +} + +# Main script execution + +# Check if running as administrator +if (-not (Test-Administrator)) { + Write-Host "ERROR: This script must be run as Administrator." + Write-Host "Please run PowerShell as Administrator and try again." + Stop-Transcript + exit 1 +} + +# Check if this is a Dell system +if (-not (Test-DellHardware)) { + Write-Host "This system is not a Dell. Script will exit." + Stop-Transcript + exit 0 +} + +Write-Host "Dell system detected. Proceeding with configuration..." + +# Check if Dell Command Configure is already installed +$cctkPath = Test-DellCommandConfigure + +if ($null -eq $cctkPath) { + Write-Host "Dell Command Configure is not installed." + + # Install Dell Command Configure + $installSuccess = Install-DellCommandConfigure -DownloadURL $dellCommandConfigureURL + + if (-not $installSuccess) { + Write-Host "ERROR: Failed to install Dell Command Configure." + Stop-Transcript + exit 1 + } + + # Check again after installation + $cctkPath = Test-DellCommandConfigure + + if ($null -eq $cctkPath) { + Write-Host "ERROR: Dell Command Configure installation verification failed." + Stop-Transcript + exit 1 + } +} else { + Write-Host "Dell Command Configure is already installed. Skipping installation." +} + +Write-Host "Using CCTK at: $cctkPath" +Write-Host "" +Write-Host "========================================" +Write-Host "Starting BIOS Configuration" +Write-Host "========================================" + +# Apply Wake on LAN settings +Set-WakeOnLan -CctkPath $cctkPath -Enable $enableWakeOnLan + +# Apply Sleep settings +Set-SleepConfiguration -CctkPath $cctkPath -DisableSleep $disableSleep + +# Apply Secure Boot settings (UEFI only) +Set-SecureBoot -CctkPath $cctkPath -Enable $enableSecureBoot + +# Apply any additional custom commands +if (-not [string]::IsNullOrEmpty($additionalCctkCommands)) { + Set-AdditionalCommands -CctkPath $cctkPath -CommandString $additionalCctkCommands +} + +Write-Host "" +Write-Host "========================================" +Write-Host "BIOS Configuration Complete" +Write-Host "========================================" +Write-Host "" +Write-Host "NOTE: Some BIOS changes may require a system reboot to take effect." +Write-Host "Script execution completed successfully." +Write-Host "" + +# Pause for testing +Read-Host "Press Enter to exit" + +Stop-Transcript +exit 0