scripts: vsql certificate script fixes the six review findings, with a Pester suite on PowerShell 5.1 and 7
ci / frontend (push) Successful in 43s
ci / server (push) Successful in 11m3s
ci / image (push) Successful in 12s
ci / e2e (push) Failing after 4m2s

- 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]>
This commit is contained in:
2026-10-18 21:41:03 -06:00
co-authored by Claude Opus 5.5
parent 850e1da3a9
commit 90adcebc44
4 changed files with 1313 additions and 227 deletions
+642 -226
View File
@@ -4,7 +4,8 @@ Requests a SQL Server TLS certificate for vsql from PESCO-PSYLOCKE-CA and binds
.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. Steps:
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.
@@ -13,20 +14,26 @@ domain account that may enroll from the chosen template. Steps:
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. Submit to the CA and install the issued certificate. A pending request is saved in a state
file; issue it on the CA, then rerun with -CerFile (or -Thumbprint once installed).
4. Validate the installed certificate before anything is bound: private key present and a
CSP exchange key, 2048 bits or more, SHA-256 family signature, valid dates, Server
Authentication EKU, every required SAN.
5. Grant the selected instances' service accounts read on the private key. A failure stops
the run before any instance is changed.
6. Save each instance's current Certificate value to the state file, then bind the new
thumbprint (the registry value SQL Server Configuration Manager sets). ForceEncryption is
left as it is. A failure restores the values already changed.
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, instance bindings and restarts included, without requesting.
-Rollback restores the Certificate values saved by the last run.
-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.
@@ -35,6 +42,10 @@ CA config string host\name. Default DC-02.PESCO.COM\PESCO-PSYLOCKE-CA.
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.
@@ -46,19 +57,26 @@ Continue from an issued certificate file (after a pending request was issued and
Continue from a certificate already in the machine store; skips the request.
.PARAMETER RestartInstances
Restart each configured instance so the certificate takes effect.
Restart each configured instance so the certificate (or the rollback) takes effect.
.PARAMETER Rollback
Restore the Certificate values saved by the last run and stop.
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 -WhatIf
.\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.cer -Instances DEV -RestartInstances
.\Request-VsqlServerCertificate.ps1 -CerFile C:\ProgramData\PacketStation\vsql-cert\vsql-20261018-090000-000.cer -Instances DEV -RestartInstances
.EXAMPLE
.\Request-VsqlServerCertificate.ps1 -Rollback
.\Request-VsqlServerCertificate.ps1 -Rollback -WhatIf
#>
[CmdletBinding(SupportsShouldProcess, DefaultParameterSetName = 'Request')]
param(
@@ -66,152 +84,205 @@ param(
[string]$Template = 'WebServer',
[string[]]$DnsNames = @('vsql.pesco.com', 'vsql'),
[string[]]$Instances,
[Parameter(ParameterSetName = 'Cer')][string]$CerFile,
[Parameter(ParameterSetName = 'Thumb')][string]$Thumbprint,
[Parameter(ParameterSetName = 'Cer', Mandatory)][string]$CerFile,
[Parameter(ParameterSetName = 'Thumb', Mandatory)][string]$Thumbprint,
[switch]$RestartInstances,
[Parameter(ParameterSetName = 'Rollback')][switch]$Rollback
[Parameter(ParameterSetName = 'Rollback', Mandatory)][switch]$Rollback,
[Parameter(ParameterSetName = 'Rollback')][string]$Snapshot
)
$ErrorActionPreference = 'Stop'
$acceptedSignatures = @('sha256RSA', 'sha384RSA', 'sha512RSA')
$work = Join-Path $env:ProgramData 'PacketStation\vsql-cert'
$stateFile = Join-Path $work 'state.json'
$instanceRoot = 'HKLM:\SOFTWARE\Microsoft\Microsoft SQL Server'
$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'
function Test-Admin {
([Security.Principal.WindowsPrincipal][Security.Principal.WindowsIdentity]::GetCurrent()).IsInRole([Security.Principal.WindowsBuiltInRole]::Administrator)
# --- 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 Read-State {
if (Test-Path $stateFile) { Get-Content $stateFile -Raw | ConvertFrom-Json } else { [pscustomobject]@{} }
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 Write-State([object]$state) {
New-Item -ItemType Directory -Force -Path $work | Out-Null
$state | ConvertTo-Json -Depth 5 | Set-Content -Path $stateFile -Encoding UTF8
function Get-SqlServiceName([string]$Instance) {
if ($Instance -eq 'MSSQLSERVER') { return 'MSSQLSERVER' }
return 'MSSQL$' + $Instance
}
function Get-SqlInstances([string[]]$wanted) {
$map = Get-ItemProperty "$instanceRoot\Instance Names\SQL" -ErrorAction SilentlyContinue
if (-not $map) { throw 'No SQL Server Database Engine instance is registered on this host. Is this the vsql machine?' }
$all = @($map.PSObject.Properties | Where-Object { $_.Name -notlike 'PS*' } | ForEach-Object {
$name = $_.Name
$svc = if ($name -eq 'MSSQLSERVER') { 'MSSQLSERVER' } else { "MSSQL`$$name" }
$service = Get-CimInstance Win32_Service -Filter "Name='$($svc.Replace('$','\$'))'" -ErrorAction SilentlyContinue
[pscustomobject]@{
Name = $name
Id = $_.Value
Service = $svc
Account = if ($service) { $service.StartName } else { $null }
RegKey = "$instanceRoot\$($_.Value)\MSSQLServer\SuperSocketNetLib"
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); }
}
})
if ($wanted) {
$unknown = @($wanted | Where-Object { $_ -notin $all.Name })
if ($unknown) { throw "Unknown instance(s): $($unknown -join ', '). Found on this host: $($all.Name -join ', ')" }
$all = @($all | Where-Object { $wanted -contains $_.Name })
}
foreach ($i in $all) {
if (-not $i.Account) { throw "Service $($i.Service) for instance $($i.Name) was not found." }
if (-not (Test-Path $i.RegKey)) { throw "Registry key for instance $($i.Name) not found: $($i.RegKey)" }
}
'@
}
return $all
$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([System.Security.Cryptography.X509Certificates.X509Certificate2]$cert, [string[]]$requiredNames) {
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 $cert.HasPrivateKey) { $problems.Add('no private key in the machine store') }
$csp = $null
if ($cert.HasPrivateKey) {
$pk = $cert.PrivateKey
if ($pk -is [System.Security.Cryptography.RSACryptoServiceProvider]) {
$csp = $pk.CspKeyContainerInfo
if ($csp.KeyNumber -ne [System.Security.Cryptography.KeyNumber]::Exchange) { $problems.Add("KeySpec is $($csp.KeyNumber), SQL Server needs AT_KEYEXCHANGE") }
if ($csp.ProviderType -ne 12) { $problems.Add("provider type $($csp.ProviderType) ($($csp.ProviderName)); expected 12, Microsoft RSA SChannel Cryptographic Provider") }
}
else {
$problems.Add('the private key is not a CryptoAPI (CSP) key; a KSP/CNG key is not supported by SQL Server')
}
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)") }
}
$keySize = $cert.PublicKey.Key.KeySize
if ($keySize -lt 2048) { $problems.Add("key size $keySize is below 2048") }
$sig = $cert.SignatureAlgorithm.FriendlyName
if ($sig -notin $acceptedSignatures) { $problems.Add("signature algorithm $sig is not one of $($acceptedSignatures -join ', ')") }
$now = Get-Date
if ($cert.NotBefore -gt $now -or $cert.NotAfter -lt $now.AddDays(30)) { $problems.Add("validity $($cert.NotBefore.ToString('yyyy-MM-dd')) to $($cert.NotAfter.ToString('yyyy-MM-dd')) is not usable") }
$eku = $cert.Extensions | Where-Object { $_.Oid.Value -eq '2.5.29.37' }
if (-not $eku -or -not ($eku.EnhancedKeyUsages | Where-Object { $_.Value -eq '1.3.6.1.5.5.7.3.1' })) { $problems.Add('Server Authentication EKU missing') }
$ku = $cert.Extensions | Where-Object { $_.Oid.Value -eq '2.5.29.15' }
if ($ku -and -not ($ku.KeyUsages -band [System.Security.Cryptography.X509Certificates.X509KeyUsageFlags]::KeyEncipherment)) { $problems.Add('key usage lacks key encipherment') }
$san = $cert.Extensions | Where-Object { $_.Oid.Value -eq '2.5.29.17' }
$sanText = if ($san) { $san.Format($false) } else { '' }
foreach ($n in $requiredNames) {
if ($sanText -notmatch [regex]::Escape("DNS Name=$n")) { $problems.Add("subject alternative name $n missing") }
$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') }
}
return [pscustomobject]@{ Problems = $problems; Csp = $csp; KeySize = $keySize; Signature = $sig }
$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 }
}
# --- rollback --------------------------------------------------------------------------------
if (Test-Admin -eq $false) { throw 'Run this in an elevated PowerShell on the SQL Server host.' }
if ($Rollback) {
$state = Read-State
if (-not $state.previous) { throw "No saved bindings in $stateFile." }
foreach ($p in $state.previous.PSObject.Properties) {
$inst = $p.Name; $value = $p.Value.value; $key = $p.Value.regKey
if ($PSCmdlet.ShouldProcess("instance $inst", "restore Certificate='$value'")) {
if ([string]::IsNullOrEmpty($value)) { Remove-ItemProperty -Path $key -Name Certificate -ErrorAction SilentlyContinue }
else { Set-ItemProperty -Path $key -Name Certificate -Value $value }
Write-Host "Restored $inst to '$value'"
}
}
Write-Host 'Restart the instances for the rollback to take effect.'
return
}
# --- 1. instances ------------------------------------------------------------------------------
$targets = Get-SqlInstances $Instances
$fqdn = [System.Net.Dns]::GetHostEntry($env:COMPUTERNAME).HostName
$names = @($DnsNames + $fqdn + $env:COMPUTERNAME) | Select-Object -Unique
$primary = $DnsNames[0]
Write-Host "Host $fqdn. Instances: $(($targets | ForEach-Object { "$($_.Name) [$($_.Account)]" }) -join ', ')"
Write-Host "Certificate CN=$primary, SANs: $($names -join ', ')"
$cert = $null
$stamp = Get-Date -Format 'yyyyMMdd-HHmmss'
New-Item -ItemType Directory -Force -Path $work | Out-Null
if ($PSCmdlet.ParameterSetName -eq 'Thumb') {
$cert = Get-ChildItem Cert:\LocalMachine\My | Where-Object { $_.Thumbprint -eq $Thumbprint.ToUpper() }
if (-not $cert) { throw "Certificate $Thumbprint is not in the machine store." }
}
elseif ($PSCmdlet.ParameterSetName -eq 'Cer') {
if (-not (Test-Path $CerFile)) { throw "Certificate file not found: $CerFile" }
$issued = New-Object System.Security.Cryptography.X509Certificates.X509Certificate2 $CerFile
if ($PSCmdlet.ShouldProcess($CerFile, 'certreq -accept')) {
& certreq -accept -q $CerFile | Out-Host
if ($LASTEXITCODE -ne 0) { throw "certreq -accept failed ($LASTEXITCODE). The request that made this certificate must exist on this host." }
}
$cert = Get-ChildItem Cert:\LocalMachine\My | Where-Object { $_.Thumbprint -eq $issued.Thumbprint }
if (-not $cert -and -not $WhatIfPreference) { throw "Issued certificate $($issued.Thumbprint) not found in the machine store after accept." }
}
else {
# --- 2. INF ---------------------------------------------------------------------------------
$inf = Join-Path $work "vsql-$stamp.inf"
$csr = Join-Path $work "vsql-$stamp.req"
$cer = Join-Path $work "vsql-$stamp.cer"
$sanLines = ($names | ForEach-Object { "_continue_ = `"dns=$_&`"" }) -join "`r`n"
$infText = @"
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"
Subject = "CN=$Primary"
KeyLength = 2048
KeyAlgorithm = RSA
HashAlgorithm = SHA256
; SQL Server needs a legacy CryptoAPI provider and an exchange key (KeySpec = AT_KEYEXCHANGE).
ProviderName = "Microsoft RSA SChannel Cryptographic Provider"
ProviderName = "$script:SchannelProvider"
ProviderType = 12
KeySpec = 1
; digital signature + key encipherment
@@ -219,7 +290,7 @@ KeyUsage = 0xA0
Exportable = TRUE
MachineKeySet = TRUE
RequestType = PKCS10
FriendlyName = "SQL Server TLS $primary ($stamp)"
FriendlyName = "SQL Server TLS $Primary ($Stamp)"
[EnhancedKeyUsageExtension]
OID = 1.3.6.1.5.5.7.3.1
@@ -231,103 +302,448 @@ $sanLines
[RequestAttributes]
CertificateTemplate = $Template
"@
Write-Host "INF: $inf"
if ($WhatIfPreference) {
Write-Host $infText
Write-Host "WhatIf: would run certreq -new, -submit to $CertificateAuthority (template $Template) and -accept."
}
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 {
$infText | Set-Content -Path $inf -Encoding ASCII
# --- 3. request, submit, accept -------------------------------------------------------
& certreq -new -q $inf $csr | Out-Host
if ($LASTEXITCODE -ne 0) { throw "certreq -new failed ($LASTEXITCODE)" }
$submit = & certreq -submit -q -config $CertificateAuthority $csr $cer 2>&1
$submit | Out-Host
$reqId = ($submit | Select-String -Pattern 'RequestId:\s*"?(\d+)' | ForEach-Object { $_.Matches[0].Groups[1].Value } | Select-Object -First 1)
$state = Read-State
$state | Add-Member -NotePropertyName request -NotePropertyValue ([pscustomobject]@{ id = $reqId; ca = $CertificateAuthority; cer = $cer; inf = $inf; at = (Get-Date).ToString('s') }) -Force
Write-State $state
if ($LASTEXITCODE -ne 0 -or -not (Test-Path $cer)) {
throw "The request was not issued (certreq exit $LASTEXITCODE, request id $reqId). If it is pending, have the CA admin issue it, then on this host run: certreq -retrieve -config `"$CertificateAuthority`" $reqId `"$cer`" and rerun this script with -CerFile `"$cer`"$(if ($Instances) { " -Instances $($Instances -join ',')" })."
$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 }
}
& certreq -accept -q $cer | Out-Host
if ($LASTEXITCODE -ne 0) { throw "certreq -accept failed ($LASTEXITCODE)" }
$issued = New-Object System.Security.Cryptography.X509Certificates.X509Certificate2 $cer
$cert = Get-ChildItem Cert:\LocalMachine\My | Where-Object { $_.Thumbprint -eq $issued.Thumbprint }
if (-not $cert) { throw "Issued certificate $($issued.Thumbprint) not found in the machine store." }
}
}
if ($WhatIfPreference -and -not $cert) {
Write-Host "WhatIf: would validate the certificate, grant private key read to $(($targets.Account | Select-Object -Unique) -join ', '), bind it to $(($targets.Name) -join ', ') and $(if ($RestartInstances) { 'restart' } else { 'print restart commands for' }) $(($targets.Service) -join ', ')."
return
}
# --- 4. validate ------------------------------------------------------------------------------
$thumb = $cert.Thumbprint.ToLower()
$check = Test-ServerCertificate $cert $names
Write-Host "Certificate $($cert.Subject) thumbprint $thumb, $($check.KeySize)-bit, $($check.Signature), expires $($cert.NotAfter.ToString('yyyy-MM-dd'))"
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."
}
# --- 5. private key access, per selected instance account ------------------------------------
$keyName = $check.Csp.UniqueKeyContainerName
$keyFile = Join-Path $env:ProgramData "Microsoft\Crypto\RSA\MachineKeys\$keyName"
if (-not (Test-Path $keyFile)) { throw "Private key container file not found: $keyFile" }
foreach ($acct in ($targets.Account | Select-Object -Unique)) {
if ($PSCmdlet.ShouldProcess($keyFile, "grant read to $acct")) {
$out = & icacls $keyFile /grant "${acct}:(R)" 2>&1
if ($LASTEXITCODE -ne 0) { throw "Could not grant $acct read on the private key ($out). Nothing was bound." }
Write-Host "Private key read granted to $acct"
}
}
# --- 6. bind, with saved previous values ---------------------------------------------------------
$state = Read-State
$previous = [ordered]@{}
foreach ($i in $targets) {
$cur = (Get-ItemProperty $i.RegKey -Name Certificate -ErrorAction SilentlyContinue).Certificate
$previous[$i.Name] = [pscustomobject]@{ regKey = $i.RegKey; value = $cur }
}
$state | Add-Member -NotePropertyName previous -NotePropertyValue ([pscustomobject]$previous) -Force
$state | Add-Member -NotePropertyName bound -NotePropertyValue ([pscustomobject]@{ thumbprint = $thumb; at = (Get-Date).ToString('s'); instances = @($targets.Name) }) -Force
Write-State $state
Write-Host "Previous bindings saved to $stateFile (rollback: .\Request-VsqlServerCertificate.ps1 -Rollback)"
$done = @()
try {
foreach ($i in $targets) {
if ($PSCmdlet.ShouldProcess("instance $($i.Name)", "set Certificate=$thumb")) {
Set-ItemProperty -Path $i.RegKey -Name Certificate -Value $thumb
$done += $i
$force = (Get-ItemProperty $i.RegKey -Name ForceEncryption -ErrorAction SilentlyContinue).ForceEncryption
Write-Host "Instance $($i.Name): Certificate bound (ForceEncryption=$force, unchanged)"
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 = @() }
}
}
}
catch {
foreach ($i in $done) {
$old = $previous[$i.Name].value
if ([string]::IsNullOrEmpty($old)) { Remove-ItemProperty -Path $i.RegKey -Name Certificate -ErrorAction SilentlyContinue } else { Set-ItemProperty -Path $i.RegKey -Name Certificate -Value $old }
Write-Warning "Restored $($i.Name) to its previous binding."
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." }
}
throw
& $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 }
}
# --- 7. restart ------------------------------------------------------------------------------
if ($RestartInstances) {
foreach ($i in $targets) {
if ($PSCmdlet.ShouldProcess($i.Service, 'restart service')) { Restart-Service -Name $i.Service -Force; Write-Host "Restarted $($i.Service)" }
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 = @() }
}
}
else {
Write-Host ''
Write-Host 'Restart the instances for the certificate to take effect (off-hours; VISUAL users lose their connection):'
$targets | ForEach-Object { Write-Host " Restart-Service -Name '$($_.Service)' -Force" }
}
Write-Host ''
Write-Host "Done. Thumbprint $thumb. After the restart the SQL Server error log should read 'The certificate [Cn = $primary] was successfully loaded for encryption'."
Write-Host 'If an instance fails to start, run this script with -Rollback and restart it, then check the private key access and the provider.'
Write-Host 'Then tell Packet Station: the stacks switch to VISUAL_ENCRYPT=yes (Gitea issue 5).'
# --- 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).' }