- A rollback record now says which restarts succeeded. A snapshot stays open while an instance is unrestored or its restart failed (or was declined), so -Rollback -RestartInstances retries exactly those instead of reporting done or stepping back to an older deployment. - Running dependents (SQL Server Agent) are written to dependents-<service>.json before the stop and removed once they run again, so a failed restart does not leave Agent stopped for good. - GetRegValue returns $null only for a value confirmed absent; a missing key or read error throws, so it is never snapshotted or verified as absent. - Instances come from the value names of Instance Names\SQL; the 'PS*' filter dropped names such as PSREPORTING. Tests for each, including the real registry operations on an HKCU test key. Pester green on PowerShell 7.6 (63) and Windows PowerShell 5.1 (62, 1 skipped). Co-Authored-By: Claude Opus 5.5 <[email protected]>
796 lines
45 KiB
PowerShell
796 lines
45 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 = {
|
|
# The key's own value names. Get-ItemProperty mixes in PSPath and friends, and
|
|
# filtering those by 'PS*' would drop a real instance such as PSREPORTING.
|
|
param($Root)
|
|
$path = "$Root\Instance Names\SQL"
|
|
$map = [ordered]@{}
|
|
if (Test-Path -LiteralPath $path) {
|
|
$key = Get-Item -LiteralPath $path -ErrorAction Stop
|
|
foreach ($n in $key.GetValueNames()) { if ($n) { $map[$n] = [string]$key.GetValue($n) } }
|
|
}
|
|
$map
|
|
}
|
|
GetService = { param($Name) Get-CimInstance Win32_Service -Filter (Get-ServiceFilter $Name) -ErrorAction Stop }
|
|
TestRegKey = { param($Key) Test-Path -LiteralPath $Key }
|
|
GetRegValue = {
|
|
# $null only for a value confirmed absent. A missing key or a read error throws, so it
|
|
# is never saved in a snapshot, or verified after a restore, as "absent".
|
|
param($Key, $Name)
|
|
$item = Get-Item -LiteralPath $Key -ErrorAction Stop
|
|
if (@($item.GetValueNames()) -notcontains $Name) { return $null }
|
|
[string]$item.GetValue($Name, $null, [Microsoft.Win32.RegistryValueOptions]::DoNotExpandEnvironmentNames)
|
|
}
|
|
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 }
|
|
GetRunningDependents = { param($Name) @((Get-Service -Name $Name -ErrorAction Stop).DependentServices | Where-Object { $_.Status -eq 'Running' } | ForEach-Object { $_.Name }) }
|
|
RestartService = { param($Name) Restart-Service -Name $Name -Force -ErrorAction Stop -Confirm:$false }
|
|
StartService = { param($Name) Start-Service -Name $Name -ErrorAction Stop -Confirm:$false }
|
|
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 Get-DependentsFile($P, [string]$Service) {
|
|
return Join-Path $P.WorkDir ('dependents-' + ($Service -replace '[^A-Za-z0-9]', '_') + '.json')
|
|
}
|
|
|
|
function Invoke-InstanceRestart($P, $Confirm, [object[]]$Targets) {
|
|
# Restart-Service -Force stops dependents (SQL Server Agent) and does not start them again.
|
|
# The running ones are written to dependents-<service>.json before the stop, and the file is
|
|
# removed only once they run again. A failed restart therefore does not lose them: the next
|
|
# restart of that service, by a rerun or a rollback, starts them too.
|
|
$failed = New-Object System.Collections.Generic.List[string]
|
|
$restarted = New-Object System.Collections.Generic.List[string]
|
|
foreach ($t in $Targets) {
|
|
if (-not (& $Confirm $t.Service 'restart service (and start its running dependents again)')) { continue }
|
|
try {
|
|
$file = Get-DependentsFile $P $t.Service
|
|
$saved = @()
|
|
if (Test-Path -LiteralPath $file) { $saved = @((Get-Content -LiteralPath $file -Raw | ConvertFrom-Json).services) }
|
|
$deps = @(Get-UniqueNames (@($saved) + @(& $P.GetRunningDependents $t.Service)))
|
|
if ($deps.Count) {
|
|
if (-not (Test-Path -LiteralPath $P.WorkDir)) { New-Item -ItemType Directory -Force -Path $P.WorkDir -Confirm:$false | Out-Null }
|
|
$json = ConvertTo-Json -InputObject ([ordered]@{ service = $t.Service; services = $deps; at = (& $P.Now).ToString('s') })
|
|
[System.IO.File]::WriteAllText($file, $json, (New-Object System.Text.UTF8Encoding $false))
|
|
}
|
|
& $P.RestartService $t.Service
|
|
foreach ($d in $deps) { & $P.StartService $d }
|
|
if (Test-Path -LiteralPath $file) { Remove-Item -LiteralPath $file -Confirm:$false }
|
|
$restarted.Add($t.Name)
|
|
& $P.Log "Restarted $($t.Service)$(if ($deps.Count) { "; 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-RollbackState($P) {
|
|
# Snapshot file name -> what the rollback records say: instances whose registry value was
|
|
# restored and verified, and instances whose restart was asked for but has not succeeded.
|
|
# Records are read in time order, so a later successful restart clears an earlier failure.
|
|
$state = @{}
|
|
foreach ($f in @(Get-ChildItem -LiteralPath $P.WorkDir -Filter 'rollback-*.json' -File -ErrorAction SilentlyContinue | Sort-Object Name)) {
|
|
$r = Get-Content -LiteralPath $f.FullName -Raw | ConvertFrom-Json
|
|
if (-not $state.ContainsKey($r.snapshot)) {
|
|
$state[$r.snapshot] = [pscustomobject]@{
|
|
Restored = New-Object 'System.Collections.Generic.HashSet[string]' ([StringComparer]::OrdinalIgnoreCase)
|
|
RestartPending = New-Object 'System.Collections.Generic.HashSet[string]' ([StringComparer]::OrdinalIgnoreCase)
|
|
}
|
|
}
|
|
$s = $state[$r.snapshot]
|
|
foreach ($n in @($r.instances)) { if ($n) { [void]$s.Restored.Add($n) } }
|
|
foreach ($n in @($r.restarted)) { if ($n) { [void]$s.RestartPending.Remove($n) } }
|
|
foreach ($n in @($r.restartPending)) { if ($n) { [void]$s.RestartPending.Add($n) } }
|
|
}
|
|
return $state
|
|
}
|
|
|
|
function Invoke-VsqlRollback($P, $Confirm, [bool]$WhatIf, [string[]]$Instances, [string]$Snapshot, [bool]$RestartInstances) {
|
|
# Default: the newest snapshot with an instance not yet restored, or restored with a restart
|
|
# that did not succeed. Each -Rollback steps back one deployment, a retry finishes the one
|
|
# that failed, and a later 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 {
|
|
$state = Get-RollbackState $P
|
|
foreach ($f in $files) {
|
|
$s = & $load $f
|
|
$st = $state[$f.Name]
|
|
$open = @(& $pick $s | Where-Object { -not $st -or -not $st.Restored.Contains($_.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)"
|
|
}
|
|
}
|
|
$restart = [pscustomobject]@{ Restarted = @(); Failed = @() }
|
|
if ($restored.Count) {
|
|
$names = @($restored | ForEach-Object { $_.Name })
|
|
if ($RestartInstances) {
|
|
try { $restart = Invoke-InstanceRestart $P $Confirm $restored.ToArray() }
|
|
finally {
|
|
# Written after the restarts and even if they throw. A restored instance that was not
|
|
# restarted stays open, so the next -Rollback -RestartInstances retries it.
|
|
$done = @($restart.Restarted)
|
|
Write-NewJsonFile (New-RecordPath $P 'rollback') ([ordered]@{
|
|
at = (& $P.Now).ToString('s')
|
|
snapshot = $chosen.File.Name
|
|
instances = $names
|
|
restarted = $done
|
|
restartPending = @($names | Where-Object { $done -notcontains $_ })
|
|
})
|
|
}
|
|
}
|
|
else {
|
|
Write-NewJsonFile (New-RecordPath $P 'rollback') ([ordered]@{ at = (& $P.Now).ToString('s'); snapshot = $chosen.File.Name; instances = $names; restarted = @(); restartPending = @() })
|
|
& $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).' }
|