scripts: vsql certificate script fixes the six review findings, with a Pester suite on PowerShell 5.1 and 7
- Admin check uses -not (IsAdmin); the old call passed -eq $false as arguments. - Named instances: WQL filter Name='MSSQL$DEV' unescaped, lookup errors surface. - SANs decoded from the raw extension (DER walk) and matched as whole names. - Immutable deploy-, request- and rollback- records; already-bound instances get no snapshot; -Rollback takes the newest snapshot not yet rolled back, -Snapshot picks one. - certreq -new, -submit, -accept and the key grants go through ShouldProcess; a declined or failed prerequisite binds nothing. - Rollback and automatic restore work per instance, read back, report failures. Also found while testing: PowerShell 7 returns RSACng for a CSP key, so the provider check now reads CERT_KEY_PROV_INFO; ForEach-Object member form returned nothing under -WhatIf; stderr capture under Stop threw on 5.1; LocalSystem and NetworkService accounts go to icacls as SIDs; SQL Agent is started again after a restart; signature checked by OID. DEPLOY.md gains the run procedure: preflight, DEV, docker-1 checks, production, rollback. Co-Authored-By: Claude Opus 5.5 <[email protected]>
This commit is contained in:
@@ -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).' }
|
||||
|
||||
Reference in New Issue
Block a user