MilestoneSystemsInc / PowerShellSamples

A collection of samples for managing your Milestone XProtect VMS using MilestonePSTools in PowerShell
https://www.milestonepstools.com
MIT License
36 stars 12 forks source link

Proper Method for SSL Cert Rotation #142

Open djarbz opened 2 months ago

djarbz commented 2 months ago

I have been working on the following script for quite some time to perfect renewing my SSL cert via Cloudflare and Let's Encrypt. Each time I go through a renewal, it always seems to fail at a different point and I have to iterate on the operation.

This latest time, I am able to get to the application step, which fails. What is the proper way to replace all certificates to the newly generated certificate?

Set-XProtectCertificate : Server Configurator exited with code 50.
At C:\ASSETS\Scripts\Renew-Certificate.ps1:223 char:13
+     $cert | Set-XProtectCertificate -VmsComponent Server -Force
+             ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
    + CategoryInfo          : NotSpecified: (:) [Write-Error], WriteErrorException
    + FullyQualifiedErrorId : Microsoft.PowerShell.Commands.WriteErrorException,Set-XProtectCertificate

[16:32:52][Renew-Certificate.ps1] Applying certificate to Mobile Server
Set-XProtectCertificate : Server Configurator exited with code 50.
At C:\ASSETS\Scripts\Renew-Certificate.ps1:226 char:13
+     $cert | Set-XProtectCertificate -VmsComponent MobileServer -Force
+             ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
    + CategoryInfo          : NotSpecified: (:) [Write-Error], WriteErrorException
    + FullyQualifiedErrorId : Microsoft.PowerShell.Commands.WriteErrorException,Set-XProtectCertificate

[16:34:11][Renew-Certificate.ps1] Applying certificate to Recording Server

Unhandled Exception: System.AggregateException: One or more errors occurred. ---> System.InvalidOperationException: Unexpected value UnspecifiedErrorEncryption
   at VideoOS.ServerConfigurator.CertificateConfigurator.SetServerAddressHandler.SetServerAddressStatusFromStatusCode(StatusCode result)
   at VideoOS.ServerConfigurator.CertificateConfigurator.RegistrationBase`3.Register(Dictionary`2 results)
   at VideoOS.ServerConfigurator.CertificateConfigurator.RegistrationBase`3.Register(Dictionary`2 results)
   at VideoOS.ServerConfigurator.CertificateConfigurator.RegistrationBase`3.Register(Dictionary`2 results)
   at VideoOS.ServerConfigurator.CertificateConfigurator.RegistrationBase`3.Register(Dictionary`2 results)
   at VideoOS.ServerConfigurator.CertificateConfigurator.RegistrationBase`3.Register()
   at VideoOS.ServerConfigurator.CertificateConfigurator.ServerConfiguratorDataModel.ApplyCertificatesAndRegisterEndpoints(IEnumerable`1 serverEndpoints, NetworkCredential credentials)
   at VideoOS.ServerConfigurator.MainApp.CommandLineRunner.ApplyEncryption()
   at System.Threading.Tasks.Task`1.InnerInvoke()
   at System.Threading.Tasks.Task.Execute()
   --- End of inner exception stack trace ---
   at System.Threading.Tasks.Task`1.GetResultCore(Boolean waitCompletionNotification)
   at VideoOS.ServerConfigurator.MainApp.MainAppStartup.RunConsole(IArgumentsInterpreter argumentInterpreter, ApplicationResources applicationResources, CommonConfigurationProvider configurationProvider, ServerConfiguratorDataModel dataModel)
   at VideoOS.ServerConfigurator.MainApp.MainAppStartup.Main(String[] args)
Set-XProtectCertificate : Server Configurator exited with code -532462766.
At C:\ASSETS\Scripts\Renew-Certificate.ps1:229 char:13
+     $cert | Set-XProtectCertificate -VmsComponent StreamingMedia -For ...
+             ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
    + CategoryInfo          : NotSpecified: (:) [Write-Error], WriteErrorException
    + FullyQualifiedErrorId : Microsoft.PowerShell.Commands.WriteErrorException,Set-XProtectCertificate

[16:36:04][Renew-Certificate.ps1] Applying certificate to Event Server
Set-XProtectCertificate : Server Configurator exited with code 50.
At C:\ASSETS\Scripts\Renew-Certificate.ps1:232 char:13
+     $cert | Set-XProtectCertificate -VmsComponent EventServer -Force  ...
+             ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
    + CategoryInfo          : NotSpecified: (:) [Write-Error], WriteErrorException
    + FullyQualifiedErrorId : Microsoft.PowerShell.Commands.WriteErrorException,Set-XProtectCertificate

[16:37:17][Renew-Certificate.ps1] New certificate installed with thumbprint 08070C87CE653EA2BB984AE259A787B9DB215923
[16:37:17][Renew-Certificate.ps1] Removing old certificate with thumbprint
Exit Code: 0
# Ensure that this script is being run as `NT AUTHORITY\SYSTEM`.
try {
    # Get the current Windows identity
    $currentIdentity = [Security.Principal.WindowsIdentity]::GetCurrent()

    # Check if the SID of the current identity matches the SID of the NT AUTHORITY\SYSTEM account
    if ($currentIdentity.User.Value -ne "S-1-5-18") {
        Write-Error "Script must run as NT AUTHORITY\SYSTEM"
        exit 1
    }
} catch {
    Write-Error "An error occurred while checking the script's execution context: [$($_.InvocationInfo.ScriptLineNumber)]: $($_.Exception.Message)"
    throw
}

try {
    # Define required PowerShell modules
    $requiredModules = @(
        @{ Name = "CredentialManager"; RequiredVersion = "2.0" },
        @{ Name = "PSFramework"; RequiredVersion = "1.10.318" },
        @{ Name = "Posh-ACME"; RequiredVersion = "4.23.0" },
        @{ Name = "MilestonePSTools"; RequiredVersion = "24.1.5" }
    )

    # Install or update required PowerShell modules
    $requiredModules | ForEach-Object {
        $moduleName = $_.Name
        $requiredVersion = $_.RequiredVersion
        $latestVersion = (
        Find-Module -Name $moduleName |
                Sort-Object Version -Descending |
                Select-Object -First 1
        ).Version

        if ($requiredVersion -lt $latestVersion) {
            Write-Output "$moduleName module version [$requiredVersion] is requested, but has a new version [$latestVersion] available for testing."
        }
        if (-not (Get-Module -Name $moduleName -ListAvailable)) {
            Write-Output "$moduleName module version [$requiredVersion] is not installed. Installing..."
            Install-Module -Name $moduleName `
                -RequiredVersion $requiredVersion `
                -Scope CurrentUser `
                -AllowClobber `
                -Force -ErrorAction Stop
        }
        else {
            $installedVersion = (Get-Module -Name $moduleName -ListAvailable).Version
            if ($installedVersion -lt $requiredVersion) {
                Write-Output "$moduleName module version [$installedVersion] is installed but requires an update to version [$requiredVersion]. Updating..."
                Uninstall-Module -Name $moduleName -Force
                Install-Module -Name $moduleName `
                    -RequiredVersion $requiredVersion `
                    -Scope CurrentUser `
                    -AllowClobber `
                    -Force -ErrorAction Stop
            }
            elseif ($installedVersion -gt $requiredVersion) {
                Write-Output "A newer version [$installedVersion] of $moduleName module is installed and correct operation cannot be guaranteed."
            }
        }

        $installedVersion = (Get-Module -Name $moduleName -ListAvailable).Version
        Write-Output "$moduleName module version [$installedVersion] is installed and up-to-date."
    }
} catch {
    Write-Error "An error occurred while installing or importing modules: [$($_.InvocationInfo.ScriptLineNumber)]: $($_.Exception.Message)"
    throw
}

try {
    # Get the directory of the PowerShell script
    $scriptDirectory = Split-Path -Parent $MyInvocation.MyCommand.Path
    # $scriptDirectory = Split-Path -Path $PSScriptRoot -Parent

    # Get the script file name without extension
    $scriptName = [System.IO.Path]::GetFileNameWithoutExtension($MyInvocation.MyCommand.Name)

    # Construct the log file path using the script directory and script name
    $logDirectory = Join-Path -Path $scriptDirectory -ChildPath "log\$scriptName"

    # Check if the directory exists
    if (Test-Path -Path $logDirectory -PathType Container) {
        # Directory exists!
        # Calculate the date 120 days ago
        $olderThanDate = (Get-Date).AddDays(-120)

        # Get files older than 120 days
        $filesToDelete = Get-ChildItem -Path $logDirectory -Filter "*.csv" | Where-Object { $_.LastWriteTime -lt $olderThanDate }

        # Delete files older than 120 days
        foreach ($file in $filesToDelete) {
            Write-Output "Deleting expired log file [$file.Name]"
            Remove-Item -Path $file.FullName -Force
        }
    }

    # Construct the log file path using the script directory and script name
    $logFilePath = Join-Path -Path $logDirectory -ChildPath "%date%.csv"
    Write-Output "Using Logfile [$logFilePath]"

    # Configuration for PSF logging provider
    $paramSetPSFLoggingProvider = @{
        Name                = 'logfile'
        InstanceName        = $scriptName
        FilePath            = $logFilePath
        Enabled             = $true
        LogRotatePath       = "$logDirectory*.csv"
        LogRetentionTime    = 120d
    }

    # Set PSF logging provider
    Set-PSFLoggingProvider @paramSetPSFLoggingProvider
} catch {
    Write-Error "An error occurred while configuring file logging: [$($_.InvocationInfo.ScriptLineNumber)]: $($_.Exception.Message)"
}

try {
    # Get the script file name without extension
    $scriptName = [System.IO.Path]::GetFileNameWithoutExtension($MyInvocation.MyCommand.Name)

    # Configuration for PSF logging provider
    $paramSetPSFLoggingProvider = @{
        Name            = 'eventlog'
        InstanceName    = $scriptName
        Source          = 'MilestoneACME'
        UseFallback     = $true
        Enabled         = $true
    }

    # Set PSF logging provider
    Set-PSFLoggingProvider @paramSetPSFLoggingProvider
} catch {
    Write-Error "An error occurred while configuring Windows Event Log logging: [$($_.InvocationInfo.ScriptLineNumber)]: $($_.Exception.Message)"
}

# We currently support CloudFlare, but any valid DNSPlugin would work with modifications as appropiate.
$DNSPlugin = "CloudFlare"
$WCVTarget = "Milestone ACME DNS Plugin Token"

try {
    # Retrieve the DNS token from the Windows Credential Vault
    $Credential = Get-StoredCredential -Target $WCVTarget

    if ($Credential) {
        $DNSToken = $Credential.Password
        $DNSPlugin = $Credential.UserName
        Write-PSFMessage -Level Important -Message "DNS Token for plugin [$DNSPlugin] retrieved from Windows Credential Vault."
        # Convert the secure string to a plain text string
        $BSTR = [System.Runtime.InteropServices.Marshal]::SecureStringToBSTR($Credential.Password)
        $DNSTokenPlainText = [System.Runtime.InteropServices.Marshal]::PtrToStringAuto($BSTR)
        Write-Output "DNS Token [$DNSTokenPlainText] - Type: [$($DNSTokenPlainText.GetType())]"
        Write-PSFMessage -Level Important -Message "DNS Token [$DNSTokenPlainText] - Type: [$($DNSTokenPlainText.GetType())]"
        [Runtime.InteropServices.Marshal]::ZeroFreeBSTR($BSTR)
        Write-PSFMessage -Level Important -Message "DNS Token [$DNSTokenPlainText] - Type: [$($DNSTokenPlainText.GetType())]"
    } else {
        # We only support the CloudFlare plugin at the moment, so it is already hard-coded above.
        do {
            $DNSToken = Read-Host "Enter the DNS Token" -AsSecureString
        } while (-not $DNSToken)
    }
} catch {
    Write-PSFMessage -Level Error -Message "An error occurred while retrieving DNS Token from Windows Credential Vault: [$($_.InvocationInfo.ScriptLineNumber)]: $($_.Exception.Message)"
    throw
}

if (-not $Credential) {
    try {
        # Convert the secure string to a plain text string
        $BSTR = [System.Runtime.InteropServices.Marshal]::SecureStringToBSTR($DNSToken)
        $DNSTokenPlainText = [System.Runtime.InteropServices.Marshal]::PtrToStringAuto($BSTR)
        [Runtime.InteropServices.Marshal]::ZeroFreeBSTR($BSTR)
        Write-PSFMessage -Level Important -Message "DNS Token [$DNSTokenPlainText]"

        # Store the DNS token in the Windows Credential Vault
        $Credential = New-StoredCredential -Target $WCVTarget -Username $DNSPlugin -Password $DNSTokenPlainText -Type Generic -Persist LocalMachine

        if ($Credential) {
            Write-PSFMessage -Level Important -Message "DNS Token stored securely in Windows Credential Vault."
        } else {
            Write-PSFMessage -Level Error -Message "Failed to store DNS Token in Windows Credential Vault."
        }
    } catch {
        Write-PSFMessage -Level Error -Message "An error occurred while storing DNS Token in Windows Credential Vault: [$($_.InvocationInfo.ScriptLineNumber)]: $($_.Exception.Message)"
        throw
    }
}

$PluginParams = @{ CFToken = $DNSToken }

try {
    $thumbprint = (Get-PACertificate).Thumbprint
    Write-PSFMessage -Level Important -Message "Submitting Renewal for ACME Certificate."
    $cert = Submit-Renewal -WarningAction Stop -ErrorAction Stop -PluginArgs $PluginParams -Verbose
    Write-PSFMessage -Level Important -Message "Renewal success, updating certificate store."
    $prodCert = Get-ChildItem Cert:\LocalMachine\My |
            Where-Object Thumbprint -eq (Get-PACertificate).Thumbprint

    # Find private key
    $privKey = $prodCert.PrivateKey.CspKeyContainerInfo.UniqueKeyContainerName
    $keyPath = "$($env:ProgramData)\Microsoft\Crypto\RSA\MachineKeys\"
    $privKeyPath = (Get-Item "$keyPath\$privKey")

    # Update ACL to allow "READ" permissions from "NT AUTHORITY\NETWORK SERVICE"
    Write-PSFMessage -Level Important -Message "Setting proper Certificate permissions."
    $Acl = Get-Acl $privKeyPath
    $Ar = New-Object System.Security.AccessControl.FileSystemAccessRule("NETWORK SERVICE", "Read", "Allow")
    $Acl.SetAccessRule($Ar)
    Set-Acl $privKeyPath.FullName $Acl

} catch {
    if ($_.Exception.Message -like "*not recommended for renewal*") {
        Write-PSFMessage -Level Important -Message $_.Exception.Message.Split(":")[1].Trim()
        exit 0
    } else {
        Write-PSFMessage -Level Error -Message "An error occured while renewing the certificate: [$($_.InvocationInfo.ScriptLineNumber)]: $($_.Exception.Message)"
        throw
    }
}

try
{
    Write-PSFMessage -Level Important -Message "Applying certificate to Management Server"
    $cert | Set-XProtectCertificate -VmsComponent Server -Force
    Start-Sleep -Seconds 60
    Write-PSFMessage -Level Important -Message "Applying certificate to Mobile Server"
    $cert | Set-XProtectCertificate -VmsComponent MobileServer -Force
    Start-Sleep -Seconds 60
    Write-PSFMessage -Level Important -Message "Applying certificate to Recording Server"
    $cert | Set-XProtectCertificate -VmsComponent StreamingMedia -Force
    Start-Sleep -Seconds 60
    Write-PSFMessage -Level Important -Message "Applying certificate to Event Server"
    $cert | Set-XProtectCertificate -VmsComponent EventServer -Force -RemoveOldCert
    Start-Sleep -Seconds 60

    Write-PSFMessage -Level Important -Message "New certificate installed with thumbprint $( $cert.Thumbprint )"
    Write-PSFMessage -Level Important -Message "Removing old certificate with thumbprint $thumbprint"

    Get-ChildItem Cert:\LocalMachine\My |
            Where-Object Thumbprint -eq $thumbprint |
            Remove-Item
} catch {
    Write-PSFMessage -Level Error -Message "An error occurred while applying the new certificate to Milestone: [$($_.InvocationInfo.ScriptLineNumber)]: $($_.Exception.Message)"
    throw
}
joshooaj commented 1 month ago

Hi @djarbz,

Can you try using the ServerConfigurator.exe CLI and see if you have the same issues? It's located in C:\Program Files\Milestone\Server Configurator\ and you can enable/update encryption for all "certificate groups" (mobile, server, recording server streaming media) by not specifying a certificate group id:

ServerConfigurator.exe /enableencryption /thumbprint=[string]

The error message in your post is coming from the server configurator so either that component is failing, or we're calling it wrong from the module. If the server configurator is erroneously throwing errors when trying to update services to use the new certificate, I'd recommend opening a technical support case with our product support team as it's no longer a PowerShell module issue - it's an issue with the native CLI for the server configurator.

djarbz commented 1 month ago

Hello, I just tested this out and it ran without issue. That being said, it was with the same certificate that is already installed and configured.