#requires -version 3 <# .SYNOPSIS Assign the certificate contained in the specified .pfx file, or where an existing certificate subject matches, to ControlUp real time console or monitor machines for authentication purposes. Restarts CU monitor service if present .DESCRIPTION The .pfx file path can also be a folder if it contains only one .pfx file .PARAMETER certificatePath Path to the .pfx certificate file to import. Can be a folder if there is only one .pfx file in it .PARAMETER certificateSubject Regular expression to match an existing installed certificate .PARAMETER clearTextPassword Password for the private key in the .pfx certificate .PARAMETER secureStringPassword Password for the private key in the .pfx certificate as a secure string .PARAMETER computers A comma separated list of computers to operate on. If none is specified, the local computer is used .PARAMETER computersFile A text file containing one computer per line. Blank lines and those starting with # will be ignored as will any characters like space or # after the computer name .PARAMETER copyCertificateLocally Copy the certificate file specified by the -certificatePath parameter to the %temp% folder on the remote machine and delete the copy after import .PARAMETER credential PSCredential object to use for the PS remoting .PARAMETER useSSL Use SSL for PS remoting .PARAMETER norestart Do not restart the ControlUp monitor service if present .PARAMETER port Use the specified port for PS remoting rather than the default .EXAMPLE & '.\Assign auth certificates to CU.ps1' -certificateSubject '*.guyrleech.local' -computers glcumonitor01,glcumonitor02 Configure the already existing certificate containing *.guyrleech.local in the subject for use by ControlUp on machines glcumonitor01 and glcumonitor02 .EXAMPLE & '.\Assign auth certificates to CU.ps1' -certificateSubject '*.guyrleech.local' Configure the already existing certificate containing *.guyrleech.local in the subject for use by ControlUp on the local machine .EXAMPLE & '.\Assign auth certificates to CU.ps1' -certificateSubject '*.guyrleech.local' -credential $null Prompt for credentials to use for the remoting (use this when the account running the script does not have PowerShell remoting permissions) and then configure the already existing certificate containing *.guyrleech.local in the subject for use by ControlUp on the local machine .EXAMPLE & '.\Assign auth certificates to CU.ps1' -certificatePath c:\temp\controlup.pfx -Verbose -password "mypassword1" -copyCertificateLocally -computersFile C:\temp\cucertmachines.txt Import the certificate from the file c:\temp\controlup.pfx after copying it to each machine specified, one per line, in the file C:\temp\cucertmachines.txt, importing it into that computer's personal certificate store and configure for use by ControlUp .NOTES The ControlUp Monitor service will be restarted if it is present https://support.controlup.com/hc/en-us/articles/360018408337 Modification history: @guyrleech 2021-05-05 First public release @guyrleech 2021-05-20 Add ability for -credential to prompt for credential when $null passed #> [CmdletBinding(DefaultParameterSetName='Existing')] Param ( [Parameter(Mandatory=$true,HelpMessage='Path/folder to the .pfx private certificate file',ParameterSetName='Import')] [string]$certificatePath , [Parameter(Mandatory=$true,HelpMessage='Subject to match in existing certificate',ParameterSetName='Existing')] [string]$certificateSubject , [Parameter(Mandatory=$false,ParameterSetName='Import')] [string]$clearTextPassword , [Parameter(Mandatory=$false,ParameterSetName='Import')] [System.Security.SecureString]$secureStringPassword , [System.Collections.Generic.List[string]]$computers , [string]$computersFile , [Parameter(Mandatory=$false,ParameterSetName='Import')] [switch]$copyCertificateLocally , [switch]$norestart , [AllowNull()] [System.Management.Automation.PSCredential]$credential , [switch]$useSSL , [int]$port ) [string]$controlUpRegistryKey = 'HKLM:\SOFTWARE\Smart-X\ControlUp\ClientCert' ## stop duplicate computers in the text file [hashtable]$comptersList = @{} if( $PSBoundParameters[ 'computersfile' ] ) { if( ! $computers ) { $computers = New-Object -TypeName System.Collections.Generic.List[string] } ForEach( $line in (Get-Content -Path $computersFile -ErrorAction Stop | Where-Object { $_ -notmatch '^#' })) { if( $line -match '([a-z0-9_\-\.]+)' -and ! [string]::IsNullOrEmpty( $Matches[1] ) ) { try { $comptersList.Add( $Matches[1] , $true ) $computers.Add( $Matches[1] ) } catch { Write-Warning -Message "Duplicate computer `"$($Matches[1])`"" } } } } if( ! $computers -or ! $computers.Count ) { $computers = @( $env:COMPUTERNAME ) } Write-Verbose -Message "$(Get-Date -Format G) : got $($computers.Count) computers to act on" [int]$computerCounter = 0 [int]$failures = 0 [hashtable]$sessionParameters = @{ 'UseSSL' = $useSSL } if( $PSBoundParameters[ 'port' ] ) { $sessionParameters.Add( 'Port' , $port ) } if( $PSBoundParameters.ContainsKey( 'credential' )) { if( ! $credential ) { $credential = Get-Credential -Message "For remoting for ControlUp certificate configuration" } if( $credential ) { $sessionParameters.Add( 'Credential' , $credential ) } } [bool]$certificatePathIsUNC = $false if( $PSBoundParameters[ 'certificatePath' ] -and $certificatePath -match '^\\\\[^\\]+\\.+' ) ## is the path to the certificate a UNC? { $sessionParameters.Add( 'EnableNetworkAccess' , $true ) $certificatePathIsUNC = $true } [byte[]]$inputData = $null ## if certificatePath is a folder and we are copying it locally, find the single source certificate now if( $copyCertificateLocally ) { if( Test-Path -Path $certificatePath -PathType Container -ErrorAction SilentlyContinue ) { [array]$pfxFiles = @( Get-ChildItem -Path (Join-Path -Path $certificatePath -ChildPath '*.pfx') -ErrorAction SilentlyContinue | Where-Object { ! $_.PSIsContainer } ) if( ! $pfxFiles -or ! $pfxFiles.Count ) { throw "No .pfx files found in folder `"$certificatePath`" so cannot copy" } elseif( $pfxFiles.Count -gt 1 ) { throw "Found $($pfxFiles.Count) .pfx files in folder `"$certificatePath`" - please specify exact path to the correct .pfx file to copy" } else { $certificatePath = $pfxFiles[0].FullName } } ## as we have a remote session, we do not need to use C$ so read the certificate data once if( ! ( $inputData = @( Get-Content -Path $certificatePath -Encoding Byte ) ) -or ! $inputData.Count -or ! ( $properties = Get-ItemProperty -Path $certificatePath ) -or $properties.Length -ne $inputData.Count ) { throw "Problem reading data from certificate `"$certificatePath`"" } } $remoteSession = $null ## can't use a secure string at the remote end since it is encrypted based on user and machine so is unique to them if( $PSBoundParameters[ 'secureStringPassword' ] ) { if( [PSCredential]$dummycredential = New-Object -TypeName System.Management.Automation.PSCredential ( 'user', $secureStringPassword ) ) { $clearTextPassword = $dummycredential.GetNetworkCredential().Password } } ForEach( $computer in $computers ) { $computerCounter++ Write-Verbose -Message "$(Get-Date -Format G) : $computerCounter / $($computers.Count) : $computer" [hashtable]$invokeCommandParameters = @{ 'ErrorAction' = 'Continue' } if( $remoteSession ) { Remove-PSSession -Session $remoteSession -ErrorAction SilentlyContinue $remoteSession = $null } if( $computer -ne '.' -and $computer -ne $env:COMPUTERNAME ) { if( ! ( $remoteSession = New-PSSession -ComputerName $computer @sessionParameters ) ) { Write-Warning -Message "Failed to remote to $computer - skipping" $failures++ continue } else { $invokeCommandParameters.Add( 'Session' , $remoteSession ) } } $imported = $null $privateKey = $null if( $PSCmdlet.ParameterSetName -eq 'Import' ) { [string]$remoteCertificatePath = $certificatePath [bool]$deleteCertificate = $false if( $remoteSession -and $copyCertificateLocally ) { ## as we have a remote session, we do not need to use C$ $remoteCertificatePath = Invoke-Command @invokeCommandParameters -ScriptBlock ` { ## New-Guid may not be available so we'll use other ways of generating a hopefully unique temp file name $null = Get-Random -SetSeed ((Get-Date).ToFileTime() % [int32]::MaxValue) [int32]$randomNumber = Get-Random -Minimum 100000000 -Maximum 999999999 $using:inputData | Set-Content -Path ($tempFile = Join-Path -Path $env:temp -ChildPath "$randomNumber-$pid.thingy") -Encoding Byte if( $? -and ( $properties = Get-ItemProperty -Path $tempFile ) -and $properties.Length -eq $using:inputData.Count ) { $tempFile } else { Write-Warning -Message "Failed to write $($using:inputData.Count) bytes to $tempfile" } } if( ! $remoteCertificatePath ) { Write-Warning -Message "Failed to copy certificate `"$certificatePath`" to $computer - skipping" continue } Write-Debug -Message "Remote certificate path on $computer is $remoteCertificatePath" $deleteCertificate = $true } ## when remoting it doesn't pass the private key detail back so we return explicitly $imported,$privateKey = Invoke-Command @invokeCommandParameters -ArgumentList $remoteCertificatePath,$deleteCertificate,$clearTextPassword,$VerbosePreference -ScriptBlock ` { Param( [string]$certificatePath , [bool]$deleteCertificate , [string]$clearTextPassword , [System.Management.Automation.ActionPreference]$verbose) $VerbosePreference = $verbose if( Test-Path -Path $certificatePath -PathType Container -ErrorAction SilentlyContinue ) { [array]$pfxFiles = @( Get-ChildItem -Path (Join-Path -Path $certificatePath -ChildPath '*.pfx') -ErrorAction SilentlyContinue | Where-Object { ! $_.PSIsContainer } ) if( ! $pfxFiles -or ! $pfxFiles.Count ) { Write-Error -Message "No .pfx files found in folder `"$certificatePath`" on $env:COMPUTERNAME" $certificatePath = $null } elseif( $pfxFiles.Count -gt 1 ) { Write-Error -Message "Found $($pfxFiles.Count) .pfx files in folder `"$certificatePath`" on $env:COMPUTERNAME - please specify exact path to the correct .pfx file" $certificatePath = $null } else { $certificatePath = $pfxFiles[0].FullName } } elseif ( ! ( Test-Path -Path $certificatePath -ErrorAction SilentlyContinue) ) { Write-Error -Message "Cannot find certificate `"$certificatePath`" on $env:COMPUTERNAME" $certificatePath = $null } if( $certificatePath ) { $exception = $null [hashtable]$importParameters = @{ CertStoreLocation = 'Cert:\LocalMachine\My' FilePath = $certificatePath } ## if certificate already exists, code still works ok try { if( ! [string]::IsNullOrEmpty( $clearTextPassword ) ) { $importParameters.Add( 'Password' , ( ConvertTo-SecureString -String $clearTextPassword -AsPlainText -Force ) ) } Import-Module -Name PKI -Verbose:$false -ErrorAction SilentlyContinue if( Get-Command -Name Import-PfxCertificate -ErrorAction SilentlyContinue ) { $imported = Import-PfxCertificate @importParameters } else { ## Server 2008R2 does not have the PKI module $output = certutil.exe -f -p "$clearTextPassword" -importpfx $certificatePath "NoExport,NoProtect" 2>&1 if( ! $? ) { Write-Error -Message "certutil error importing `"$certificatePath`" - $output" } else { ## now need to get this certificate into $imported ## certutil output will be something like this so we grab the subject ## Certificate "CN=*.guyrleech.local, OU=Consultancy, O=Secure Platform Solutions Ltd, L=Wakefield, S=West Yorkshire, C=GB" added to store. Write-Verbose -Message "certutil imported certificate ok - $output" if( ($output | Out-String) -match '\bCertificate\s*\"(.*)\" added to store\.' ) { [string]$subject = $Matches[1] if( $null -ne ( $matchingSubjects = Get-ChildItem -Path cert:\LocalMachine\My | Where-Object { $_.Subject -eq $subject } | Sort-Object -Property NotBefore -Descending ) ) { if( $matchingSubjects -is [array] ) { Write-Warning -Message "Found $($matchingSubjects.Count) certificates with subject `"$subject`" so using one with thumprint $($matchingSubjects[0].Thumbprint) as most recent" $imported = $matchingSubjects[0] } else { $imported = $matchingSubjects } } else { Write-Error -Message "Found no computer certificates with subject `"$subject`"" } } else { Write-Error -Message "Failed to find certificate subject in certutil output - $output" } } } } catch { $exception = $_ $imported = $null } if( ! $imported ) { Write-Error -Message "Failed to import certificate from `"$certificatePath`" on $env:COMPUTERNAME - $exception" } if( $deleteCertificate ) { ## because it was copied locally Remove-Item -Path $certificatePath -Force } $imported ## return $imported.PrivateKey.CspKeyContainerInfo.UniqueKeyContainerName ## return } } } else ## certificate is supposed to already exist { $imported,$privateKey = Invoke-Command @invokeCommandParameters -ArgumentList $certificateSubject -ScriptBlock ` { Param( [string]$certificateSubject ) [string]$escapedPath = [regex]::Escape( $certificateSubject ) [array]$certificates = @( Get-ChildItem -Path Cert:\LocalMachine\My -ErrorAction SilentlyContinue | Where-Object { $_.NotAfter -gt ([datetime]::Now) -and $_.Subject -match $escapedPath -and $_.EnhancedKeyUsageList.Where( { $_.FriendlyName -eq 'Server Authentication' } ) } | Sort-Object -Property NotAfter -Descending ) if( ! $certificates -or ! $certificates.Count ) { Write-Error -Message "No existing certificate matching `"$certificateSubject`" found on $env:COMPUTERNAME" } elseif( $certificates.Count -gt 1 ) { Write-Warning -Message "Found $($certificates.Count) suitable certificates on $env:COMPUTERNAME matching `"$certificateSubject`" so using the latest one with subject `"$($certificates[0].Subject)`", expiring $(Get-Date -Date $certificates[0].NotAfter -Format G)" } else { $certificates[ 0 ] ## return $certificates[ 0 ].PrivateKey.CspKeyContainerInfo.UniqueKeyContainerName ## return } } } if( $imported ) { if( ! $imported.PSObject.Properties[ 'Thumbprint' ] -or [string]::IsNullOrEmpty( $imported.Thumbprint ) -or $imported.Thumbprint.Length -ne 40 ) { Write-Warning -Message "Failed to get valid thumbprint for imported certificate `"$certificatePath`"" } else { $remoteErrors = $null Invoke-Command @invokeCommandParameters -ArgumentList $controlUpRegistryKey,$imported,$privateKey,$norestart,$VerbosePreference -ErrorVariable $remoteErrors -ScriptBlock ` { Param( [string]$controlUpRegistryKey , $imported , $privateKey , [bool]$norestart , [System.Management.Automation.ActionPreference]$verbose) $VerbosePreference = $verbose if( ! ( Test-Path -Path $controlUpRegistryKey -PathType Container -ErrorAction SilentlyContinue ) ) { if( ! ( New-Item -Path $controlUpRegistryKey -ItemType Key -Force ) ) { Write-Warning -Message "Error creating registry key $controlUpRegistryKey on $env:COMPUTERNAME" } } if( ! ( Set-ItemProperty -Path "$controlUpRegistryKey" -Name "Enabled" -Value 1 -Force -PassThru ) ) { Write-Warning -Message "Failed to set Enabled value in `"$controlUpRegistryKey`" on $env:COMPUTERNAME" } if( ! ( Set-ItemProperty -Path "$controlUpRegistryKey" -Name "Thumbprint" -Force -Value $imported.Thumbprint -PassThru ) ) { Write-Warning -Message "Failed to set thumbprint value in `"$controlUpRegistryKey`" on $env:COMPUTERNAME" } $keyPath = "$([Environment]::GetFolderPath( [Environment+SpecialFolder]::CommonApplicationData ))\Microsoft\Crypto\RSA\MachineKeys" ## programdata if( ! ( $privateKeyPath = Get-Item -Force -Path (Join-Path -Path $keyPath -ChildPath $privateKey) ) ) { Write-Warning -Message "Unable to find private key file `"$privateKeyPath`" on $env:COMPUTERNAME" } if( $privateKeyPath.PSIsContainer ) { Write-Error -Message "Private key path `"$($privateKeyPath.FullName)`" is a folder, not a file" } else { $service = Get-CimInstance -ClassName win32_service -Filter "name = 'cumonitor'" -ErrorAction SilentlyContinue -Verbose:$false # Update ACL to grant "FULL CONTROL" permissions on the private key to "NT AUTHORITY\NETWORK SERVICE" if( $Acl = Get-Acl -Path $privateKeyPath.FullName ) { $account = $null if( $service -and ( $account = $service.StartName )) { Write-Debug -Message "Using account $account from $($service.name) service" } if( [string]::IsNullOrEmpty( $account ) ) { ## https://docs.microsoft.com/en-US/troubleshoot/windows-server/identity/security-identifiers-in-windows $account = New-Object -TypeName System.Security.Principal.SecurityIdentifier( 'S-1-5-20' ) ## 'NETWORK SERVICE' } $exception = $null try { $AllowAce = New-Object -TypeName System.Security.AccessControl.FileSystemAccessRule( $account , 'FullControl' , 'Allow' ) $Acl.SetAccessRule( $AllowAce ) $newAcl = Set-Acl -Path $privateKeyPath.FullName -AclObject $Acl -Passthru } catch { $exception = $_ $newAcl = $null } if( ! $newAcl -or $newAcl.Access.Count -ne $Acl.Access.Count ) { Write-Error -Message "Failed to set ACL for `"$($privateKeyPath.FullName)`" on $env:COMPUTERNAME : $exception" } else { Write-Verbose -Message "Set ACL ok on `"$($privateKeyPath.FullName)`" on $env:COMPUTERNAME" } if( $service -and ! $norestart ) { Write-Verbose -Message "Restarting $($service.Name) service on $env:COMPUTERNAME" [datetime]$startTime = [datetime]::Now if( ! ( $restart = $service | Restart-Service -Force -PassThru )) { Write-Warning -Message "Problem with restart of $($service.Name) service after $([math]::Round( (New-TimeSpan -Start $startTime).TotalSeconds,1)) seconds of trying on $env:COMPUTERNAME" ## see if service has stopped and if not kill the process and start the service again if( ( $cuMonitorProcess = Get-CimInstance -ClassName win32_process -Filter "Name = '$((Split-Path -Path $service.PathName -Leaf).Trim( '"' ))'" -ErrorAction SilentlyContinue ) ) { ## not looking at start time relative to script start time since if old or new process it isn't responding properly so kill it if( ! ( $killResult = Invoke-CimMethod -InputObject $cuMonitorProcess -MethodName Terminate ) -or $killResult.ReturnValue -ne 0 ) { ## TODO try again a few times ? } } $restart = $service | Start-Service -PassThru } if( ! $restart ) { Write-Error "Problem restarting $($service.Name) service on $env:COMPUTERNAME" } } Write-Verbose -Message "Certificate `"$($imported|Select-Object -ExpandProperty Subject)`" with thumbprint $($imported|Select-Object -ExpandProperty Thumbprint), expiring $(Get-Date -Date $imported.NotAfter -Format G) configured on $env:COMPUTERNAME" } else { Write-Error -Message "Failed to get ACL for `"$($privateKeyPath.FullName)`" on $env:COMPUTERNAME" } } } if( $remoteErrors -and $remoteErrors.Count ) { $failures++ } } } else { $failures++ } } if( $remoteSession ) { Remove-PSSession -Session $remoteSession -ErrorAction SilentlyContinue $remoteSession = $null } Write-Verbose "$(Get-Date -Format G) : finished : $failures failures out of $($computers.Count) computers" Exit $failures