Files
PacketStation/scripts/Request-VsqlServerCertificate.ps1
T
spencermandClaude Opus 5.5 90adcebc44
ci / frontend (push) Successful in 43s
ci / server (push) Successful in 11m3s
ci / image (push) Successful in 12s
ci / e2e (push) Failing after 4m2s
scripts: vsql certificate script fixes the six review findings, with a Pester suite on PowerShell 5.1 and 7
- Admin check uses -not (IsAdmin); the old call passed -eq $false as arguments.
- Named instances: WQL filter Name='MSSQL$DEV' unescaped, lookup errors surface.
- SANs decoded from the raw extension (DER walk) and matched as whole names.
- Immutable deploy-, request- and rollback- records; already-bound instances
  get no snapshot; -Rollback takes the newest snapshot not yet rolled back,
  -Snapshot picks one.
- certreq -new, -submit, -accept and the key grants go through ShouldProcess;
  a declined or failed prerequisite binds nothing.
- Rollback and automatic restore work per instance, read back, report failures.

Also found while testing: PowerShell 7 returns RSACng for a CSP key, so the
provider check now reads CERT_KEY_PROV_INFO; ForEach-Object member form returned
nothing under -WhatIf; stderr capture under Stop threw on 5.1; LocalSystem and
NetworkService accounts go to icacls as SIDs; SQL Agent is started again after
a restart; signature checked by OID.

DEPLOY.md gains the run procedure: preflight, DEV, docker-1 checks, production, rollback.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-18 21:41:03 -06:00

750 lines
42 KiB
PowerShell

<#
.SYNOPSIS
Requests a SQL Server TLS certificate for vsql from PESCO-PSYLOCKE-CA and binds it to the instances.
.DESCRIPTION
Run on the SQL Server host (the machine behind vsql.pesco.com) in an elevated PowerShell, as a
domain account that may enroll from the chosen template. Works on Windows PowerShell 5.1 and
PowerShell 7. Steps:
1. Resolve the SQL Server instances to configure and their service accounts. Unknown names
in -Instances are an error, nothing is touched.
2. Write a certreq INF with what SQL Server requires (Microsoft certificate requirements):
a legacy CryptoAPI provider, Microsoft RSA SChannel Cryptographic Provider, with KeySpec
AT_KEYEXCHANGE; RSA 2048; SHA-256; Server Authentication EKU; key usage digital signature
and key encipherment; subject CN=vsql.pesco.com; subject alternative names vsql.pesco.com,
vsql, the host FQDN and short name; machine store; exportable.
3. certreq -new, -submit and -accept, each confirmed through ShouldProcess. A pending request
is saved in its own request-<time>.json; issue it on the CA, retrieve it, then rerun with
-CerFile (or -Thumbprint once installed).
4. Validate the installed certificate before anything is bound: a private key from a CSP
(provider type 12) with KeySpec AT_KEYEXCHANGE, read from the certificate's own key
provider property; 2048 bits or more; SHA-256 family signature; valid for 30 more days;
Server Authentication EKU; digital signature and key encipherment if key usage is set;
every required DNS name, decoded from the SAN extension and compared as whole names.
5. Grant the selected instances' service accounts read on the private key. A declined or
failed grant cancels the run before any instance is changed.
6. Instances already bound to this thumbprint are left alone. For the others, save their
current Certificate values in a new deploy-<time>.json (never overwritten), then bind the
thumbprint (the registry value SQL Server Configuration Manager sets) and read it back.
ForceEncryption is left as it is. A failure restores every instance changed in this run,
each on its own, verified.
7. Print the restart commands, or restart with -RestartInstances (a short VISUAL outage).
Services that depend on the instance (SQL Server Agent) are started again if they ran.
-WhatIf previews every step without changing anything. -Confirm asks before each change.
-Rollback restores a saved deployment snapshot.
.PARAMETER CertificateAuthority
CA config string host\name. Default DC-02.PESCO.COM\PESCO-PSYLOCKE-CA.
.PARAMETER Template
Certificate template name. Default WebServer. The template must allow Server Authentication
and must not force a Key Storage Provider.
.PARAMETER DnsNames
Names the certificate must carry. The first is the subject CN. The host FQDN and short name
are always added.
.PARAMETER Instances
Instance names to configure (MSSQLSERVER for the default instance, DEV for vsql\DEV).
Default: every instance on this host.
.PARAMETER CerFile
Continue from an issued certificate file (after a pending request was issued and retrieved).
.PARAMETER Thumbprint
Continue from a certificate already in the machine store; skips the request.
.PARAMETER RestartInstances
Restart each configured instance so the certificate (or the rollback) takes effect.
.PARAMETER Rollback
Restore the Certificate values saved by a deployment snapshot and stop. Each instance is
restored on its own and read back; only verified instances count as restored. The default
snapshot is the newest one with instances not yet restored (a rollback-<time>.json records
what was restored), so each -Rollback steps back one deployment. Run it with -WhatIf first
to see which one it picks.
.PARAMETER Snapshot
With -Rollback: the snapshot file (name or path) to restore instead of the default.
.EXAMPLE
.\Request-VsqlServerCertificate.ps1 -Instances DEV -WhatIf
.EXAMPLE
.\Request-VsqlServerCertificate.ps1 -Instances DEV
.EXAMPLE
.\Request-VsqlServerCertificate.ps1 -CerFile C:\ProgramData\PacketStation\vsql-cert\vsql-20261018-090000-000.cer -Instances DEV -RestartInstances
.EXAMPLE
.\Request-VsqlServerCertificate.ps1 -Rollback -WhatIf
#>
[CmdletBinding(SupportsShouldProcess, DefaultParameterSetName = 'Request')]
param(
[string]$CertificateAuthority = 'DC-02.PESCO.COM\PESCO-PSYLOCKE-CA',
[string]$Template = 'WebServer',
[string[]]$DnsNames = @('vsql.pesco.com', 'vsql'),
[string[]]$Instances,
[Parameter(ParameterSetName = 'Cer', Mandatory)][string]$CerFile,
[Parameter(ParameterSetName = 'Thumb', Mandatory)][string]$Thumbprint,
[switch]$RestartInstances,
[Parameter(ParameterSetName = 'Rollback', Mandatory)][switch]$Rollback,
[Parameter(ParameterSetName = 'Rollback')][string]$Snapshot
)
$script:AcceptedSignatureOids = @{
'1.2.840.113549.1.1.11' = 'sha256RSA'
'1.2.840.113549.1.1.12' = 'sha384RSA'
'1.2.840.113549.1.1.13' = 'sha512RSA'
}
$script:SchannelProvider = 'Microsoft RSA SChannel Cryptographic Provider'
# --- helpers that touch nothing --------------------------------------------------------------
function Test-SameValue($A, $B) {
# Registry values: $null means absent, '' means present and empty. Thumbprints ignore case.
# Untyped on purpose: a [string] parameter turns $null into ''.
if ($null -eq $A -or $null -eq $B) { return ($null -eq $A -and $null -eq $B) }
return [string]::Equals([string]$A, [string]$B, [StringComparison]::OrdinalIgnoreCase)
}
function Get-UniqueNames([string[]]$Names) {
$seen = New-Object 'System.Collections.Generic.HashSet[string]' ([StringComparer]::OrdinalIgnoreCase)
$out = New-Object System.Collections.Generic.List[string]
foreach ($n in $Names) { if ($n -and $seen.Add($n)) { $out.Add($n) } }
return $out.ToArray()
}
function Get-SqlServiceName([string]$Instance) {
if ($Instance -eq 'MSSQLSERVER') { return 'MSSQLSERVER' }
return 'MSSQL$' + $Instance
}
function Get-ServiceFilter([string]$ServiceName) {
# WQL string literal. `$` needs no escape; a quote or backslash never occurs in a service name.
if ($ServiceName -match "['\\]") { throw "Unexpected character in service name '$ServiceName'." }
return "Name='$ServiceName'"
}
function Read-DerLength([byte[]]$Bytes, [ref]$Position) {
if ($Position.Value -ge $Bytes.Length) { throw 'SAN extension is truncated.' }
$first = [int]$Bytes[$Position.Value]; $Position.Value++
if ($first -lt 0x80) { return $first }
$count = $first -band 0x7F
if ($count -lt 1 -or $count -gt 3) { throw 'SAN extension has an unsupported length encoding.' }
$length = 0
for ($i = 0; $i -lt $count; $i++) {
if ($Position.Value -ge $Bytes.Length) { throw 'SAN extension is truncated.' }
$length = ($length * 256) + [int]$Bytes[$Position.Value]; $Position.Value++
}
return $length
}
function Get-SanDnsNames([byte[]]$RawData) {
# SubjectAltName ::= SEQUENCE OF GeneralName; dNSName is [2] IMPLICIT IA5String (tag 0x82).
# A plain DER walk, the same on Windows PowerShell 5.1 and PowerShell 7, independent of
# display formatting and language.
if (-not $RawData -or $RawData.Length -lt 2 -or $RawData[0] -ne 0x30) { throw 'SAN extension is not a DER SEQUENCE.' }
$pos = 1
$length = Read-DerLength $RawData ([ref]$pos)
$end = $pos + $length
if ($end -ne $RawData.Length) { throw 'SAN extension length does not match its content.' }
$names = New-Object System.Collections.Generic.List[string]
while ($pos -lt $end) {
$tag = [int]$RawData[$pos]; $pos++
if (($tag -band 0x1F) -eq 0x1F) { throw 'SAN extension uses a multi-byte tag.' }
$len = Read-DerLength $RawData ([ref]$pos)
if ($pos + $len -gt $end) { throw 'SAN extension is truncated.' }
if ($tag -eq 0x82) { $names.Add([System.Text.Encoding]::ASCII.GetString($RawData, $pos, $len)) }
$pos += $len
}
return $names.ToArray()
}
function Get-CertificateKeyInfo([System.Security.Cryptography.X509Certificates.X509Certificate2]$Certificate) {
# The certificate's CERT_KEY_PROV_INFO property, which is what SQL Server uses to find the key.
# $cert.PrivateKey is not usable for this: PowerShell 7 (.NET 5 and later) returns RSACng
# even for a CSP key, Windows PowerShell 5.1 returns RSACryptoServiceProvider.
if (-not ('PacketStation.KeyProvInfo' -as [type])) {
Add-Type -TypeDefinition @'
using System;
using System.Runtime.InteropServices;
namespace PacketStation {
public static class KeyProvInfo {
[StructLayout(LayoutKind.Sequential, CharSet = CharSet.Unicode)]
struct CRYPT_KEY_PROV_INFO {
public string pwszContainerName;
public string pwszProvName;
public uint dwProvType;
public uint dwFlags;
public uint cProvParam;
public IntPtr rgProvParam;
public uint dwKeySpec;
}
[DllImport("crypt32.dll", SetLastError = true)]
static extern bool CertGetCertificateContextProperty(IntPtr pCertContext, uint dwPropId, IntPtr pvData, ref uint pcbData);
public static object[] Get(IntPtr certContext) {
uint size = 0;
if (!CertGetCertificateContextProperty(certContext, 2, IntPtr.Zero, ref size)) return null;
IntPtr buffer = Marshal.AllocHGlobal((int)size);
try {
if (!CertGetCertificateContextProperty(certContext, 2, buffer, ref size)) return null;
CRYPT_KEY_PROV_INFO info = (CRYPT_KEY_PROV_INFO)Marshal.PtrToStructure(buffer, typeof(CRYPT_KEY_PROV_INFO));
return new object[] { info.pwszContainerName, info.pwszProvName, (int)info.dwProvType, (int)info.dwFlags, (int)info.dwKeySpec };
} finally { Marshal.FreeHGlobal(buffer); }
}
}
}
'@
}
$raw = [PacketStation.KeyProvInfo]::Get($Certificate.Handle)
if (-not $raw) { return $null }
$info = [pscustomobject]@{
Container = [string]$raw[0]
Provider = [string]$raw[1]
ProviderType = [int]$raw[2]
Machine = (([int]$raw[3]) -band 0x20) -ne 0 # CRYPT_MACHINE_KEYSET
KeySpec = [int]$raw[4]
UniqueName = $null
Error = $null
}
if ($info.ProviderType -ne 0) {
try {
$cp = New-Object System.Security.Cryptography.CspParameters $info.ProviderType, $info.Provider, $info.Container
$cp.KeyNumber = $info.KeySpec
$cp.Flags = [System.Security.Cryptography.CspProviderFlags]::UseExistingKey
if ($info.Machine) { $cp.Flags = $cp.Flags -bor [System.Security.Cryptography.CspProviderFlags]::UseMachineKeyStore }
$rsa = New-Object System.Security.Cryptography.RSACryptoServiceProvider $cp
try { $info.UniqueName = $rsa.CspKeyContainerInfo.UniqueKeyContainerName } finally { $rsa.Dispose() }
}
catch { $info.Error = $_.Exception.Message }
}
return $info
}
function Test-ServerCertificate {
param(
[System.Security.Cryptography.X509Certificates.X509Certificate2]$Certificate,
[string[]]$RequiredNames,
[datetime]$Now = (Get-Date),
[object]$KeyInfo = $(if ($Certificate.HasPrivateKey) { Get-CertificateKeyInfo $Certificate })
)
$problems = New-Object System.Collections.Generic.List[string]
if (-not $Certificate.HasPrivateKey) { $problems.Add('no private key') }
elseif (-not $KeyInfo) { $problems.Add('no key provider information on the certificate') }
elseif ($KeyInfo.ProviderType -eq 0) { $problems.Add("the private key is in a Key Storage Provider ($($KeyInfo.Provider)); SQL Server needs a CryptoAPI (CSP) key") }
else {
if ($KeyInfo.ProviderType -ne 12) { $problems.Add("provider type $($KeyInfo.ProviderType) ($($KeyInfo.Provider)); expected 12, $script:SchannelProvider") }
if ($KeyInfo.KeySpec -ne 1) { $problems.Add("KeySpec is $($KeyInfo.KeySpec); SQL Server needs AT_KEYEXCHANGE (1)") }
if ($KeyInfo.Error) { $problems.Add("the private key container cannot be opened: $($KeyInfo.Error)") }
}
$rsaPublic = [System.Security.Cryptography.X509Certificates.RSACertificateExtensions]::GetRSAPublicKey($Certificate)
$keySize = if ($rsaPublic) { $rsaPublic.KeySize } else { 0 }
if (-not $rsaPublic) { $problems.Add('the key is not RSA') }
elseif ($keySize -lt 2048) { $problems.Add("key size $keySize is below 2048") }
$sigOid = $Certificate.SignatureAlgorithm.Value
$sig = if ($script:AcceptedSignatureOids.ContainsKey($sigOid)) { $script:AcceptedSignatureOids[$sigOid] } else { "$($Certificate.SignatureAlgorithm.FriendlyName) ($sigOid)" }
if (-not $script:AcceptedSignatureOids.ContainsKey($sigOid)) { $problems.Add("signature algorithm $sig is not SHA-256, SHA-384 or SHA-512 with RSA") }
if ($Certificate.NotBefore -gt $Now -or $Certificate.NotAfter -lt $Now) { $problems.Add("not valid now (valid $($Certificate.NotBefore.ToString('yyyy-MM-dd')) to $($Certificate.NotAfter.ToString('yyyy-MM-dd')))") }
elseif ($Certificate.NotAfter -lt $Now.AddDays(30)) { $problems.Add("expires $($Certificate.NotAfter.ToString('yyyy-MM-dd')), within 30 days") }
$ekuExt = $Certificate.Extensions | Where-Object { $_.Oid.Value -eq '2.5.29.37' } | Select-Object -First 1
$ekus = @()
if ($ekuExt) { $ekus = @((New-Object System.Security.Cryptography.X509Certificates.X509EnhancedKeyUsageExtension $ekuExt, $ekuExt.Critical).EnhancedKeyUsages | ForEach-Object { $_.Value }) }
if ($ekus -notcontains '1.3.6.1.5.5.7.3.1') { $problems.Add('Server Authentication EKU missing') }
$kuExt = $Certificate.Extensions | Where-Object { $_.Oid.Value -eq '2.5.29.15' } | Select-Object -First 1
if ($kuExt) {
$ku = (New-Object System.Security.Cryptography.X509Certificates.X509KeyUsageExtension $kuExt, $kuExt.Critical).KeyUsages
$flags = [System.Security.Cryptography.X509Certificates.X509KeyUsageFlags]
if (-not ($ku -band $flags::KeyEncipherment)) { $problems.Add('key usage lacks key encipherment') }
if (-not ($ku -band $flags::DigitalSignature)) { $problems.Add('key usage lacks digital signature') }
}
$dns = @()
$sanExt = $Certificate.Extensions | Where-Object { $_.Oid.Value -eq '2.5.29.17' } | Select-Object -First 1
if (-not $sanExt) { $problems.Add('no subject alternative name extension') }
else {
try { $dns = @(Get-SanDnsNames $sanExt.RawData) } catch { $problems.Add("subject alternative names unreadable: $($_.Exception.Message)") }
}
foreach ($n in $RequiredNames) {
$hit = @($dns | Where-Object { [string]::Equals($_, $n, [StringComparison]::OrdinalIgnoreCase) })
if (-not $hit) { $problems.Add("subject alternative name $n missing") }
}
return [pscustomobject]@{ Problems = $problems; KeyInfo = $KeyInfo; KeySize = $keySize; Signature = $sig; DnsNames = $dns }
}
function New-CertRequestInf([string]$Primary, [string[]]$Names, [string]$Template, [string]$Stamp) {
$sanLines = ($Names | ForEach-Object { "_continue_ = `"dns=$_&`"" }) -join "`r`n"
return @"
[Version]
Signature = "`$Windows NT`$"
[NewRequest]
Subject = "CN=$Primary"
KeyLength = 2048
KeyAlgorithm = RSA
HashAlgorithm = SHA256
; SQL Server needs a legacy CryptoAPI provider and an exchange key (KeySpec = AT_KEYEXCHANGE).
ProviderName = "$script:SchannelProvider"
ProviderType = 12
KeySpec = 1
; digital signature + key encipherment
KeyUsage = 0xA0
Exportable = TRUE
MachineKeySet = TRUE
RequestType = PKCS10
FriendlyName = "SQL Server TLS $Primary ($Stamp)"
[EnhancedKeyUsageExtension]
OID = 1.3.6.1.5.5.7.3.1
[Extensions]
2.5.29.17 = "{text}"
$sanLines
[RequestAttributes]
CertificateTemplate = $Template
"@
}
function Invoke-NativeCommand([string]$FilePath, [string[]]$Arguments) {
# Windows PowerShell 5.1 turns redirected stderr into a terminating error under 'Stop'.
$ErrorActionPreference = 'Continue'
$out = @(& $FilePath @Arguments 2>&1 | ForEach-Object { "$_" })
return [pscustomobject]@{ ExitCode = $LASTEXITCODE; Output = $out }
}
function Resolve-AccountSid([string]$Account) {
switch -Regex ($Account) {
'^(LocalSystem|NT AUTHORITY\\SYSTEM)$' { return 'S-1-5-18' }
'^NT AUTHORITY\\(LocalService|LOCAL SERVICE)$' { return 'S-1-5-19' }
'^NT AUTHORITY\\(NetworkService|NETWORK SERVICE)$' { return 'S-1-5-20' }
}
$name = if ($Account -like '.\*') { "$env:COMPUTERNAME\$($Account.Substring(2))" } else { $Account }
return (New-Object System.Security.Principal.NTAccount $name).Translate([System.Security.Principal.SecurityIdentifier]).Value
}
# --- the host, as replaceable operations ------------------------------------------------------
function Get-VsqlDefaultProvider {
@{
WorkDir = Join-Path $env:ProgramData 'PacketStation\vsql-cert'
InstanceRoot = 'HKLM:\SOFTWARE\Microsoft\Microsoft SQL Server'
IsAdmin = { ([Security.Principal.WindowsPrincipal][Security.Principal.WindowsIdentity]::GetCurrent()).IsInRole([Security.Principal.WindowsBuiltInRole]::Administrator) }
GetInstanceMap = {
param($Root)
$p = Get-ItemProperty -LiteralPath "$Root\Instance Names\SQL" -ErrorAction SilentlyContinue
$map = [ordered]@{}
if ($p) { foreach ($x in $p.PSObject.Properties) { if ($x.Name -notlike 'PS*') { $map[$x.Name] = [string]$x.Value } } }
$map
}
GetService = { param($Name) Get-CimInstance Win32_Service -Filter (Get-ServiceFilter $Name) -ErrorAction Stop }
TestRegKey = { param($Key) Test-Path -LiteralPath $Key }
GetRegValue = {
param($Key, $Name)
$p = Get-ItemProperty -LiteralPath $Key -Name $Name -ErrorAction SilentlyContinue
if ($p) { [string]$p.$Name } else { $null }
}
SetRegValue = { param($Key, $Name, $Value) Set-ItemProperty -LiteralPath $Key -Name $Name -Value $Value -ErrorAction Stop -Confirm:$false }
RemoveRegValue = { param($Key, $Name) Remove-ItemProperty -LiteralPath $Key -Name $Name -ErrorAction Stop -Confirm:$false }
GetHostNames = { @([System.Net.Dns]::GetHostEntry($env:COMPUTERNAME).HostName, $env:COMPUTERNAME) }
GetMachineCert = { param($Thumb) Get-ChildItem Cert:\LocalMachine\My | Where-Object { $_.Thumbprint -eq $Thumb } | Select-Object -First 1 }
Certreq = { param([string[]]$Arguments) Invoke-NativeCommand 'certreq.exe' $Arguments }
Icacls = { param([string[]]$Arguments) Invoke-NativeCommand 'icacls.exe' $Arguments }
ResolveSid = { param($Account) Resolve-AccountSid $Account }
GetKeyInfo = { param($Cert) Get-CertificateKeyInfo $Cert }
KeyFilePath = { param($KeyInfo) Join-Path $env:ProgramData "Microsoft\Crypto\RSA\MachineKeys\$($KeyInfo.UniqueName)" }
TestFile = { param($Path) Test-Path -LiteralPath $Path -PathType Leaf }
RestartService = {
param($Name)
$running = @((Get-Service -Name $Name).DependentServices | Where-Object { $_.Status -eq 'Running' })
Restart-Service -Name $Name -Force -ErrorAction Stop -Confirm:$false
foreach ($d in $running) { Start-Service -Name $d.Name -ErrorAction Stop -Confirm:$false }
@($running | ForEach-Object { $_.Name })
}
Now = { Get-Date }
Log = { param($Message) Write-Host $Message }
ShouldProcess = $null # $null: the cmdlet's own ShouldProcess (-WhatIf, -Confirm)
}
}
# --- steps ------------------------------------------------------------------------------------
function Get-SqlInstances($P, [string[]]$Wanted) {
$map = & $P.GetInstanceMap $P.InstanceRoot
if (-not $map -or $map.Count -eq 0) { throw 'No SQL Server Database Engine instance is registered on this host. Is this the vsql machine?' }
$found = @($map.Keys)
if ($Wanted) {
$unknown = @($Wanted | Where-Object { $found -notcontains $_ })
if ($unknown) { throw "Unknown instance(s): $($unknown -join ', '). Found on this host: $($found -join ', '). Nothing was changed." }
$found = @($found | Where-Object { $Wanted -contains $_ })
}
$targets = foreach ($name in $found) {
$svc = Get-SqlServiceName $name
$service = & $P.GetService $svc
if (-not $service) { throw "Service $svc for instance $name was not found." }
if (-not $service.StartName) { throw "Service $svc for instance $name has no service account." }
$regKey = "$($P.InstanceRoot)\$($map[$name])\MSSQLServer\SuperSocketNetLib"
if (-not (& $P.TestRegKey $regKey)) { throw "Registry key for instance $name not found: $regKey" }
[pscustomobject]@{ Name = $name; Id = $map[$name]; Service = $svc; Account = [string]$service.StartName; RegKey = $regKey }
}
return @($targets)
}
function Write-NewJsonFile([string]$Path, [object]$Value) {
# Snapshots and request records are immutable: a file is written once and never replaced.
$dir = Split-Path -Parent $Path
if (-not (Test-Path -LiteralPath $dir)) { New-Item -ItemType Directory -Force -Path $dir -Confirm:$false | Out-Null }
$json = ConvertTo-Json -InputObject $Value -Depth 6
$stream = [System.IO.File]::Open($Path, [System.IO.FileMode]::CreateNew, [System.IO.FileAccess]::Write)
try {
$writer = New-Object System.IO.StreamWriter $stream, (New-Object System.Text.UTF8Encoding $false)
$writer.Write($json); $writer.Flush()
}
finally { $stream.Dispose() }
}
function Restore-InstanceBinding($P, [string]$RegKey, $Previous) {
# One instance, on its own. Returns $null when the value reads back as saved, else the problem.
try {
if ($null -eq $Previous) {
if ($null -ne (& $P.GetRegValue $RegKey 'Certificate')) { & $P.RemoveRegValue $RegKey 'Certificate' }
}
else { & $P.SetRegValue $RegKey 'Certificate' $Previous }
$now = & $P.GetRegValue $RegKey 'Certificate'
if (Test-SameValue $now $Previous) { return $null }
return "reads '$now' after restoring '$Previous'"
}
catch { return $_.Exception.Message }
}
function Format-Value($Value) {
if ($null -eq $Value) { return '(absent)' }
if ($Value -eq '') { return "''" }
return $Value
}
function Invoke-InstanceRestart($P, $Confirm, [object[]]$Targets) {
$failed = New-Object System.Collections.Generic.List[string]
$restarted = New-Object System.Collections.Generic.List[string]
foreach ($t in $Targets) {
if (& $Confirm $t.Service 'restart service (and start its running dependents again)') {
try {
$deps = @(& $P.RestartService $t.Service)
$restarted.Add($t.Name)
& $P.Log "Restarted $($t.Service)$(if ($deps) { "; started again: $($deps -join ', ')" })"
}
catch { $failed.Add("$($t.Service): $($_.Exception.Message)") }
}
}
return [pscustomobject]@{ Restarted = $restarted.ToArray(); Failed = $failed.ToArray() }
}
function New-RecordPath($P, [string]$Prefix) {
# deploy-, request- and rollback- records sort by name in time order and are never reused.
for ($i = 0; $i -lt 50; $i++) {
$path = Join-Path $P.WorkDir ("$Prefix-" + (& $P.Now).ToString('yyyyMMdd-HHmmss-fff') + '.json')
if (-not (Test-Path -LiteralPath $path)) { return $path }
Start-Sleep -Milliseconds 5
}
throw "No free $Prefix record name in $($P.WorkDir)."
}
function Get-RolledBackInstances($P) {
# Snapshot file name -> instance names already restored from it, from the rollback records.
$done = @{}
foreach ($f in @(Get-ChildItem -LiteralPath $P.WorkDir -Filter 'rollback-*.json' -File -ErrorAction SilentlyContinue)) {
$r = Get-Content -LiteralPath $f.FullName -Raw | ConvertFrom-Json
if (-not $done.ContainsKey($r.snapshot)) { $done[$r.snapshot] = @() }
$done[$r.snapshot] += @($r.instances)
}
return $done
}
function Invoke-VsqlRollback($P, $Confirm, [bool]$WhatIf, [string[]]$Instances, [string]$Snapshot, [bool]$RestartInstances) {
# Default: the newest snapshot with instances not yet restored, so each -Rollback steps back
# one deployment and a second run never re-applies a newer snapshot. -Snapshot picks one.
$files = @(Get-ChildItem -LiteralPath $P.WorkDir -Filter 'deploy-*.json' -File -ErrorAction SilentlyContinue | Sort-Object Name -Descending)
if (-not $files) { throw "No deployment snapshots in $($P.WorkDir)." }
$load = { param($f) Get-Content -LiteralPath $f.FullName -Raw | ConvertFrom-Json }
$pick = { param($s) @($s.instances | Where-Object { -not $Instances -or $Instances -contains $_.name }) }
if ($Instances -and -not $Snapshot) {
$known = @($files | ForEach-Object { (& $load $_).instances | ForEach-Object { $_.name } })
$unknown = @($Instances | Where-Object { $known -notcontains $_ })
if ($unknown) { throw "Instance(s) $($unknown -join ', ') are not in any snapshot. Nothing was changed." }
}
$chosen = $null
$entries = @()
if ($Snapshot) {
$file = $files | Where-Object { $_.Name -eq $Snapshot -or $_.FullName -eq $Snapshot -or $_.BaseName -eq $Snapshot } | Select-Object -First 1
if (-not $file) { throw "Snapshot $Snapshot not found. Snapshots: $(($files | ForEach-Object { $_.Name }) -join ', ')" }
$chosen = [pscustomobject]@{ File = $file; Data = (& $load $file) }
$entries = @(& $pick $chosen.Data)
}
else {
$done = Get-RolledBackInstances $P
foreach ($f in $files) {
$s = & $load $f
$restoredBefore = if ($done.ContainsKey($f.Name)) { @($done[$f.Name]) } else { @() }
$open = @(& $pick $s | Where-Object { $restoredBefore -notcontains $_.name })
if ($open) { $chosen = [pscustomobject]@{ File = $f; Data = $s }; $entries = $open; break }
}
if (-not $chosen) {
& $P.Log 'Every snapshot has been rolled back already. Nothing to do. Use -Snapshot to restore a specific one.'
return [pscustomobject]@{ Snapshot = $null; Restored = @(); Failed = @(); Restarted = @() }
}
}
if ($Instances) {
$unknown = @($Instances | Where-Object { @($chosen.Data.instances | ForEach-Object { $_.name }) -notcontains $_ })
if ($unknown) { throw "Instance(s) $($unknown -join ', ') are not in snapshot $($chosen.File.Name). Nothing was changed." }
}
& $P.Log "Snapshot $($chosen.File.Name): saved $($chosen.Data.at) before binding $($chosen.Data.thumbprint)."
$restored = New-Object System.Collections.Generic.List[object]
$failed = New-Object System.Collections.Generic.List[string]
foreach ($e in $entries) {
$prev = if ($null -eq $e.previous) { $null } else { [string]$e.previous }
if (-not (& $Confirm "instance $($e.name)" "restore Certificate to $(Format-Value $prev)")) { continue }
$problem = Restore-InstanceBinding $P $e.regKey $prev
if ($problem) { $failed.Add("$($e.name): $problem"); & $P.Log "FAILED to restore $($e.name): $problem" }
else {
$restored.Add([pscustomobject]@{ Name = $e.name; Service = $e.service; RegKey = $e.regKey })
& $P.Log "Restored $($e.name) to $(Format-Value $prev) (verified)"
}
}
if ($restored.Count) {
Write-NewJsonFile (New-RecordPath $P 'rollback') ([ordered]@{ at = (& $P.Now).ToString('s'); snapshot = $chosen.File.Name; instances = @($restored | ForEach-Object { $_.Name }) })
}
$restart = [pscustomobject]@{ Restarted = @(); Failed = @() }
if ($restored.Count) {
if ($RestartInstances) { $restart = Invoke-InstanceRestart $P $Confirm $restored.ToArray() }
else {
& $P.Log 'Restart the restored instances for the rollback to take effect:'
foreach ($r in $restored) { & $P.Log " Restart-Service -Name '$($r.Service)' -Force" }
}
}
$all = @($failed) + @($restart.Failed)
if ($all.Count) { throw "Rollback incomplete:`n - $($all -join "`n - ")" }
return [pscustomobject]@{ Snapshot = $chosen.File.FullName; Restored = @($restored | ForEach-Object { $_.Name }); Failed = @(); Restarted = $restart.Restarted }
}
function Invoke-VsqlCertificateDeployment {
[CmdletBinding(SupportsShouldProcess)]
param(
[Parameter(Mandatory)][hashtable]$Provider,
[string]$CertificateAuthority = 'DC-02.PESCO.COM\PESCO-PSYLOCKE-CA',
[string]$Template = 'WebServer',
[string[]]$DnsNames = @('vsql.pesco.com', 'vsql'),
[string[]]$Instances,
[string]$CerFile,
[string]$Thumbprint,
[switch]$RestartInstances,
[switch]$Rollback,
[string]$Snapshot
)
$ErrorActionPreference = 'Stop'
$P = $Provider
$whatIf = [bool]$WhatIfPreference
$cmdlet = $PSCmdlet
$confirm = if ($P.ShouldProcess) { $P.ShouldProcess } else { { param($Target, $Action) $cmdlet.ShouldProcess($Target, $Action) }.GetNewClosure() }
# A prerequisite (enrollment, key access) that is declined cancels the run; under -WhatIf the preview goes on.
$gate = {
param($Target, $Action)
if (& $confirm $Target $Action) { return $true }
if (-not $whatIf) { throw (New-Object System.OperationCanceledException "Cancelled: $Action on $Target was declined. Nothing was bound.") }
return $false
}.GetNewClosure()
if (-not (& $P.IsAdmin)) { throw 'Run this in an elevated PowerShell on the SQL Server host.' }
if ($CerFile -and $Thumbprint) { throw 'Use -CerFile or -Thumbprint, not both.' }
if ($Rollback) { return Invoke-VsqlRollback $P $confirm $whatIf $Instances $Snapshot $RestartInstances.IsPresent }
try {
# --- 1. instances ----------------------------------------------------------------------
$targets = @(Get-SqlInstances $P $Instances)
$names = @(Get-UniqueNames (@($DnsNames) + @(& $P.GetHostNames)))
$primary = $DnsNames[0]
& $P.Log "Instances: $(($targets | ForEach-Object { "$($_.Name) [$($_.Account)]" }) -join ', ')"
& $P.Log "Certificate CN=$primary, SANs: $($names -join ', ')"
$stamp = (& $P.Now).ToString('yyyyMMdd-HHmmss-fff')
$cert = $null
# --- 2 and 3. the certificate ----------------------------------------------------------
if ($Thumbprint) {
$t = ($Thumbprint -replace '[^0-9A-Fa-f]', '').ToUpperInvariant()
if ($t.Length -ne 40) { throw "Thumbprint '$Thumbprint' is not 40 hex digits." }
$cert = & $P.GetMachineCert $t
if (-not $cert) { throw "Certificate $t is not in the machine store." }
}
elseif ($CerFile) {
if (-not (& $P.TestFile $CerFile)) { throw "Certificate file not found: $CerFile" }
$issued = New-Object System.Security.Cryptography.X509Certificates.X509Certificate2 (, [System.IO.File]::ReadAllBytes($CerFile))
if (& $gate $CerFile 'certreq -accept -machine (install the issued certificate with its pending key)') {
$r = & $P.Certreq @('-accept', '-machine', '-q', $CerFile)
$r.Output | ForEach-Object { & $P.Log $_ }
if ($r.ExitCode -ne 0) { throw "certreq -accept failed ($($r.ExitCode)). The request that made this certificate must be pending on this host; if it is already installed, rerun with -Thumbprint $($issued.Thumbprint)." }
}
$cert = & $P.GetMachineCert $issued.Thumbprint
if (-not $cert -and -not $whatIf) { throw "Issued certificate $($issued.Thumbprint) not found in the machine store after accept." }
}
else {
$work = $P.WorkDir
$inf = Join-Path $work "vsql-$stamp.inf"
$csr = Join-Path $work "vsql-$stamp.req"
$cer = Join-Path $work "vsql-$stamp.cer"
$infText = New-CertRequestInf $primary $names $Template $stamp
& $P.Log "INF: $inf"
if ($whatIf) { & $P.Log $infText }
if (& $gate $inf 'write the request INF and run certreq -new (new RSA 2048 key in the machine store)') {
if (-not (Test-Path -LiteralPath $work)) { New-Item -ItemType Directory -Force -Path $work -Confirm:$false | Out-Null }
[System.IO.File]::WriteAllText($inf, $infText, [System.Text.Encoding]::ASCII)
$r = & $P.Certreq @('-new', '-q', $inf, $csr)
$r.Output | ForEach-Object { & $P.Log $_ }
if ($r.ExitCode -ne 0) { throw "certreq -new failed ($($r.ExitCode))." }
}
if (& $gate $CertificateAuthority "certreq -submit (template $Template)") {
$r = & $P.Certreq @('-submit', '-q', '-config', $CertificateAuthority, $csr, $cer)
$r.Output | ForEach-Object { & $P.Log $_ }
$reqId = $r.Output | Select-String -Pattern 'RequestId:\s*"?(\d+)' | ForEach-Object { $_.Matches[0].Groups[1].Value } | Select-Object -First 1
$requestFile = New-RecordPath $P 'request'
Write-NewJsonFile $requestFile ([ordered]@{ id = $reqId; ca = $CertificateAuthority; template = $Template; inf = $inf; req = $csr; cer = $cer; instances = @($targets | ForEach-Object { $_.Name }); at = (& $P.Now).ToString('s'); exitCode = $r.ExitCode })
$resume = "-CerFile `"$cer`"$(if ($Instances) { " -Instances $($Instances -join ',')" })"
if (-not (& $P.TestFile $cer)) {
if ($r.ExitCode -eq 0 -or ($r.Output -join ' ') -match 'pending|Taken Under Submission') {
throw "The request is pending on the CA (request id $reqId, saved in $requestFile). Have the CA admin issue it (certsrv.msc, Pending Requests, Issue). Then on this host run: certreq -retrieve -config `"$CertificateAuthority`" $reqId `"$cer`" and rerun this script with $resume. Nothing was bound."
}
throw "The CA did not issue the certificate (certreq exit $($r.ExitCode), request id $reqId, saved in $requestFile). Nothing was bound."
}
}
if (& $gate $cer 'certreq -accept -machine (install the issued certificate)') {
$r = & $P.Certreq @('-accept', '-machine', '-q', $cer)
$r.Output | ForEach-Object { & $P.Log $_ }
if ($r.ExitCode -ne 0) { throw "certreq -accept failed ($($r.ExitCode))." }
$issued = New-Object System.Security.Cryptography.X509Certificates.X509Certificate2 (, [System.IO.File]::ReadAllBytes($cer))
$cert = & $P.GetMachineCert $issued.Thumbprint
if (-not $cert) { throw "Issued certificate $($issued.Thumbprint) not found in the machine store." }
}
}
# --- 4. validate -----------------------------------------------------------------------
$thumb = $null
$keyFile = 'the new private key'
if ($cert) {
$thumb = $cert.Thumbprint.ToLowerInvariant()
$keyInfo = & $P.GetKeyInfo $cert
$check = Test-ServerCertificate -Certificate $cert -RequiredNames $names -Now (& $P.Now) -KeyInfo $keyInfo
& $P.Log "Certificate $($cert.Subject) thumbprint $thumb, $($check.KeySize)-bit, $($check.Signature), expires $($cert.NotAfter.ToString('yyyy-MM-dd')), SANs $($check.DnsNames -join ', ')"
if ($keyInfo -and $keyInfo.ProviderType -ne 0 -and -not $keyInfo.Machine) { $check.Problems.Add('the private key is not in the machine key store') }
if ($check.Problems.Count) {
throw "The certificate is not usable by SQL Server:`n - $($check.Problems -join "`n - ")`nNothing was bound. Check the template ($Template) on the CA; it must issue through a legacy CSP with an exchange key."
}
$keyFile = & $P.KeyFilePath $keyInfo
if (-not (& $P.TestFile $keyFile)) { throw "Private key container file not found: $keyFile. Nothing was bound." }
}
else { & $P.Log 'WhatIf: the issued certificate would be validated here before anything is bound.' }
# --- 5. private key access, per selected instance account -----------------------------
foreach ($acct in (Get-UniqueNames @($targets | ForEach-Object { $_.Account }))) {
$sid = & $P.ResolveSid $acct
if (& $gate $keyFile "grant read to $acct ($sid)") {
$r = & $P.Icacls @($keyFile, '/grant', "*${sid}:(R)")
if ($r.ExitCode -ne 0) { throw "Could not grant $acct read on the private key ($($r.Output -join ' ')). Nothing was bound." }
& $P.Log "Private key read granted to $acct"
}
}
# --- 6. bind, with an immutable snapshot of what it replaces --------------------------
$already = New-Object System.Collections.Generic.List[object]
$plan = New-Object System.Collections.Generic.List[object]
foreach ($i in $targets) {
$cur = & $P.GetRegValue $i.RegKey 'Certificate'
if ($thumb -and (Test-SameValue $cur $thumb)) { $already.Add($i); & $P.Log "Instance $($i.Name): already bound to $thumb; left alone, no snapshot."; continue }
if (& $confirm "instance $($i.Name)" "set Certificate=$(if ($thumb) { $thumb } else { '<new thumbprint>' }) (now $(Format-Value $cur))") {
$plan.Add([pscustomobject]@{ Target = $i; Previous = $cur })
}
}
$snapshotFile = $null
$bound = New-Object System.Collections.Generic.List[object]
if ($plan.Count) {
$snapshotFile = New-RecordPath $P 'deploy'
Write-NewJsonFile $snapshotFile ([ordered]@{
at = (& $P.Now).ToString('s')
thumbprint = $thumb
instances = @($plan | ForEach-Object { [ordered]@{ name = $_.Target.Name; service = $_.Target.Service; regKey = $_.Target.RegKey; previous = $_.Previous } })
})
& $P.Log "Previous bindings saved to $snapshotFile (rollback: .\Request-VsqlServerCertificate.ps1 -Rollback)"
$attempted = New-Object System.Collections.Generic.List[object]
try {
foreach ($item in $plan) {
$attempted.Add($item)
& $P.SetRegValue $item.Target.RegKey 'Certificate' $thumb
$readBack = & $P.GetRegValue $item.Target.RegKey 'Certificate'
if (-not (Test-SameValue $readBack $thumb)) { throw "instance $($item.Target.Name) reads '$readBack' after binding $thumb" }
$bound.Add($item.Target)
$force = & $P.GetRegValue $item.Target.RegKey 'ForceEncryption'
& $P.Log "Instance $($item.Target.Name): Certificate bound (ForceEncryption=$force, unchanged)"
}
}
catch {
$original = $_.Exception.Message
$restoreFailures = New-Object System.Collections.Generic.List[string]
foreach ($item in $attempted) {
$problem = Restore-InstanceBinding $P $item.Target.RegKey $item.Previous
if ($problem) { $restoreFailures.Add("$($item.Target.Name): $problem") }
else { & $P.Log "Restored $($item.Target.Name) to $(Format-Value $item.Previous) (verified)" }
}
$msg = "Binding failed: $original."
if ($restoreFailures.Count) { $msg += " Automatic restore FAILED for:`n - $($restoreFailures -join "`n - ")`nRun -Rollback -Snapshot $(Split-Path -Leaf $snapshotFile)." }
else { $msg += ' Every instance changed in this run was restored and verified.' }
throw $msg
}
}
# --- 7. restart -----------------------------------------------------------------------
$live = New-Object System.Collections.Generic.List[object]
foreach ($i in $bound) { $live.Add($i) }
foreach ($i in $already) { $live.Add($i) }
$restart = [pscustomobject]@{ Restarted = @(); Failed = @() }
if ($RestartInstances) { $restart = Invoke-InstanceRestart $P $confirm $(if ($whatIf) { $targets } else { $live.ToArray() }) }
elseif ($live.Count) {
& $P.Log ''
& $P.Log 'Restart the instances for the certificate to take effect (off-hours; VISUAL users lose their connection):'
foreach ($i in $live) { & $P.Log " Restart-Service -Name '$($i.Service)' -Force" }
}
if ($restart.Failed.Count) { throw "Restart failed:`n - $($restart.Failed -join "`n - ")`nIf an instance does not start, run this script with -Rollback -RestartInstances." }
if ($thumb) {
& $P.Log ''
& $P.Log "Done. Thumbprint $thumb. After the restart the SQL Server error log should read 'The certificate [Cn = $primary] was successfully loaded for encryption'."
}
return [pscustomobject]@{
Cancelled = $false
Thumbprint = $thumb
Bound = @($bound | ForEach-Object { $_.Name })
AlreadyBound = @($already | ForEach-Object { $_.Name })
Snapshot = $snapshotFile
Restarted = $restart.Restarted
}
}
catch [System.OperationCanceledException] {
Write-Warning $_.Exception.Message
return [pscustomobject]@{ Cancelled = $true; Thumbprint = $null; Bound = @(); AlreadyBound = @(); Snapshot = $null; Restarted = @() }
}
}
# --- entry -------------------------------------------------------------------------------------
# Dot-sourced (by the Pester tests): define the functions only.
if ($MyInvocation.InvocationName -eq '.') { return }
$ErrorActionPreference = 'Stop'
$run = @{
Provider = Get-VsqlDefaultProvider
CertificateAuthority = $CertificateAuthority
Template = $Template
DnsNames = $DnsNames
Instances = $Instances
CerFile = $CerFile
Thumbprint = $Thumbprint
RestartInstances = $RestartInstances
Rollback = $Rollback
Snapshot = $Snapshot
}
$result = Invoke-VsqlCertificateDeployment @run
if ($result.Cancelled) { exit 2 }
if (-not $Rollback -and $result.Thumbprint) { Write-Host 'Then tell Packet Station: the stacks switch to VISUAL_ENCRYPT=yes (Gitea issue 5).' }