scripts: vsql certificate request uses the legacy SChannel CSP with an exchange key, validates before binding, scopes key access, saves bindings for rollback, resumes pending requests
Co-Authored-By: Claude Fable 5.1 <[email protected]>
This commit is contained in:
+1
-1
@@ -9,4 +9,4 @@ Run on a domain-joined PC. Exports every unexpired PESCO CA certificate from the
|
|||||||
|
|
||||||
## Request-VsqlServerCertificate.ps1
|
## Request-VsqlServerCertificate.ps1
|
||||||
|
|
||||||
Run elevated on the SQL Server host behind vsql. Requests a TLS certificate from PESCO-PSYLOCKE-CA with certreq (RSA 2048, SHA-256, Server Authentication, SANs vsql.pesco.com, vsql and the host names, Key Storage Provider, exportable), installs it, grants the SQL Server service accounts read on the private key, points each instance at it, and prints or performs the restarts. `-WhatIf` shows the steps. Gitea issue 5 tracks the switch to `VISUAL_ENCRYPT=yes`.
|
Run elevated on the SQL Server host behind vsql. Requests a TLS certificate from PESCO-PSYLOCKE-CA with certreq the way SQL Server requires it (legacy Microsoft RSA SChannel Cryptographic Provider, KeySpec AT_KEYEXCHANGE, RSA 2048, SHA-256, Server Authentication, key encipherment, SANs vsql.pesco.com, vsql and the host names), installs it, validates it (private key, provider, key size, signature, dates, EKU, SANs) before anything is bound, grants only the selected instances' service accounts read on the private key, saves the instances' current bindings to a state file, binds the new thumbprint and prints or performs the restarts. `-WhatIf` previews every step; `-CerFile` continues a request that was pending; `-Thumbprint` binds a certificate already installed; `-Rollback` restores the saved bindings. Gitea issue 5 tracks the switch to `VISUAL_ENCRYPT=yes`.
|
||||||
|
|||||||
@@ -1,83 +1,207 @@
|
|||||||
<#
|
<#
|
||||||
.SYNOPSIS
|
.SYNOPSIS
|
||||||
Requests a SQL Server TLS certificate for vsql from PESCO-PSYLOCKE-CA and installs it on the instances.
|
Requests a SQL Server TLS certificate for vsql from PESCO-PSYLOCKE-CA and binds it to the instances.
|
||||||
|
|
||||||
.DESCRIPTION
|
.DESCRIPTION
|
||||||
Run on the SQL Server host (the machine behind vsql.pesco.com) in an elevated PowerShell, as a
|
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. The script:
|
domain account that may enroll from the chosen template. Steps:
|
||||||
|
|
||||||
1. Writes a certreq INF with the requirements Packet Station needs: RSA 2048, SHA-256,
|
1. Resolve the SQL Server instances to configure and their service accounts. Unknown names
|
||||||
Server Authentication EKU, subject CN=vsql.pesco.com, subject alternative names
|
in -Instances are an error, nothing is touched.
|
||||||
vsql.pesco.com, vsql, the host's own FQDN and short name, key in the Microsoft Software
|
2. Write a certreq INF with what SQL Server requires (Microsoft certificate requirements):
|
||||||
Key Storage Provider, exportable, in the machine store.
|
a legacy CryptoAPI provider, Microsoft RSA SChannel Cryptographic Provider, with KeySpec
|
||||||
2. Submits the request to the CA and installs the issued certificate.
|
AT_KEYEXCHANGE; RSA 2048; SHA-256; Server Authentication EKU; key usage digital signature
|
||||||
3. Grants the SQL Server service account(s) read access to the private key.
|
and key encipherment; subject CN=vsql.pesco.com; subject alternative names vsql.pesco.com,
|
||||||
4. Points each SQL Server instance at the certificate (the same registry value that SQL Server
|
vsql, the host FQDN and short name; machine store; exportable.
|
||||||
Configuration Manager sets). ForceEncryption is left as it is.
|
3. Submit to the CA and install the issued certificate. A pending request is saved in a state
|
||||||
5. Prints the thumbprint and what to restart. With -RestartInstances it restarts the services
|
file; issue it on the CA, then rerun with -CerFile (or -Thumbprint once installed).
|
||||||
(a short VISUAL outage; run off-hours).
|
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.
|
||||||
|
7. Print the restart commands, or restart with -RestartInstances (a short VISUAL outage).
|
||||||
|
|
||||||
Afterwards, from docker-1:
|
-WhatIf previews every step, instance bindings and restarts included, without requesting.
|
||||||
docker compose ... run --rm --no-deps -e VISUAL_ENCRYPT=yes worker doctor --skip mfiles,db,volume,smtp,clamav,ldap
|
-Rollback restores the Certificate values saved by the last run.
|
||||||
should show "visual: Connect PASS" once the image trusts the PESCO CA.
|
|
||||||
|
|
||||||
.PARAMETER CertificateAuthority
|
.PARAMETER CertificateAuthority
|
||||||
The CA config string, host\name. Default DC-02.PESCO.COM\PESCO-PSYLOCKE-CA.
|
CA config string host\name. Default DC-02.PESCO.COM\PESCO-PSYLOCKE-CA.
|
||||||
|
|
||||||
.PARAMETER Template
|
.PARAMETER Template
|
||||||
The certificate template name. Default WebServer. Use the template IT prefers for servers; it
|
Certificate template name. Default WebServer. The template must allow Server Authentication
|
||||||
must allow Server Authentication and an exportable private key is not required but is set.
|
and must not force a Key Storage Provider.
|
||||||
|
|
||||||
.PARAMETER Instances
|
.PARAMETER Instances
|
||||||
SQL Server instance names to configure. Default: every instance found on this host
|
Instance names to configure (MSSQLSERVER for the default instance, DEV for vsql\DEV).
|
||||||
(MSSQLSERVER for the default instance, DEV for vsql\DEV, and so on).
|
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
|
.PARAMETER RestartInstances
|
||||||
Restart each configured instance so the certificate takes effect.
|
Restart each configured instance so the certificate takes effect.
|
||||||
|
|
||||||
.EXAMPLE
|
.PARAMETER Rollback
|
||||||
.\Request-VsqlServerCertificate.ps1
|
Restore the Certificate values saved by the last run and stop.
|
||||||
Requests and installs the certificate, configures every instance, prints restart instructions.
|
|
||||||
|
|
||||||
.EXAMPLE
|
|
||||||
.\Request-VsqlServerCertificate.ps1 -Instances DEV -RestartInstances
|
|
||||||
Configures only the DEV instance and restarts it.
|
|
||||||
|
|
||||||
.EXAMPLE
|
.EXAMPLE
|
||||||
.\Request-VsqlServerCertificate.ps1 -WhatIf
|
.\Request-VsqlServerCertificate.ps1 -WhatIf
|
||||||
Shows what would happen without changing anything.
|
.EXAMPLE
|
||||||
|
.\Request-VsqlServerCertificate.ps1 -Instances DEV
|
||||||
|
.EXAMPLE
|
||||||
|
.\Request-VsqlServerCertificate.ps1 -CerFile C:\ProgramData\PacketStation\vsql-cert\vsql-20261018-090000.cer -Instances DEV -RestartInstances
|
||||||
|
.EXAMPLE
|
||||||
|
.\Request-VsqlServerCertificate.ps1 -Rollback
|
||||||
#>
|
#>
|
||||||
[CmdletBinding(SupportsShouldProcess)]
|
[CmdletBinding(SupportsShouldProcess, DefaultParameterSetName = 'Request')]
|
||||||
param(
|
param(
|
||||||
[string]$CertificateAuthority = 'DC-02.PESCO.COM\PESCO-PSYLOCKE-CA',
|
[string]$CertificateAuthority = 'DC-02.PESCO.COM\PESCO-PSYLOCKE-CA',
|
||||||
[string]$Template = 'WebServer',
|
[string]$Template = 'WebServer',
|
||||||
[string[]]$DnsNames = @('vsql.pesco.com', 'vsql'),
|
[string[]]$DnsNames = @('vsql.pesco.com', 'vsql'),
|
||||||
[string[]]$Instances,
|
[string[]]$Instances,
|
||||||
[switch]$RestartInstances
|
[Parameter(ParameterSetName = 'Cer')][string]$CerFile,
|
||||||
|
[Parameter(ParameterSetName = 'Thumb')][string]$Thumbprint,
|
||||||
|
[switch]$RestartInstances,
|
||||||
|
[Parameter(ParameterSetName = 'Rollback')][switch]$Rollback
|
||||||
)
|
)
|
||||||
|
|
||||||
$ErrorActionPreference = 'Stop'
|
$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'
|
||||||
|
|
||||||
if (-not ([Security.Principal.WindowsPrincipal][Security.Principal.WindowsIdentity]::GetCurrent()).IsInRole([Security.Principal.WindowsBuiltInRole]::Administrator)) {
|
function Test-Admin {
|
||||||
throw 'Run this in an elevated PowerShell on the SQL Server host.'
|
([Security.Principal.WindowsPrincipal][Security.Principal.WindowsIdentity]::GetCurrent()).IsInRole([Security.Principal.WindowsBuiltInRole]::Administrator)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function Read-State {
|
||||||
|
if (Test-Path $stateFile) { Get-Content $stateFile -Raw | ConvertFrom-Json } else { [pscustomobject]@{} }
|
||||||
|
}
|
||||||
|
|
||||||
|
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-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"
|
||||||
|
}
|
||||||
|
})
|
||||||
|
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
|
||||||
|
}
|
||||||
|
|
||||||
|
function Test-ServerCertificate([System.Security.Cryptography.X509Certificates.X509Certificate2]$cert, [string[]]$requiredNames) {
|
||||||
|
$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')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
$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") }
|
||||||
|
}
|
||||||
|
return [pscustomobject]@{ Problems = $problems; Csp = $csp; KeySize = $keySize; Signature = $sig }
|
||||||
|
}
|
||||||
|
|
||||||
|
# --- 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
|
$fqdn = [System.Net.Dns]::GetHostEntry($env:COMPUTERNAME).HostName
|
||||||
$short = $env:COMPUTERNAME
|
$names = @($DnsNames + $fqdn + $env:COMPUTERNAME) | Select-Object -Unique
|
||||||
$names = @($DnsNames + $fqdn + $short) | Select-Object -Unique
|
|
||||||
$primary = $DnsNames[0]
|
$primary = $DnsNames[0]
|
||||||
Write-Host "Host $fqdn. Certificate for CN=$primary with SANs: $($names -join ', ')"
|
Write-Host "Host $fqdn. Instances: $(($targets | ForEach-Object { "$($_.Name) [$($_.Account)]" }) -join ', ')"
|
||||||
|
Write-Host "Certificate CN=$primary, SANs: $($names -join ', ')"
|
||||||
|
|
||||||
# --- 1. INF for certreq ---------------------------------------------------------------------
|
$cert = $null
|
||||||
$work = Join-Path $env:ProgramData 'PacketStation\vsql-cert'
|
|
||||||
New-Item -ItemType Directory -Force -Path $work | Out-Null
|
|
||||||
$stamp = Get-Date -Format 'yyyyMMdd-HHmmss'
|
$stamp = Get-Date -Format 'yyyyMMdd-HHmmss'
|
||||||
$inf = Join-Path $work "vsql-$stamp.inf"
|
New-Item -ItemType Directory -Force -Path $work | Out-Null
|
||||||
$csr = Join-Path $work "vsql-$stamp.req"
|
|
||||||
$cer = Join-Path $work "vsql-$stamp.cer"
|
|
||||||
|
|
||||||
$sanLines = ($names | ForEach-Object { "_continue_ = `"dns=$_&`"" }) -join "`r`n"
|
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 = @"
|
||||||
[Version]
|
[Version]
|
||||||
Signature = "`$Windows NT`$"
|
Signature = "`$Windows NT`$"
|
||||||
|
|
||||||
@@ -86,8 +210,11 @@ Subject = "CN=$primary"
|
|||||||
KeyLength = 2048
|
KeyLength = 2048
|
||||||
KeyAlgorithm = RSA
|
KeyAlgorithm = RSA
|
||||||
HashAlgorithm = SHA256
|
HashAlgorithm = SHA256
|
||||||
ProviderName = "Microsoft Software Key Storage Provider"
|
; SQL Server needs a legacy CryptoAPI provider and an exchange key (KeySpec = AT_KEYEXCHANGE).
|
||||||
KeySpec = 0
|
ProviderName = "Microsoft RSA SChannel Cryptographic Provider"
|
||||||
|
ProviderType = 12
|
||||||
|
KeySpec = 1
|
||||||
|
; digital signature + key encipherment
|
||||||
KeyUsage = 0xA0
|
KeyUsage = 0xA0
|
||||||
Exportable = TRUE
|
Exportable = TRUE
|
||||||
MachineKeySet = TRUE
|
MachineKeySet = TRUE
|
||||||
@@ -103,79 +230,104 @@ $sanLines
|
|||||||
|
|
||||||
[RequestAttributes]
|
[RequestAttributes]
|
||||||
CertificateTemplate = $Template
|
CertificateTemplate = $Template
|
||||||
"@ | Set-Content -Path $inf -Encoding ASCII
|
"@
|
||||||
|
Write-Host "INF: $inf"
|
||||||
|
if ($WhatIfPreference) {
|
||||||
|
Write-Host $infText
|
||||||
|
Write-Host "WhatIf: would run certreq -new, -submit to $CertificateAuthority (template $Template) and -accept."
|
||||||
|
}
|
||||||
|
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 ',')" })."
|
||||||
|
}
|
||||||
|
& 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." }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
Write-Host "INF written: $inf"
|
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
|
||||||
|
}
|
||||||
|
|
||||||
if (-not $PSCmdlet.ShouldProcess($CertificateAuthority, "request certificate from template $Template")) { return }
|
# --- 4. validate ------------------------------------------------------------------------------
|
||||||
|
|
||||||
# --- 2. request, submit, accept -------------------------------------------------------------
|
|
||||||
& certreq -new -q $inf $csr | Out-Host
|
|
||||||
if ($LASTEXITCODE -ne 0) { throw "certreq -new failed ($LASTEXITCODE)" }
|
|
||||||
|
|
||||||
& certreq -submit -q -config $CertificateAuthority $csr $cer | Out-Host
|
|
||||||
if ($LASTEXITCODE -ne 0) { throw "certreq -submit failed ($LASTEXITCODE). If the request is pending, have the CA admin issue it, then run: certreq -retrieve <RequestId> `"$cer`" and certreq -accept `"$cer`"" }
|
|
||||||
|
|
||||||
& 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." }
|
|
||||||
$thumb = $cert.Thumbprint.ToLower()
|
$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."
|
||||||
|
}
|
||||||
|
|
||||||
$keySize = $cert.PublicKey.Key.KeySize
|
# --- 5. private key access, per selected instance account ------------------------------------
|
||||||
$sig = $cert.SignatureAlgorithm.FriendlyName
|
$keyName = $check.Csp.UniqueKeyContainerName
|
||||||
Write-Host "Installed: $($cert.Subject) thumbprint $thumb, $keySize-bit, $sig, expires $($cert.NotAfter.ToString('yyyy-MM-dd'))"
|
$keyFile = Join-Path $env:ProgramData "Microsoft\Crypto\RSA\MachineKeys\$keyName"
|
||||||
if ($keySize -lt 2048 -or $sig -notmatch 'sha256') { Write-Warning 'The CA issued something weaker than requested. Check the template and the CA hash setting.' }
|
if (-not (Test-Path $keyFile)) { throw "Private key container file not found: $keyFile" }
|
||||||
|
foreach ($acct in ($targets.Account | Select-Object -Unique)) {
|
||||||
# --- 3. private key read access for the SQL Server service accounts -------------------------
|
if ($PSCmdlet.ShouldProcess($keyFile, "grant read to $acct")) {
|
||||||
$services = Get-CimInstance Win32_Service | Where-Object { $_.Name -eq 'MSSQLSERVER' -or $_.Name -like 'MSSQL$*' }
|
$out = & icacls $keyFile /grant "${acct}:(R)" 2>&1
|
||||||
if (-not $services) { Write-Warning 'No SQL Server service found on this host. Is this the vsql machine?' }
|
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"
|
||||||
$rsa = [System.Security.Cryptography.X509Certificates.RSACertificateExtensions]::GetRSAPrivateKey($cert)
|
|
||||||
$keyName = $rsa.Key.UniqueName
|
|
||||||
$keyFile = Get-ChildItem -Path "$env:ProgramData\Microsoft\Crypto\Keys", "$env:ProgramData\Microsoft\Crypto\RSA\MachineKeys" -Filter $keyName -Recurse -ErrorAction SilentlyContinue | Select-Object -First 1
|
|
||||||
if (-not $keyFile) { throw "Private key file $keyName not found under ProgramData\Microsoft\Crypto." }
|
|
||||||
|
|
||||||
foreach ($svc in $services) {
|
|
||||||
$acct = $svc.StartName
|
|
||||||
if ($PSCmdlet.ShouldProcess($keyFile.FullName, "grant read to $acct")) {
|
|
||||||
& icacls $keyFile.FullName /grant "${acct}:(R)" | Out-Null
|
|
||||||
if ($LASTEXITCODE -ne 0) { Write-Warning "icacls failed for $acct; grant Read on the private key by hand (certlm.msc, Manage Private Keys)." }
|
|
||||||
else { Write-Host "Private key read granted to $acct ($($svc.Name))" }
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
# --- 4. point each instance at the certificate -----------------------------------------------
|
# --- 6. bind, with saved previous values ---------------------------------------------------------
|
||||||
$instanceRoot = 'HKLM:\SOFTWARE\Microsoft\Microsoft SQL Server'
|
$state = Read-State
|
||||||
$instanceMap = Get-ItemProperty "$instanceRoot\Instance Names\SQL" -ErrorAction SilentlyContinue
|
$previous = [ordered]@{}
|
||||||
if (-not $instanceMap) { throw 'No SQL Server instances are registered on this host.' }
|
foreach ($i in $targets) {
|
||||||
$all = $instanceMap.PSObject.Properties | Where-Object { $_.Name -notlike 'PS*' } | ForEach-Object { @{ Name = $_.Name; Id = $_.Value } }
|
$cur = (Get-ItemProperty $i.RegKey -Name Certificate -ErrorAction SilentlyContinue).Certificate
|
||||||
if ($Instances) { $all = $all | Where-Object { $Instances -contains $_.Name } }
|
$previous[$i.Name] = [pscustomobject]@{ regKey = $i.RegKey; value = $cur }
|
||||||
if (-not $all) { throw "None of the requested instances ($($Instances -join ', ')) exist here. Found: $(($instanceMap.PSObject.Properties | Where-Object { $_.Name -notlike 'PS*' } | ForEach-Object Name) -join ', ')" }
|
}
|
||||||
|
$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)"
|
||||||
|
|
||||||
foreach ($inst in $all) {
|
$done = @()
|
||||||
$key = "$instanceRoot\$($inst.Id)\MSSQLServer\SuperSocketNetLib"
|
try {
|
||||||
if ($PSCmdlet.ShouldProcess("$($inst.Name) ($key)", "set Certificate=$thumb")) {
|
foreach ($i in $targets) {
|
||||||
Set-ItemProperty -Path $key -Name Certificate -Value $thumb
|
if ($PSCmdlet.ShouldProcess("instance $($i.Name)", "set Certificate=$thumb")) {
|
||||||
$force = (Get-ItemProperty $key -Name ForceEncryption -ErrorAction SilentlyContinue).ForceEncryption
|
Set-ItemProperty -Path $i.RegKey -Name Certificate -Value $thumb
|
||||||
Write-Host "Instance $($inst.Name): Certificate set (ForceEncryption=$force, unchanged)"
|
$done += $i
|
||||||
|
$force = (Get-ItemProperty $i.RegKey -Name ForceEncryption -ErrorAction SilentlyContinue).ForceEncryption
|
||||||
|
Write-Host "Instance $($i.Name): Certificate bound (ForceEncryption=$force, unchanged)"
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
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."
|
||||||
|
}
|
||||||
|
throw
|
||||||
|
}
|
||||||
|
|
||||||
# --- 5. restart ------------------------------------------------------------------------------
|
# --- 7. restart ------------------------------------------------------------------------------
|
||||||
$toRestart = $all | ForEach-Object { if ($_.Name -eq 'MSSQLSERVER') { 'MSSQLSERVER' } else { "MSSQL`$$($_.Name)" } }
|
|
||||||
if ($RestartInstances) {
|
if ($RestartInstances) {
|
||||||
foreach ($s in $toRestart) {
|
foreach ($i in $targets) {
|
||||||
if ($PSCmdlet.ShouldProcess($s, 'restart service')) { Restart-Service -Name $s -Force; Write-Host "Restarted $s" }
|
if ($PSCmdlet.ShouldProcess($i.Service, 'restart service')) { Restart-Service -Name $i.Service -Force; Write-Host "Restarted $($i.Service)" }
|
||||||
}
|
}
|
||||||
} else {
|
}
|
||||||
|
else {
|
||||||
Write-Host ''
|
Write-Host ''
|
||||||
Write-Host 'Restart the instances for the certificate to take effect (off-hours; VISUAL users lose their connection):'
|
Write-Host 'Restart the instances for the certificate to take effect (off-hours; VISUAL users lose their connection):'
|
||||||
$toRestart | ForEach-Object { Write-Host " Restart-Service -Name '$_' -Force" }
|
$targets | ForEach-Object { Write-Host " Restart-Service -Name '$($_.Service)' -Force" }
|
||||||
}
|
}
|
||||||
|
|
||||||
Write-Host ''
|
Write-Host ''
|
||||||
Write-Host "Done. Thumbprint $thumb. The SQL Server error log should show 'The certificate [Cn = $primary] was successfully loaded for encryption' after the restart."
|
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).'
|
Write-Host 'Then tell Packet Station: the stacks switch to VISUAL_ENCRYPT=yes (Gitea issue 5).'
|
||||||
|
|||||||
Reference in New Issue
Block a user