<# .SYNOPSIS This script updates certificate bindings for ports used by Laserfiche services. .DESCRIPTION This script updates certificate bindings for ports used by Laserfiche services. Especially those that use TLS ports other than 443, which can often be handled through the IIS Certificate Rebind feature (https://learn.microsoft.com/en-us/iis/get-started/whats-new-in-iis-85/certificate-rebind-in-iis85) or an Automatic Certificate Management Environment (ACME) client for Let's Encrypt or other supporting CA. It is intended to run as part of a Windows Scheduled Task or similar scheduled/triggered job. See the Examples for specifics on how to invoke the script through a Scheduled Task action. Certificate binding requires the Scheduled Task to run with "Highest" (Administrator) privileges. Consider adding it an additional action to a relevant existing Scheduled Task, such as an IIS Certificate Rebind or ACME renewal one. .PARAMETER Ports The port(s) to add/update bindings for. Provide as comma-separated list. Ex: -Ports 443,5049,8181 This is an optional parameter; if not provided, the program dynamically selects ports based on which Laserfiche services (including IIS for 443) are installed on the machine. .PARAMETER LogPath The directory for the log file. This is an optional parameter; if not provided, uses the default value of 'C:\Scripts\Logs'. .PARAMETER LogName The name of the log file. This is an optional parameter; if not provided, uses the default value of 'LaserficheCertificateBindingLog.txt'. .PARAMETER MatchSubject The pattern used to match the certificate Subject field value, one of the dynamic certificate selection criteria. If the Subject value has a friendly DNS name (e.g., "lf-web.example.com"), provide it or a matching component (e.g., "lf-") here. If a Thumbprint parameter value is provided, dynamic certificate selection does not occur and MatchSubject has no effect. This is an optional parameter; if not provided, uses the default value of the machine's fully-qualified hostname. .PARAMETER MatchIssuer The pattern used to match the certificate Issuer field value, an optional dynamic certificate selection criteria. E.g., "GoDaddy" or "My-AD-Domain-CA". Use this parameter when there are certificates from multiple Certificate Authorities (e.g., one public, one private) that would match the MatchSubject value and other criteria to ensure a specific one is used. This is an optional parameter; if not provided, Issuer is not used for dynamic certificate selection. .PARAMETER Thumbprint The thumbprint of the certificate to use (no spaces) E.g., '5882d61ef5296744405532cfe379feb8e754c3d4' This is an optional parameter; if not provided, the certificate is dynamically selected. .PARAMETER EnforceCertValidity Whether to enforce the certificate validation checks. The check uses the 'Test-Certificate' PowerShell commandlet. If $true, on validation failure the program will log an error message and exit. If $false, on validation failure the program will log a warning message and continue. This is an optional parameter; if not provided, uses the default value of $true (boolean). .PARAMETER CreateBindings Specifies whether to attempt to create bindings before updating them. Can use for initial setup if bindings do not already exist. When specified as a switch, the program runs 'netsh http add sslcert' before 'netsh http update sslcert'. This is an optional switch parameter; if not provided, bindings are not created. .EXAMPLE UpdateLaserficheCertificateBindings.ps1 -Ports 5049,8181 -MatchSubject "lf-web.example.com" .EXAMPLE UpdateLaserficheCertificateBindings.ps1 -Thumbprint "5882d61ef5296744405532cfe379feb8e754c3d4" -CreateBindings .EXAMPLE UpdateLaserficheCertificateBindings.ps1 -LogPath "E:\Scripts\Logs" -LogName "CertLog.txt" .EXAMPLE When invoked through a Scheduled Task: Action: Start a program Program/script: powershell Add arguments: -File "C:\Scripts\UpdateLaserficheCertificateBindings.ps1" -Ports 5049,8181 -MatchSubject "lf-web.example.com" Note: Make sure to use double quotes ("") for the argument values, especially the -File path. Using single quotes will cause it to fail with a path format error. See: https://troubleshootingsql.com/2014/11/17/gotcha-executing-powershell-scripts-using-scheduled-tasks/ If the script appears to fail to run under a Scheduled Task, begin troubleshooting with these steps: 1. Check that task's History for entries with Event ID "201" and Task Category "Action completed" 2. Look for log info body like: "Task Scheduler successfully completed task "$TaskName" , instance "{82d790c8-26d1-4eaf-b99c-eac2f5b4f1f2}" , action "C:\WINDOWS\System32\WindowsPowerShell\v1.0\powershell.EXE" with return code 4294770688."" 3. Search the internet for "PowerShell scheduled task return code $ReturnCodeNumber" Note: A return (exit) code of "0" (usually) indicates a successful script execution. Note: The script successfully executing does not mean it produced the desired result, only that it ran and terminated normally. .NOTES Author: Samuel Carson (Laserfiche) Last updated: 2024-03-20 Disclaimer: THIS SOFTWARE IS PROVIDED “AS IS”, WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. #> param ( # Specifies port(s) to update bindings for # Provide as comma-separated list (ex: "-Ports 443, 5049, 8181") [Parameter(Mandatory = $false)] [string[]]$Ports, # Specifies the log path [Parameter(Mandatory = $false)] [string]$LogPath = 'C:\Scripts\Logs', # Specifies the log file name [Parameter(Mandatory = $false)] [string]$LogName = 'LaserficheCertificateBindingLog.txt', # Specifies the pattern to match for the certificate subject (defaults to hostname) # If the Subject has a friendly DNS name (e.g., "lf-web.example.com"), provide that # or a matching component (e.g., "lf-") instead [Parameter(Mandatory = $false)] [string]$MatchSubject = [System.Net.Dns]::GetHostByName($env:computerName).HostName, # Specifies the pattern to match for the certificate issuer field # E.g., "GoDaddy" or "My-AD-Domain-CA". [Parameter(Mandatory = $false)] [string]$MatchIssuer, # Specifies the thumbprint of the certificate to use (no spaces) # The certificate is dynamically selected if not specified [Parameter(Mandatory = $false)] [string]$Thumbprint, # Specifies whether to enforce certificate validation check [Parameter(Mandatory = $false)] [boolean]$EnforceCertValidity = $true, # Specifies whether to attempt to create bindings before updating them # Can use for initial setup if bindings do not already exist [Parameter(Mandatory = $false)] [switch]$CreateBindings = $false ) class ServicePortMap { [string]$Name [string]$DisplayName [string]$Port ServicePortMap ([string]$Name, [string]$DisplayName, [string]$Port) { $this.Name = $Name $this.DisplayName = $DisplayName $this.Port = $Port } } $ServicesToCheck = @( [ServicePortMap]::new('W3SVC','IIS','443'), [ServicePortMap]::new('LFS','Laserfiche Server','443'), [ServicePortMap]::new('LfFTSrv','Laserfiche Full-Text Search','5053'), [ServicePortMap]::new('LaserficheNotificationHubService',' LaserficheForms Notification Hub Service','8181'), [ServicePortMap]::new('LicenseManagerWCF','Directory Server','5049') ) # Log instantiation variables $LogEntrySeparator = ([Environment]::NewLine) + '--------------------------------------------------' $LogFullPath = Join-Path -Path $LogPath -ChildPath $LogName $DateTime = Get-Date -Format "yyyy-MM-dd HH:mm" $LogStartMsg = "`n`n[$DateTime]" # Create log file if it doesn't exist if(!(Test-Path -Path $LogFullPath)) { New-Item -Path $LogPath -Name $LogName -ItemType "file" -Value '[Log file for certificate binding update script]' -Force } Start-Sleep -s 3 Add-Content -Path $LogFullPath -Value $LogStartMsg <# Check if valid ports were specified in input parameter, and if not, dynamically construct the ports list using the $ServicesToCheck ServicePortMap list If the script is invoked via "-File - | " such as from a Scheduled Task, an input param like "-Ports 5049, 8181" is passed as a single string '5049, 8181' into the first ([0]) element of the array instead of the intended multiple array elements. The script addresses this scenario by checking if the array has Count = 1, and if so, removing any white space from the string and then splitting on commas to reconstruct the array. "The File parameter can't support scripts using a parameter that expects an array of argument values. This, unfortunately, is a limitation of how a native command gets argument values. When you call a native executable (such as powershell or pwsh), it doesn't know what to do with an array, so it's passed as a string." https://learn.microsoft.com/en-us/powershell/module/microsoft.powershell.core/about/about_powershell_exe?view=powershell-5.1#-file----filepath-args #> if (![string]::IsNullOrEmpty($Ports)) { if ($Ports.Count -eq 1) { $Ports[0] = $Ports[0] -Replace '\s','' $Ports = $Ports.Split(',') } [string[]]$InvalidPorts = $null foreach ($Port in $Ports) { if ($Port -NotIn 1..65535) { $InvalidPorts += $Port } } if (![string]::IsNullOrEmpty($InvalidPorts)) { $Msg = 'ERROR: The following specified ports are invalid: ' + ($InvalidPorts -Join ', ') + '. Exiting.' Write-Host $Msg #Add-Content -Path $LogFullPath -Value "$Msg $LogEntrySeparator" #Exit } } elseif ([string]::IsNullOrEmpty($Ports)) { $Msg = 'INFO: No values were provided for the Ports parameter. The program will automatically detect applicable services to determine ports.' Add-Content -Path $LogFullPath -Value "$Msg" $Msg = '' $ServicesFound = '' foreach ($Service in $ServicesToCheck) { $CheckService = Get-Service -Name $Service.ServiceName -ErrorAction SilentlyContinue if (![string]::IsNullOrEmpty($CheckService)) { $ServicesFound += $Service.DisplayName $Ports += $Service.Port } } if ([string]::IsNullOrEmpty($Ports)) { $Msg = 'ERROR: No applicable services were found. This can occur if Laserfiche applications and/or IIS are not installed. Exiting.' Add-Content -Path $LogFullPath -Value "$Msg $LogEntrySeparator" Exit } $UniquePorts = $Ports | Get-Unique $Msg = 'INFO: Services detected: ' + ($ServicesFound -Join ', ') + '. Ports to bind: ' + ($UniquePorts -Join ', ') + '.' Add-Content -Path $LogFullPath -Value "$Msg" } # If certificate thumbprint is provided, use it to find the cert. Else, find cert dynamically. if (![string]::IsNullOrWhitespace($Thumbprint)) { $Cert = (Get-ChildItem cert:\LocalMachine\My | Where-Object {$_.Thumbprint -eq $Thumbprint}) if ([string]::IsNullOrEmpty($Cert)) { $Msg = "ERROR: No certificate found with thumbprint $Thumbprint. Exiting." Add-Content -Path $LogFullPath -Value "$Msg $LogEntrySeparator" Exit } } else { # Get all certificates matching criteria (subject pattern match, server auth usage, has private key, not expired, issuer (if specified)) if (![string]::IsNullOrEmpty($MatchIssuer)) { $Certs = (Get-ChildItem cert:\LocalMachine\My | Where-Object {$_.Subject -like "*$MatchSubject*" -and $_.EnhancedKeyUsageList -like "*Server Authentication*" -and $_.HasPrivateKey -eq $true -and $_.NotAfter -gt (Get-Date) -and $_.Issuer -like "*$MatchIssuer*"}) } else { $Certs = (Get-ChildItem cert:\LocalMachine\My | Where-Object {$_.Subject -like "*$MatchSubject*" -and $_.EnhancedKeyUsageList -like "*Server Authentication*" -and $_.HasPrivateKey -eq $true -and $_.NotAfter -gt (Get-Date)}) } # Exit if no certificates are found if ([string]::IsNullOrWhitespace($Certs)) { $Msg = 'ERROR: No certificate found matching selection criteria found. Exiting.' Add-Content -Path $LogFullPath -Value "$Msg $LogEntrySeparator" Exit } # Select matching certificate with latest expiration date # Certificates typically renew well before they expire and the old certificate (which would not yet be expired) isn't always cleared out # This will (usually) ensure the selected cert is the newest one $CertExpiration = $Certs.NotAfter | Measure-Object -Maximum $Cert = $Certs | Where-Object {$_.NotAfter -eq $CertExpiration.Maximum} | Select-Object -First 1 $Thumbprint = $Cert.Thumbprint } # Check cert validity - see: https://learn.microsoft.com/en-us/powershell/module/pki/test-certificate $params = @{ Cert = $Cert EKU = '1.3.6.1.5.5.7.3.1' #Object ID for "Server Authentication" Policy = 'SSL' } $CertValid = Test-Certificate @params -ErrorVariable CertValidationError if (!$CertValid) { $Msg = "WARNING: The certificate with thumbprint $Thumbprint did not pass validation with error:" Add-Content -Path $LogFullPath -Value "$Msg" Add-Content -Path $LogFullPath -Value "WARNING: $CertValidationError" $Msg = '' if ($EnforceCertValidation) { $Msg = 'ERROR: Certificate Validation is currently enforced. The program will not proceed to certificate binding. Exiting.' Add-Content -Path $LogFullPath -Value "$Msg $LogEntrySeparator" Exit } } function New-CertificateBinding { [CmdletBinding()] param( [Parameter(Mandatory)] [string]$Port, [Parameter(Mandatory)] [string]$Thumbprint ) $Guid = '{'+ (New-Guid) + '}' $Result = netsh http add sslcert ipport=0.0.0.0:$Port certhash=$Thumbprint appid=$Guid Add-Content -Path $LogFullPath -Value "INFO: [Port $Port] netsh:$Result" } function Update-CertificateBinding { [CmdletBinding()] param( [Parameter(Mandatory)] [string]$Port, [Parameter(Mandatory)] [string]$Thumbprint ) $Guid = '{'+ (New-Guid) + '}' $Result = netsh http update sslcert ipport=0.0.0.0:$Port certhash=$Thumbprint appid=$Guid Add-Content -Path $LogFullPath -Value "INFO: [Port $Port] netsh:$Result" } foreach ($Port in $Ports) { if ($CreateBindings) { New-CertificateBinding -Port $Port -Thumbprint $Thumbprint } Update-CertificateBinding -Port $Port -Thumbprint $Thumbprint } Add-Content -Path $LogFullPath -Value "$LogEntrySeparator"