Certify the Web \ Lets Encrypt Powershell to Update Certificate on SQL Server Bindings

# ==============================================================================
# Update-SQLServerCertificate.ps1
#
# Post-Renewal Task script for Certify The Web / Let's Encrypt
#
# What it does:
#   1. Pulls certificate details from the Certify $result object
#   2. Auto-discovers ALL SQL Server instances via the registry
#   3. For each instance:
#      a. Writes the thumbprint as REG_SZ to:
#           HKLM\SOFTWARE\Microsoft\Microsoft SQL Server\
#             <InstanceKey>\MSSQLServer\SuperSocketNetLib\Certificate
#         (confirmed from live SSCM export — this is exactly where SSCM reads it)
#      b. Grants Read on the private key to the SQL service account
#      c. Restarts the service (only if StartMode = Auto)
#   4. Writes a timestamped audit log
#
# Usage in Certify The Web:
#   Managed Certificate > Deployment > Add Deployment Task > Run PowerShell Script
#   Script: C:\Scripts\Update-SQLServerCertificate.ps1
#
# KEY REGISTRY FACT (confirmed from live SSCM export):
#   Certificate thumbprint lives at:
#     HKLM\SOFTWARE\Microsoft\Microsoft SQL Server\<InstanceKey>\
#       MSSQLServer\SuperSocketNetLib\Certificate
#   NOT under the \Ssl subkey — that path does not exist in a default installation.
#   Thumbprint must be lowercase, no spaces, no leading/trailing characters.
# ==============================================================================

param($result)

# --- OPTIONAL CONFIGURATION ---------------------------------------------------
$LogFile     = "C:\Logs\SQLCertRenewal.log"
$SkipRestart = $false          # Set $true to test without bouncing SQL services
# ------------------------------------------------------------------------------


# ==============================================================================
# LOGGING
# ==============================================================================

function Write-Log {
    param([string]$Message, [string]$Level = "INFO")
    $timestamp = Get-Date -Format "yyyy-MM-dd HH:mm:ss"
    $entry = "[$timestamp] [$Level] $Message"
    Write-Host $entry
    $logDir = Split-Path $LogFile -Parent
    if (-not (Test-Path $logDir)) { New-Item -ItemType Directory -Path $logDir -Force | Out-Null }
    Add-Content -LiteralPath $LogFile -Value $entry
}


# ==============================================================================
# STEP 0 — Validate Certify $result object
# ==============================================================================

Write-Log "============================================================"
Write-Log "SQL Server Certificate Renewal Script Started"


$RawThumbprint = $result.ManagedItem.CertificateThumbprintHash
$PfxPath       = $result.ManagedItem.CertificatePath
$PrimaryDomain = $result.ManagedItem.RequestConfig.PrimaryDomain

if (-not $RawThumbprint) {
    Write-Log "ERROR: CertificateThumbprintHash is empty. Cannot proceed." -Level "ERROR"
    exit 1
}

# Sanitise the thumbprint:
#   - Strip everything that is not a hex character (removes spaces, dashes,
#     hidden Unicode, zero-width spaces, BOM characters etc.)
#   - Force lowercase — confirmed from live SSCM registry export:
#     SSCM writes the thumbprint as lowercase, SQL Server loads it correctly.
$NewThumbprint = ($RawThumbprint -replace '[^a-fA-F0-9]', '').ToLower()

Write-Log "Certify Renewal Successful"
Write-Log "Primary Domain  : $PrimaryDomain"
Write-Log "PFX Path        : $PfxPath"
Write-Log "Raw Thumbprint  : $RawThumbprint"
Write-Log "Clean Thumbprint: $NewThumbprint  (lowercase, no spaces)"
Write-Log "Previous Thumb  : $($result.ManagedItem.CertificatePreviousThumbprintHash)"

# Load the cert from LocalMachine\My — the store comparison is case-insensitive
$cert = Get-ChildItem -Path "Cert:\LocalMachine\My" |
        Where-Object { $_.Thumbprint -ieq $NewThumbprint } |
        Select-Object -First 1

if (-not $cert) {
    Write-Log "ERROR: Certificate '$NewThumbprint' not found in LocalMachine\My store." -Level "ERROR"
    exit 1
}

Write-Log "Certificate found: Subject=$($cert.Subject) | Expires=$($cert.NotAfter)"

# Inform if SAN cert — these work fine in SQL Server but may not show in SSCM dropdown
$san = $cert.Extensions | Where-Object { $_.Oid.FriendlyName -eq "Subject Alternative Name" }
if ($san) {
    Write-Log "INFO: Certificate has Subject Alternative Names (SAN). SQL Server will use it correctly via registry, but it may not appear in the SSCM certificate dropdown — this is expected." -Level "INFO"
}


# ==============================================================================
# STEP 1 — Discover SQL instances via registry (authoritative source)
# ==============================================================================

Write-Log "------------------------------------------------------------"
Write-Log "Discovering SQL Server instances..."

$regInstanceRoot = "HKLM:\SOFTWARE\Microsoft\Microsoft SQL Server\Instance Names\SQL"

if (-not (Test-Path $regInstanceRoot)) {
    Write-Log "ERROR: Registry path not found: $regInstanceRoot" -Level "ERROR"
    Write-Log "ERROR: No SQL Server instances found on this machine." -Level "ERROR"
    exit 1
}

# Each value under Instance Names\SQL is: InstanceName -> InstanceKey
# e.g.  MSSQLSERVER  -> MSSQL15.MSSQLSERVER
#       CASEMANAGER  -> MSSQL15.CASEMANAGER
$instanceMap = @{}
$regProps = Get-ItemProperty -Path $regInstanceRoot
$regProps.PSObject.Properties |
    Where-Object { $_.Name -notmatch "^PS" } |
    ForEach-Object { $instanceMap[$_.Name] = $_.Value }

if ($instanceMap.Count -eq 0) {
    Write-Log "ERROR: No SQL Server instances found in registry." -Level "ERROR"
    exit 1
}

Write-Log "Instances discovered ($($instanceMap.Count)): $($instanceMap.Keys -join ', ')"
foreach ($kv in $instanceMap.GetEnumerator()) {
    Write-Log "  $($kv.Key) -> $($kv.Value)"
}


# ==============================================================================
# HELPERS
# ==============================================================================

function Get-SqlServiceInfo {
    param([string]$InstanceName)
    # Default instance service name is MSSQLSERVER; named instances are MSSQL$<name>
    $serviceName = if ($InstanceName -eq "MSSQLSERVER") { "MSSQLSERVER" } else { "MSSQL`$$InstanceName" }
    $svc = Get-WmiObject -Class Win32_Service -Filter "Name='$serviceName'" -ErrorAction SilentlyContinue
    if ($svc) {
        Write-Log "  Service   : $serviceName  |  Account : $($svc.StartName)  |  StartMode : $($svc.StartMode)"
        return @{
            ServiceName    = $serviceName
            ServiceAccount = $svc.StartName
            StartMode      = $svc.StartMode   # "Auto", "Manual", "Disabled"
        }
    }
    Write-Log "  WARNING: Service '$serviceName' not found in SCM." -Level "WARN"
    return $null
}


function Get-SqlNetLibRegPath {
    <#
    .SYNOPSIS
        Returns the SuperSocketNetLib registry key path for a SQL instance.
        This is where SSCM reads and writes the Certificate thumbprint value.

    .NOTES
        Correct path (confirmed from live SSCM registry export):
          HKLM\SOFTWARE\Microsoft\Microsoft SQL Server\<InstanceKey>\
            MSSQLServer\SuperSocketNetLib

        The Certificate value sits DIRECTLY under SuperSocketNetLib.
        There is NO \Ssl subkey in a default SQL Server installation.
    #>
    param([string]$InstanceKey)
    $path = "HKLM:\SOFTWARE\Microsoft\Microsoft SQL Server\$InstanceKey\MSSQLServer\SuperSocketNetLib"
    if (Test-Path $path) {
        Write-Log "  Registry  : $path"
        return $path
    }
    Write-Log "  WARNING: Registry path not found: $path" -Level "WARN"
    return $null
}




function Grant-PrivateKeyPermission {
    param(
        [System.Security.Cryptography.X509Certificates.X509Certificate2]$Cert,
        [string]$ServiceAccount
    )
    Write-Log "  Granting private key Read to: $ServiceAccount"

    # Try CNG key first (used by modern Let's Encrypt certs)
    try {
        $rsaKey = [System.Security.Cryptography.X509Certificates.RSACertificateExtensions]::GetRSAPrivateKey($Cert)
        if ($rsaKey -is [System.Security.Cryptography.RSACng]) {
            $keyName = $rsaKey.Key.UniqueName
            $keyFile = @(
                "$env:ProgramData\Microsoft\Crypto\Keys\$keyName",
                "$env:ProgramData\Microsoft\Crypto\RSA\MachineKeys\$keyName"
            ) | Where-Object { Test-Path $_ } | Select-Object -First 1

            if (-not $keyFile) { throw "CNG key file not found for UniqueName: $keyName" }
            Write-Log "  CNG key file : $keyFile"
            $acl  = Get-Acl -Path $keyFile
            $rule = New-Object System.Security.AccessControl.FileSystemAccessRule($ServiceAccount, "Read", "Allow")
            $acl.AddAccessRule($rule)
            Set-Acl -Path $keyFile -AclObject $acl
            Write-Log "  CNG permission granted."
            return
        }
    } catch {
        Write-Log "  CNG key attempt failed, trying CAPI: $_" -Level "WARN"
    }

    # Fallback: CAPI key (legacy)
    try {
        $keyContainer = $Cert.PrivateKey.CspKeyContainerInfo.UniqueKeyContainerName
        $keyPath      = "$env:ProgramData\Microsoft\Crypto\RSA\MachineKeys\$keyContainer"
        if (-not (Test-Path $keyPath)) { throw "CAPI key file not found: $keyPath" }
        Write-Log "  CAPI key file: $keyPath"
        $acl  = Get-Acl -Path $keyPath
        $rule = New-Object System.Security.AccessControl.FileSystemAccessRule($ServiceAccount, "Read", "Allow")
        $acl.AddAccessRule($rule)
        Set-Acl -Path $keyPath -AclObject $acl
        Write-Log "  CAPI permission granted."
    } catch {
        throw "Could not grant private key permission (tried CNG and CAPI): $_"
    }
}


# ==============================================================================
# STEP 2 — Process each SQL instance
# ==============================================================================

$successCount = 0
$failCount    = 0

foreach ($instanceName in $instanceMap.Keys) {

    $instanceKey = $instanceMap[$instanceName]

    Write-Log "------------------------------------------------------------"
    Write-Log "Processing instance : $instanceName  (key: $instanceKey)"

    try {

        # ---- Service info -------------------------------------------------------
        $svcInfo = Get-SqlServiceInfo -InstanceName $instanceName
        if (-not $svcInfo) {
            Write-Log "  Skipping — service not found in SCM." -Level "WARN"
            $failCount++
            continue
        }

        # ---- Registry path ------------------------------------------------------
        # Path: ...\<InstanceKey>\MSSQLServer\SuperSocketNetLib
        # Value: Certificate  (directly under SuperSocketNetLib — NOT under \Ssl)
        $regPath = Get-SqlNetLibRegPath -InstanceKey $instanceKey
        if (-not $regPath) {
            Write-Log "  Skipping — SuperSocketNetLib registry path not found." -Level "WARN"
            $failCount++
            continue
        }

        $oldThumb = (Get-ItemProperty -Path $regPath -Name "Certificate" -ErrorAction SilentlyContinue).Certificate
        Write-Log "  Current thumbprint : '$oldThumb'"
        Write-Log "  New thumbprint     : '$NewThumbprint'"

        # ---- Write thumbprint to registry as REG_SZ -----------------------------
        Set-ItemProperty -Path $regPath -Name "Certificate" -Value $NewThumbprint -ErrorAction Stop
        Write-Log "  Registry write: done"

        # Confirm what is now in the registry
        $writtenThumb = (Get-ItemProperty -Path $regPath -Name "Certificate" -ErrorAction SilentlyContinue).Certificate
        Write-Log "  Verified registry : '$writtenThumb'"

        if ($writtenThumb -ne $NewThumbprint) {
            throw "Registry verify failed — expected '$NewThumbprint' but found '$writtenThumb'"
        }

        # ---- Private key permission ---------------------------------------------
        Grant-PrivateKeyPermission -Cert $cert -ServiceAccount $svcInfo.ServiceAccount

        # ---- Service restart (Auto-start services only) -------------------------
        if ($SkipRestart) {
            Write-Log "  SkipRestart=true — skipping service restart." -Level "WARN"
        } elseif ($svcInfo.StartMode -ne "Auto") {
            Write-Log "  Skipping restart — StartMode is '$($svcInfo.StartMode)' (only Auto services are restarted)." -Level "WARN"
        } else {
            Write-Log "  Stopping : $($svcInfo.ServiceName)"
            Stop-Service -Name $svcInfo.ServiceName -Force -ErrorAction Stop
            Start-Sleep -Seconds 3

            Write-Log "  Starting : $($svcInfo.ServiceName)"
            Start-Service -Name $svcInfo.ServiceName -ErrorAction Stop
            Start-Sleep -Seconds 5

            $status = (Get-Service -Name $svcInfo.ServiceName).Status
            if ($status -ne "Running") {
                throw "Service did not reach Running state after restart. Status: $status"
            }
            Write-Log "  Service running." -Level "OK"
        }

        Write-Log "  Instance '$instanceName' complete." -Level "OK"
        $successCount++

    } catch {
        Write-Log "  ERROR processing '$instanceName': $_" -Level "ERROR"

        # Pull recent SQL errors from Application event log to aid diagnosis
        try {
            $events = Get-EventLog -LogName Application -Source "MSSQL*" -Newest 3 -ErrorAction SilentlyContinue |
                      Where-Object { $_.EntryType -eq "Error" }
            if ($events) {
                Write-Log "  Recent SQL Application event errors:" -Level "ERROR"
                $events | ForEach-Object {
                    Write-Log "    [$($_.TimeGenerated)] $($_.Message.Split("`n")[0])" -Level "ERROR"
                }
            }
        } catch { }

        $failCount++
    }
}


# ==============================================================================
# SUMMARY
# ==============================================================================

Write-Log "------------------------------------------------------------"
Write-Log "Summary      : Success=$successCount | Failed=$failCount"
Write-Log "Thumbprint   : $NewThumbprint"
Write-Log "Cert expiry  : $($cert.NotAfter)"
Write-Log "============================================================"

exit $(if ($failCount -gt 0) { 1 } else { 0 })
1 Star2 Stars3 Stars4 Stars5 Stars (No Ratings Yet)
Loading...