Remove the vsql certificate script, its tests and its DEPLOY.md procedure #58

Merged
spencerm merged 2 commits from remove-vsql-cert-script into main 2026-11-03 22:13:39 +00:00
5 changed files with 5 additions and 1589 deletions
Showing only changes of commit 8fb0818092 - Show all commits
+4
View File
@@ -23,6 +23,10 @@ All notable changes to this project are recorded here. The format follows [Keep
- Gitea issue 20: the storage alert stays at 85 percent. Phase 9 mails it to PS-Admins. - Gitea issue 20: the storage alert stays at 85 percent. Phase 9 mails it to PS-Admins.
- Gitea issue 38: the test stack's files stay under `~/packetstation/test` on docker-1. The test env example, the compose header and the docs now give that path. - Gitea issue 38: the test stack's files stay under `~/packetstation/test` on docker-1. The test env example, the compose header and the docs now give that path.
### Removed
- `scripts/Request-VsqlServerCertificate.ps1`, its Pester tests and its DEPLOY.md procedure. IT issues and binds the vsql certificate by hand. Gitea issue 5 still tracks the switch to `VISUAL_ENCRYPT=yes`.
### Fixed ### Fixed
- The hand drawing, hand BOM and rev approval lines on a VISUAL draft showed the person's user id instead of their name. - The hand drawing, hand BOM and rev approval lines on a VISUAL draft showed the person's user id instead of their name.
+1 -59
View File
@@ -179,65 +179,7 @@ Docker's default address pool on docker-1 holds fifteen /16 bridge networks (172
## Known issue: vsql serves a weak self-signed certificate (found 2026-10-18) ## Known issue: vsql serves a weak self-signed certificate (found 2026-10-18)
`vsql.pesco.com\DEV` serves TLS (`VISUAL_ENCRYPT=trust` connects), but with `VISUAL_ENCRYPT=yes` OpenSSL in the image refuses it with "EE certificate key too weak": the server certificate's RSA key is under 2048 bits, which is what SQL Server's auto-generated self-signed certificate looks like. IT needs to issue vsql a 2048-bit, SHA-256 certificate from PESCO-PSYLOCKE-CA with `vsql.pesco.com` in the subject alternative names, install it on the DEV and production instances in SQL Server Configuration Manager and restart them. The image then needs the PESCO CA in its OpenSSL trust store for the ODBC driver (the entrypoint can concatenate the system bundle and /etc/ssl/pesco/ca.pem into /tmp and export `SSL_CERT_FILE`), and the stacks switch to `VISUAL_ENCRYPT=yes`. Tracked as Gitea issue 5. `vsql.pesco.com\DEV` serves TLS (`VISUAL_ENCRYPT=trust` connects), but with `VISUAL_ENCRYPT=yes` OpenSSL in the image refuses it with "EE certificate key too weak": the server certificate's RSA key is under 2048 bits, which is what SQL Server's auto-generated self-signed certificate looks like. IT needs to issue vsql a 2048-bit, SHA-256 certificate from PESCO-PSYLOCKE-CA with `vsql.pesco.com` in the subject alternative names, install it on the DEV and production instances in SQL Server Configuration Manager and restart them. The image then needs the PESCO CA in its OpenSSL trust store for the ODBC driver (the entrypoint can concatenate the system bundle and /etc/ssl/pesco/ca.pem into /tmp and export `SSL_CERT_FILE`), and the stacks switch to `VISUAL_ENCRYPT=yes`. Tracked as Gitea issue 5. IT issues and binds the certificate by hand; the repo has no script for it.
### Installing the vsql certificate
`scripts/Request-VsqlServerCertificate.ps1` does the request, the checks and the binding. Spencer runs it on the SQL Server host, in an elevated PowerShell, as an account that may enroll from the WebServer template. Every step there is a real change, so preview first. Copy only the script to `C:\ProgramData\PacketStation\` and work from there.
1. Preflight. Nothing changes. Check the instance names, the service accounts, the INF and the list of steps.
```powershell
cd C:\ProgramData\PacketStation
powershell -ExecutionPolicy Bypass -File .\Request-VsqlServerCertificate.ps1 -Instances DEV -WhatIf
```
2. DEV first. This enrolls, validates, grants the DEV service account read on the key, saves a snapshot and binds DEV. Production is untouched.
```powershell
powershell -ExecutionPolicy Bypass -File .\Request-VsqlServerCertificate.ps1 -Instances DEV
```
If the CA holds the request as pending, the script stops with the request id. Issue it on the CA (certsrv.msc, Pending Requests), then run the `certreq -retrieve` line it printed and continue with `-CerFile <that .cer> -Instances DEV`. If validation fails, nothing is bound. The usual cause is a template that forces a Key Storage Provider.
3. Restart DEV and check the log. `Restart-Service -Name 'MSSQL$DEV' -Force`, then start `SQLAgent$DEV` again if it ran. Or rerun with `-Thumbprint <thumbprint> -Instances DEV -RestartInstances`, which leaves the binding alone and does both. The instance's ERRORLOG should read `The certificate [Cn = vsql.pesco.com] was successfully loaded for encryption`. If the instance does not start, roll back (step 6).
4. Check from docker-1. Export the certificate (`Export-Certificate -Cert Cert:\LocalMachine\My\<thumbprint> -FilePath vsql.cer`) and copy it to docker-1. OpenSSL cannot speak TDS, so `openssl s_client` does not work against SQL Server. Verify the file at the image's security level instead:
```bash
openssl x509 -inform DER -in vsql.cer -out vsql.pem
openssl x509 -in vsql.pem -noout -subject -dates -ext subjectAltName,keyUsage,extendedKeyUsage
openssl verify -auth_level 2 -purpose sslserver -CAfile ~/packetstation/test/ca/pesco-ca.pem vsql.pem
```
Then prove the live connection with the doctor and `VISUAL_ENCRYPT=yes`. The image does not yet add the PESCO CA to OpenSSL's trust store for the ODBC driver (the issue 5 follow-up), so this one-off check builds a bundle in `/tmp`. It skips the entrypoint, so the M-Files password line fails. Read the VISUAL line.
```bash
docker compose -p packetstation-test --env-file ~/packetstation/test/stack.env \
-f deploy/compose.yaml -f deploy/compose.proxy.yaml run --rm --no-deps \
-e VISUAL_ENCRYPT=yes --entrypoint sh app -c \
'cat /etc/ssl/certs/ca-certificates.crt /etc/ssl/pesco/ca.pem > /tmp/ca.pem && SSL_CERT_FILE=/tmp/ca.pem exec packetstation doctor'
```
That the ODBC driver honours `SSL_CERT_FILE` is untested. If the VISUAL line fails with an issuer error while `openssl verify` passed, the trust step is the gap, not the server.
5. Production, off-hours. Production (`vsql`, `PESCOINC`) is the default instance, `MSSQLSERVER`. Bind the same certificate. VISUAL users lose their connection during the restart.
```powershell
powershell -ExecutionPolicy Bypass -File .\Request-VsqlServerCertificate.ps1 -Thumbprint <thumbprint> -Instances MSSQLSERVER -WhatIf
powershell -ExecutionPolicy Bypass -File .\Request-VsqlServerCertificate.ps1 -Thumbprint <thumbprint> -Instances MSSQLSERVER -RestartInstances
```
Check the ERRORLOG line again. The production stack's own doctor run is Spencer's, with its own approval.
6. Rollback. Each deployment saved `deploy-<time>.json` in `C:\ProgramData\PacketStation\vsql-cert`. `-Rollback` restores the newest one not yet rolled back. It restores each instance on its own, reads each value back and lists any failure. If a restart fails, fix the cause and run the same command again: it retries only the instances not yet restored or restarted, and starts SQL Server Agent if it ran before the failed attempt. Preview it, then run it:
```powershell
powershell -ExecutionPolicy Bypass -File .\Request-VsqlServerCertificate.ps1 -Rollback -Instances DEV -WhatIf
powershell -ExecutionPolicy Bypass -File .\Request-VsqlServerCertificate.ps1 -Rollback -Instances DEV -RestartInstances
```
`-Snapshot deploy-<time>.json` restores a specific one. The rollback leaves the certificate and the key grant in place. Removing them is a manual step once nothing is bound to them.
## Doctor ## Doctor
-19
View File
@@ -7,22 +7,3 @@ The nightly `pg_dump` backup with its systemd timer, and the restore script, for
## Export-PescoCaBundle.ps1 ## Export-PescoCaBundle.ps1
Run on a domain-joined PC. Exports every unexpired PESCO CA certificate from the Windows stores to one PEM bundle and copies it to docker-1 with scp (default target `~/packetstation/test/ca/pesco-ca.pem`). Use `-Remote` for another path and `-NoCopy` to only write the file. It warns on SHA-1 signed certificates; see docs/DEPLOY.md. Run on a domain-joined PC. Exports every unexpired PESCO CA certificate from the Windows stores to one PEM bundle and copies it to docker-1 with scp (default target `~/packetstation/test/ca/pesco-ca.pem`). Use `-Remote` for another path and `-NoCopy` to only write the file. It warns on SHA-1 signed certificates; see docs/DEPLOY.md.
## Request-VsqlServerCertificate.ps1
Run elevated on the SQL Server host behind vsql, in Windows PowerShell 5.1 or PowerShell 7. 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 usage 0xA0, SANs vsql.pesco.com, vsql and the host FQDN and short name). It installs it and validates it before anything is bound. The validation checks the key provider and KeySpec from the certificate's own key property, the key size, the signature, the dates, the EKU, and the SAN DNS names as whole names. It grants only the selected instances' service accounts read on the private key, then binds the thumbprint and reads it back.
- `-WhatIf` previews every step and changes nothing. `-Confirm` asks before each change. Declining enrollment or a key grant cancels the run with nothing bound (exit code 2).
- `-CerFile` continues a request that was pending. `-Thumbprint` binds a certificate already installed. `-Instances` limits the run (`DEV`, `MSSQLSERVER`). `-RestartInstances` restarts them and starts SQL Server Agent again if it ran. The running dependents are written to `dependents-<service>.json` before the stop and the file is removed once they run again, so after a failed restart the next restart of that service starts them too.
- Each deployment writes a new `deploy-<time>.json` in `C:\ProgramData\PacketStation\vsql-cert` with the values it replaced. Instances already bound to the thumbprint are left alone and get no snapshot, so a rerun never hides the useful one. A pending request writes `request-<time>.json` with its request id. A registry value that cannot be read stops the run; only a value confirmed absent is saved as absent.
- `-Rollback` restores the newest snapshot not yet rolled back, each instance on its own, verified by reading back, and records it in `rollback-<time>.json` with which restarts succeeded. A snapshot stays open while an instance is unrestored or its restart failed, so `-Rollback -RestartInstances` again retries exactly those. `-Snapshot` picks another. Failures are listed and the run exits with an error.
- Instances are read from the value names of `Instance Names\SQL`, so a name starting with `PS` (for example `PSREPORTING`) is found.
Tests: `scripts/tests/Request-VsqlServerCertificate.Tests.ps1` (Pester 5) fakes the host (instances, services, registry, machine store, certreq, icacls) and uses real throwaway certificates in `Cert:\CurrentUser\My`. Run it on both PowerShell versions:
```powershell
Install-Module Pester -RequiredVersion 5.7.1 -Scope CurrentUser -SkipPublisherCheck
Invoke-Pester -Path scripts ests -Output Detailed
```
The run procedure is in docs/DEPLOY.md, under the vsql known issue. Gitea issue 5 tracks the switch to `VISUAL_ENCRYPT=yes`.
-795
View File
@@ -1,795 +0,0 @@
<#
.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) -or $st.RestartPending.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).' }
@@ -1,716 +0,0 @@
#Requires -Modules @{ ModuleName = 'Pester'; ModuleVersion = '5.0' }
# Pester 5 tests for Request-VsqlServerCertificate.ps1. Run on Windows PowerShell 5.1 and PowerShell 7:
# Invoke-Pester -Path scripts\tests -Output Detailed
# The host (registry, services, certreq, icacls, machine store) is a fake. The certificates are
# real, made with New-SelfSignedCertificate in Cert:\CurrentUser\My under the friendly name
# below, and removed in AfterAll. Nothing touches the machine store, the registry or a CA.
BeforeAll {
. (Join-Path $PSScriptRoot '..\Request-VsqlServerCertificate.ps1')
$script:TestFriendlyName = 'PS2-VSQL-CERT-TEST'
$script:Root = 'HKLM:\SOFTWARE\Microsoft\Microsoft SQL Server'
$script:KeyDefault = "$Root\MSSQL16.MSSQLSERVER\MSSQLServer\SuperSocketNetLib"
$script:KeyDev = "$Root\MSSQL16.DEV\MSSQLServer\SuperSocketNetLib"
$script:HostSans = 'DNS=vsql.pesco.com&DNS=vsql&DNS=sqlhost.pesco.com&DNS=SQLHOST'
$script:Required = @('vsql.pesco.com', 'vsql', 'sqlhost.pesco.com', 'SQLHOST')
Get-ChildItem Cert:\CurrentUser\My | Where-Object { $_.FriendlyName -eq $TestFriendlyName } | ForEach-Object { Remove-Item -LiteralPath "Cert:\CurrentUser\My\$($_.Thumbprint)" -DeleteKey }
function New-TestCert {
param(
[string]$Sans = $HostSans,
[string]$Provider = 'Microsoft RSA SChannel Cryptographic Provider',
[string]$KeySpec = 'KeyExchange',
[int]$KeyLength = 2048,
[string]$Hash = 'SHA256',
[string]$Eku = '1.3.6.1.5.5.7.3.1',
[string[]]$KeyUsage = @('DigitalSignature', 'KeyEncipherment'),
[datetime]$NotBefore = (Get-Date).AddDays(-1),
[datetime]$NotAfter = (Get-Date).AddYears(1)
)
$a = @{
Subject = 'CN=vsql.pesco.com'
CertStoreLocation = 'Cert:\CurrentUser\My'
FriendlyName = $TestFriendlyName
Provider = $Provider
KeyLength = $KeyLength
HashAlgorithm = $Hash
KeyUsage = $KeyUsage
NotBefore = $NotBefore
NotAfter = $NotAfter
TextExtension = @("2.5.29.17={text}$Sans", "2.5.29.37={text}$Eku")
}
if ($KeySpec) { $a.KeySpec = $KeySpec }
$c = New-SelfSignedCertificate @a
Get-Item "Cert:\CurrentUser\My\$($c.Thumbprint)"
}
$script:Good = New-TestCert
$script:Good2 = New-TestCert
$script:Evil = New-TestCert -Sans 'DNS=vsql.pesco.com.evil.example&DNS=vsql.evil.example&DNS=sqlhost.pesco.com&DNS=SQLHOST'
$script:Cased = New-TestCert -Sans 'DNS=VSQL.PESCO.COM&DNS=Vsql&DNS=SqlHost.Pesco.Com&DNS=sqlhost'
$script:Ksp = New-TestCert -Provider 'Microsoft Software Key Storage Provider' -KeySpec ''
# The SChannel CSP cannot hold a signature key, so these use the Enhanced RSA and AES CSP (type 24).
$script:SigSpec = New-TestCert -Provider 'Microsoft Enhanced RSA and AES Cryptographic Provider' -KeySpec 'Signature' -KeyUsage 'DigitalSignature'
$script:AesCsp = New-TestCert -Provider 'Microsoft Enhanced RSA and AES Cryptographic Provider'
$script:Weak = New-TestCert -KeyLength 1024
$script:Sha1 = New-TestCert -Hash 'SHA1'
$script:NoServerAuth = New-TestCert -Eku '1.3.6.1.5.5.7.3.2'
$script:Expired = New-TestCert -NotBefore (Get-Date).AddDays(-60) -NotAfter (Get-Date).AddDays(-1)
$script:Mutating = '^(certreq -new|certreq -submit|certreq -accept|icacls|SetReg|RemoveReg|Restart)'
# A fake SQL host: default instance and DEV, service accounts, registry, machine store,
# certreq and icacls. $Fake.Calls records every operation in order.
function New-FakeHost {
param([string]$Work)
$fake = @{
Admin = $true
Calls = New-Object System.Collections.ArrayList
Log = New-Object System.Collections.ArrayList
Instances = [ordered]@{ MSSQLSERVER = 'MSSQL16.MSSQLSERVER'; DEV = 'MSSQL16.DEV' }
Services = @{
'MSSQLSERVER' = [pscustomobject]@{ Name = 'MSSQLSERVER'; StartName = 'NT Service\MSSQLSERVER' }
'MSSQL$DEV' = [pscustomobject]@{ Name = 'MSSQL$DEV'; StartName = 'PESCO\svc-sqldev' }
}
Registry = @{
$KeyDefault = @{ Certificate = ''; ForceEncryption = '0' }
$KeyDev = @{ Certificate = ''; ForceEncryption = '0' }
}
Store = @{}
Submit = 'Issued' # Issued, Pending, Failure
IssueCert = $Good
IcaclsFail = $false
SetFails = @{} # regKey -> $true: Set-ItemProperty throws
SetIgnored = @{} # regKey -> $true: the write does not stick
ReadFails = @{} # regKey -> $true: reading throws (access denied)
Dependents = @{ 'MSSQLSERVER' = @('SQLSERVERAGENT'); 'MSSQL$DEV' = @('SQLAgent$DEV') }
Running = New-Object 'System.Collections.Generic.HashSet[string]' ([StringComparer]::OrdinalIgnoreCase)
RestartFails = @{} # service -> $true: stops it and its dependents, then fails to start
KeyFile = Join-Path $Work 'machinekey.bin'
ShouldProcess = $null
}
New-Item -ItemType Directory -Force -Path $Work | Out-Null
Set-Content -Path $fake.KeyFile -Value 'key'
$realKeyInfo = ${function:Get-CertificateKeyInfo}
$p = @{
WorkDir = $Work
InstanceRoot = $Root
IsAdmin = { [void]$fake.Calls.Add('IsAdmin'); $fake.Admin }.GetNewClosure()
GetInstanceMap = { param($r) $fake.Instances }.GetNewClosure()
GetService = { param($n) [void]$fake.Calls.Add("GetService $n"); $fake.Services[$n] }.GetNewClosure()
TestRegKey = { param($k) $fake.Registry.ContainsKey($k) }.GetNewClosure()
GetRegValue = {
param($k, $n)
if ($fake.ReadFails[$k]) { throw "Requested registry access is not allowed: $k" }
$v = $fake.Registry[$k]; if ($v.ContainsKey($n)) { [string]$v[$n] } else { $null }
}.GetNewClosure()
SetRegValue = {
param($k, $n, $v)
[void]$fake.Calls.Add("SetReg $k $n=$v")
if ($fake.SetFails[$k]) { throw "Access to $k is denied" }
if (-not $fake.SetIgnored[$k]) { $fake.Registry[$k][$n] = $v }
}.GetNewClosure()
RemoveRegValue = { param($k, $n) [void]$fake.Calls.Add("RemoveReg $k $n"); if ($fake.SetFails[$k]) { throw "Access to $k is denied" }; $fake.Registry[$k].Remove($n) }.GetNewClosure()
GetHostNames = { @('sqlhost.pesco.com', 'SQLHOST') }
GetMachineCert = { param($t) $fake.Store[$t] }.GetNewClosure()
Certreq = {
param([string[]]$a)
[void]$fake.Calls.Add("certreq $($a[0]) $($a[1..($a.Count - 1)] -join ' ')")
switch ($a[0]) {
'-new' { return [pscustomobject]@{ ExitCode = 0; Output = @('CertReq: Request Created') } }
'-submit' {
$cer = $a[-1]
switch ($fake.Submit) {
'Issued' { [IO.File]::WriteAllBytes($cer, $fake.IssueCert.Export('Cert')); return [pscustomobject]@{ ExitCode = 0; Output = @('RequestId: 42', 'RequestId: "42"', 'Certificate retrieved(Issued) Issued') } }
'Pending' { return [pscustomobject]@{ ExitCode = 0; Output = @('RequestId: 43', 'RequestId: "43"', 'Certificate request is pending: Taken Under Submission (0)') } }
default { return [pscustomobject]@{ ExitCode = -2146877436; Output = @('RequestId: 44', 'Certificate not issued (Denied) Denied by Policy Module') } }
}
}
'-accept' {
$c = New-Object System.Security.Cryptography.X509Certificates.X509Certificate2 (, [IO.File]::ReadAllBytes($a[-1]))
$fake.Store[$c.Thumbprint] = Get-Item "Cert:\CurrentUser\My\$($c.Thumbprint)"
return [pscustomobject]@{ ExitCode = 0; Output = @('Installed Certificate') }
}
}
}.GetNewClosure()
Icacls = {
param([string[]]$a)
[void]$fake.Calls.Add("icacls $($a -join ' ')")
if ($fake.IcaclsFail) { return [pscustomobject]@{ ExitCode = 5; Output = @('Access is denied.') } }
[pscustomobject]@{ ExitCode = 0; Output = @('processed file') }
}.GetNewClosure()
ResolveSid = { param($acct) @{ 'NT Service\MSSQLSERVER' = 'S-1-5-80-1'; 'PESCO\svc-sqldev' = 'S-1-5-21-9-1105' }[$acct] }
# The real key reader on the real test certificate; only the store location is faked,
# since the test keys live in the user store and vsql's in the machine store.
GetKeyInfo = { param($c) $k = & $realKeyInfo $c; if ($k) { $k.Machine = $true }; $k }.GetNewClosure()
KeyFilePath = { param($k) $fake.KeyFile }.GetNewClosure()
TestFile = { param($f) Test-Path -LiteralPath $f -PathType Leaf }
GetRunningDependents = { param($n) @($fake.Dependents[$n] | Where-Object { $fake.Running.Contains($_) }) }.GetNewClosure()
RestartService = {
param($n)
[void]$fake.Calls.Add("Restart $n")
foreach ($d in $fake.Dependents[$n]) { [void]$fake.Running.Remove($d) }
[void]$fake.Running.Remove($n)
if ($fake.RestartFails[$n]) { throw "Service '$n' cannot be started" }
[void]$fake.Running.Add($n)
}.GetNewClosure()
StartService = { param($n) [void]$fake.Calls.Add("Start $n"); [void]$fake.Running.Add($n) }.GetNewClosure()
Now = { Get-Date }
Log = { param($m) [void]$fake.Log.Add("$m") }.GetNewClosure()
ShouldProcess = $null
}
foreach ($svc in 'MSSQLSERVER', 'MSSQL$DEV', 'SQLSERVERAGENT', 'SQLAgent$DEV') { [void]$fake.Running.Add($svc) }
$fake.Provider = $p
return $fake
}
function Get-MutatingCalls($Fake) { @($Fake.Calls | Where-Object { $_ -match $Mutating }) }
function Get-Snapshots($Fake) { @(Get-ChildItem -LiteralPath $Fake.Provider.WorkDir -Filter 'deploy-*.json' -File -ErrorAction SilentlyContinue | Sort-Object Name) }
function Add-ToStore($Fake, $Cert) { $Fake.Store[$Cert.Thumbprint] = $Cert }
}
AfterAll {
Get-ChildItem Cert:\CurrentUser\My | Where-Object { $_.FriendlyName -eq $TestFriendlyName } | ForEach-Object { Remove-Item -LiteralPath "Cert:\CurrentUser\My\$($_.Thumbprint)" -DeleteKey }
}
Describe 'Finding 1: administrator check' {
BeforeEach { $f = New-FakeHost (Join-Path $TestDrive ([guid]::NewGuid())); Add-ToStore $f $Good }
It 'refuses an unelevated session before any lookup or change' {
$f.Admin = $false
{ Invoke-VsqlCertificateDeployment -Provider $f.Provider -Thumbprint $Good.Thumbprint } | Should -Throw '*elevated*'
$f.Calls | Should -Be @('IsAdmin')
}
It 'refuses an unelevated rollback too' {
$f.Admin = $false
{ Invoke-VsqlCertificateDeployment -Provider $f.Provider -Rollback } | Should -Throw '*elevated*'
}
It 'lets an elevated session through' {
$r = Invoke-VsqlCertificateDeployment -Provider $f.Provider -Thumbprint $Good.Thumbprint
$r.Bound | Should -Be @('MSSQLSERVER', 'DEV')
}
It 'no longer calls Test-Admin with stray arguments' {
$text = Get-Content (Join-Path $PSScriptRoot '..\Request-VsqlServerCertificate.ps1') -Raw
$text | Should -Not -Match 'Test-Admin\s+-eq'
$text | Should -Match 'if \(-not \(& \$P\.IsAdmin\)\)'
}
}
Describe 'Finding 2: named instance service lookup' {
It 'builds a WQL filter that keeps $ unescaped' {
Get-ServiceFilter 'MSSQL$DEV' | Should -BeExactly "Name='MSSQL`$DEV'"
Get-SqlServiceName 'DEV' | Should -BeExactly 'MSSQL$DEV'
Get-SqlServiceName 'MSSQLSERVER' | Should -BeExactly 'MSSQLSERVER'
}
It 'runs that filter against real WMI without error (the old escaped form is an invalid query)' {
{ Get-CimInstance Win32_Service -Filter (Get-ServiceFilter 'MSSQL$DEV') -ErrorAction Stop } | Should -Not -Throw
{ Get-CimInstance Win32_Service -Filter "Name='MSSQL\`$DEV'" -ErrorAction Stop } | Should -Throw
}
It 'discovers the default and DEV instances with their accounts' {
$f = New-FakeHost (Join-Path $TestDrive 'disc')
$t = Get-SqlInstances $f.Provider $null
$t.Name | Should -Be @('MSSQLSERVER', 'DEV')
($t | Where-Object Name -eq 'DEV').Service | Should -BeExactly 'MSSQL$DEV'
($t | Where-Object Name -eq 'DEV').Account | Should -Be 'PESCO\svc-sqldev'
($t | Where-Object Name -eq 'DEV').RegKey | Should -Be $KeyDev
$f.Calls | Should -Contain 'GetService MSSQL$DEV'
}
It 'fails loudly when a service is missing' {
$f = New-FakeHost (Join-Path $TestDrive 'nosvc')
$f.Services.Remove('MSSQL$DEV')
{ Get-SqlInstances $f.Provider @('DEV') } | Should -Throw '*MSSQL$DEV*not found*'
}
}
Describe 'Unknown instance names' {
It 'rejects them before any change' {
$f = New-FakeHost (Join-Path $TestDrive 'unknown')
{ Invoke-VsqlCertificateDeployment -Provider $f.Provider -Instances 'DEV', 'PROD' } | Should -Throw '*Unknown instance(s): PROD*'
Get-MutatingCalls $f | Should -BeNullOrEmpty
@($f.Calls | Where-Object { $_ -like 'certreq*' }) | Should -BeNullOrEmpty
Test-Path (Join-Path $f.Provider.WorkDir '*.inf') | Should -BeFalse
}
}
Describe 'Finding 3: SAN decoding and matching' {
It 'decodes the DNS names from the raw extension' {
$ext = $Good.Extensions | Where-Object { $_.Oid.Value -eq '2.5.29.17' }
Get-SanDnsNames $ext.RawData | Should -Be @('vsql.pesco.com', 'vsql', 'sqlhost.pesco.com', 'SQLHOST')
}
It 'agrees with .NET where .NET can decode SANs (PowerShell 7)' {
$ext = $Good.Extensions | Where-Object { $_.Oid.Value -eq '2.5.29.17' }
if (-not ($ext.PSObject.Methods.Name -contains 'EnumerateDnsNames')) { Set-ItResult -Skipped -Because 'this .NET has no X509SubjectAlternativeNameExtension'; return }
Get-SanDnsNames $ext.RawData | Should -Be @($ext.EnumerateDnsNames())
}
It 'skips non-DNS entries and handles long-form lengths' {
# SEQUENCE { [7] 4 bytes IP, [2] 130 bytes name }
$name = 'a' * 126 + '.com'
$dns = [byte[]](0x82, 0x81, 130) + [Text.Encoding]::ASCII.GetBytes($name)
$ip = [byte[]](0x87, 4, 192, 168, 1, 1)
$body = $ip + $dns
$raw = [byte[]](0x30, 0x81, $body.Length) + $body
Get-SanDnsNames $raw | Should -Be @($name)
}
It 'rejects malformed data' {
{ Get-SanDnsNames ([byte[]](0x30, 0x05, 0x82, 0x09, 0x61)) } | Should -Throw
{ Get-SanDnsNames ([byte[]](0x31, 0x00)) } | Should -Throw
}
It 'rejects vsql.pesco.com.evil.example for vsql.pesco.com and vsql' {
$c = Test-ServerCertificate -Certificate $Evil -RequiredNames $Required
$c.Problems | Should -Contain 'subject alternative name vsql.pesco.com missing'
$c.Problems | Should -Contain 'subject alternative name vsql missing'
@($c.Problems | Where-Object { $_ -like '*sqlhost*' -or $_ -like '*SQLHOST*' }) | Should -BeNullOrEmpty
}
It 'accepts case variants' {
(Test-ServerCertificate -Certificate $Cased -RequiredNames $Required).Problems | Should -BeNullOrEmpty
}
}
Describe 'Certificate validation on real keys' {
It 'accepts a CSP exchange key, RSA 2048, SHA-256, Server Authentication, all SANs' {
$c = Test-ServerCertificate -Certificate $Good -RequiredNames $Required
$c.Problems | Should -BeNullOrEmpty
$c.KeyInfo.ProviderType | Should -Be 12
$c.KeyInfo.KeySpec | Should -Be 1
$c.KeyInfo.UniqueName | Should -Not -BeNullOrEmpty
}
It 'rejects a KSP (CNG) key' {
(Test-ServerCertificate -Certificate $Ksp -RequiredNames $Required).Problems -join " | " | Should -Match 'Key Storage Provider'
}
It 'rejects a CSP signature key' {
$p = (Test-ServerCertificate -Certificate $SigSpec -RequiredNames $Required).Problems
$p -join " | " | Should -Match 'AT_KEYEXCHANGE'
$p | Should -Contain 'key usage lacks key encipherment'
}
It 'rejects a CSP other than the SChannel provider' {
$p = (Test-ServerCertificate -Certificate $AesCsp -RequiredNames $Required).Problems
@($p) | Should -HaveCount 1
$p -join " | " | Should -Match 'provider type 24'
}
It 'rejects a key under 2048 bits' {
(Test-ServerCertificate -Certificate $Weak -RequiredNames $Required).Problems -join " | " | Should -Match 'key size 1024'
}
It 'rejects SHA-1' {
(Test-ServerCertificate -Certificate $Sha1 -RequiredNames $Required).Problems -join " | " | Should -Match 'not SHA-256'
}
It 'rejects a certificate without Server Authentication' {
(Test-ServerCertificate -Certificate $NoServerAuth -RequiredNames $Required).Problems | Should -Contain 'Server Authentication EKU missing'
}
It 'rejects an expired certificate' {
(Test-ServerCertificate -Certificate $Expired -RequiredNames $Required).Problems -join " | " | Should -Match 'not valid now'
}
It 'rejects one that expires within 30 days' {
(Test-ServerCertificate -Certificate $Good -RequiredNames $Required -Now $Good.NotAfter.AddDays(-10)).Problems -join " | " | Should -Match 'within 30 days'
}
It 'binds nothing when the issued certificate fails validation' {
$f = New-FakeHost (Join-Path $TestDrive 'invalid')
Add-ToStore $f $Ksp
{ Invoke-VsqlCertificateDeployment -Provider $f.Provider -Thumbprint $Ksp.Thumbprint } | Should -Throw '*not usable*'
Get-MutatingCalls $f | Should -BeNullOrEmpty
Get-Snapshots $f | Should -BeNullOrEmpty
}
}
Describe 'Certificate request INF' {
It 'asks for exactly what SQL Server needs' {
$inf = New-CertRequestInf 'vsql.pesco.com' $Required 'WebServer' '20261018-090000-000'
$inf | Should -Match 'Subject = "CN=vsql.pesco.com"'
$inf | Should -Match 'ProviderName = "Microsoft RSA SChannel Cryptographic Provider"'
$inf | Should -Match 'ProviderType = 12'
$inf | Should -Match 'KeySpec = 1'
$inf | Should -Match 'KeyLength = 2048'
$inf | Should -Match 'KeyAlgorithm = RSA'
$inf | Should -Match 'HashAlgorithm = SHA256'
$inf | Should -Match 'KeyUsage = 0xA0'
$inf | Should -Match 'MachineKeySet = TRUE'
$inf | Should -Match 'OID = 1\.3\.6\.1\.5\.5\.7\.3\.1'
$inf | Should -Match 'CertificateTemplate = WebServer'
$inf | Should -Match 'Signature = "\$Windows NT\$"'
foreach ($n in $Required) { $inf | Should -Match ([regex]::Escape("_continue_ = `"dns=$n&`"")) }
}
}
Describe 'A full request, issued' {
It 'requests, accepts with -machine, validates, grants each account, snapshots and binds' {
$f = New-FakeHost (Join-Path $TestDrive 'issued')
$r = Invoke-VsqlCertificateDeployment -Provider $f.Provider
$r.Thumbprint | Should -Be $Good.Thumbprint.ToLowerInvariant()
$r.Bound | Should -Be @('MSSQLSERVER', 'DEV')
$order = @($f.Calls | Where-Object { $_ -match $Mutating } | ForEach-Object { ($_ -split ' ')[0..1] -join ' ' })
$order[0..2] | Should -Be @('certreq -new', 'certreq -submit', 'certreq -accept')
@($f.Calls | Where-Object { $_ -like 'certreq -accept*' })[0] | Should -Match '-machine -q'
@($f.Calls | Where-Object { $_ -like 'icacls*' }) | Should -Be @("icacls $($f.KeyFile) /grant *S-1-5-80-1:(R)", "icacls $($f.KeyFile) /grant *S-1-5-21-9-1105:(R)")
$f.Registry[$KeyDev].Certificate | Should -Be $r.Thumbprint
$f.Registry[$KeyDev].ForceEncryption | Should -Be '0'
@(Get-ChildItem $f.Provider.WorkDir -Filter 'request-*.json').Count | Should -Be 1
(Get-Content (Get-Snapshots $f)[0].FullName -Raw | ConvertFrom-Json).instances.previous | Should -Be @('', '')
@($f.Calls | Where-Object { $_ -like 'Restart*' }) | Should -BeNullOrEmpty
$f.Log | Should -Contain " Restart-Service -Name 'MSSQL`$DEV' -Force"
}
It 'restarts only with -RestartInstances' {
$f = New-FakeHost (Join-Path $TestDrive 'restart')
$r = Invoke-VsqlCertificateDeployment -Provider $f.Provider -Instances DEV -RestartInstances
$r.Bound | Should -Be @('DEV')
$f.Calls | Should -Contain 'Restart MSSQL$DEV'
$f.Calls | Should -Not -Contain 'Restart MSSQLSERVER'
$f.Registry[$KeyDefault].Certificate | Should -Be ''
}
It 'fails cleanly when the CA denies the request' {
$f = New-FakeHost (Join-Path $TestDrive 'denied')
$f.Submit = 'Failure'
{ Invoke-VsqlCertificateDeployment -Provider $f.Provider } | Should -Throw '*did not issue*request id 44*'
@($f.Calls | Where-Object { $_ -match '^(icacls|SetReg|certreq -accept)' }) | Should -BeNullOrEmpty
}
}
Describe 'Pending request' {
It 'saves the request id, binds nothing, and resumes with -CerFile' {
$f = New-FakeHost (Join-Path $TestDrive 'pending')
$f.Submit = 'Pending'
$err = $null
try { Invoke-VsqlCertificateDeployment -Provider $f.Provider -Instances DEV } catch { $err = $_.Exception.Message }
$err | Should -Match 'pending on the CA \(request id 43'
$err | Should -Match 'certreq -retrieve -config "DC-02.PESCO.COM\\PESCO-PSYLOCKE-CA" 43'
$err | Should -Match '-Instances DEV'
@($f.Calls | Where-Object { $_ -match '^(icacls|SetReg|certreq -accept)' }) | Should -BeNullOrEmpty
$req = Get-Content (Get-ChildItem $f.Provider.WorkDir -Filter 'request-*.json')[0].FullName -Raw | ConvertFrom-Json
$req.id | Should -Be '43'
$req.instances | Should -Be @('DEV')
# The CA admin issues it; certreq -retrieve writes the .cer the record names.
[IO.File]::WriteAllBytes($req.cer, $Good.Export('Cert'))
$r = Invoke-VsqlCertificateDeployment -Provider $f.Provider -CerFile $req.cer -Instances DEV
@($f.Calls | Where-Object { $_ -like 'certreq -accept*' })[0] | Should -Be "certreq -accept -machine -q $($req.cer)"
$r.Bound | Should -Be @('DEV')
$f.Registry[$KeyDev].Certificate | Should -Be $Good.Thumbprint.ToLowerInvariant()
}
}
Describe 'Finding 5: declined or failed prerequisites' {
BeforeEach { $f = New-FakeHost (Join-Path $TestDrive ([guid]::NewGuid())); Add-ToStore $f $Good }
It 'a declined ACL grant cancels the run and binds nothing' {
$f.Provider.ShouldProcess = { param($t, $a) $a -notlike 'grant read*' }
$r = Invoke-VsqlCertificateDeployment -Provider $f.Provider -Thumbprint $Good.Thumbprint 3>$null
$r.Cancelled | Should -BeTrue
@($f.Calls | Where-Object { $_ -match '^(SetReg|RemoveReg|Restart|icacls)' }) | Should -BeNullOrEmpty
Get-Snapshots $f | Should -BeNullOrEmpty
}
It 'a declined second grant cancels after the first, still binding nothing' {
$count = @{ n = 0 }
$f.Provider.ShouldProcess = { param($t, $a) if ($a -like 'grant read*') { $count.n++; return ($count.n -lt 2) }; $true }.GetNewClosure()
$r = Invoke-VsqlCertificateDeployment -Provider $f.Provider -Thumbprint $Good.Thumbprint 3>$null
$r.Cancelled | Should -BeTrue
@($f.Calls | Where-Object { $_ -match '^(SetReg|RemoveReg|Restart)' }) | Should -BeNullOrEmpty
}
It 'a failed icacls stops the run and binds nothing' {
$f.IcaclsFail = $true
{ Invoke-VsqlCertificateDeployment -Provider $f.Provider -Thumbprint $Good.Thumbprint } | Should -Throw '*Could not grant*Nothing was bound*'
@($f.Calls | Where-Object { $_ -match '^(SetReg|RemoveReg|Restart)' }) | Should -BeNullOrEmpty
Get-Snapshots $f | Should -BeNullOrEmpty
}
It 'declining certreq -new, -submit or -accept cancels before the next step' {
foreach ($step in 'certreq -new', 'certreq -submit', 'certreq -accept') {
$g = New-FakeHost (Join-Path $TestDrive ([guid]::NewGuid()))
$g.Provider.ShouldProcess = { param($t, $a) $a -notlike "*$step*" }.GetNewClosure()
$r = Invoke-VsqlCertificateDeployment -Provider $g.Provider 3>$null
$r.Cancelled | Should -BeTrue
@($g.Calls | Where-Object { $_ -like "$step*" }) | Should -BeNullOrEmpty
@($g.Calls | Where-Object { $_ -match '^(icacls|SetReg)' }) | Should -BeNullOrEmpty
}
}
It 'declining one instance binding leaves that instance out of the snapshot' {
$f.Provider.ShouldProcess = { param($t, $a) $t -ne 'instance MSSQLSERVER' }
$r = Invoke-VsqlCertificateDeployment -Provider $f.Provider -Thumbprint $Good.Thumbprint
$r.Bound | Should -Be @('DEV')
(Get-Content (Get-Snapshots $f)[0].FullName -Raw | ConvertFrom-Json).instances.name | Should -Be @('DEV')
}
}
Describe 'Finding 4: snapshots per deployment' {
It 'keeps the useful snapshot when the bound certificate is run again, and rolls back to it' {
$f = New-FakeHost (Join-Path $TestDrive 'rerun')
Add-ToStore $f $Good; Add-ToStore $f $Good2
$a = $Good.Thumbprint.ToLowerInvariant(); $b = $Good2.Thumbprint.ToLowerInvariant()
$f.Registry[$KeyDefault].Certificate = $a; $f.Registry[$KeyDev].Certificate = $a
$r1 = Invoke-VsqlCertificateDeployment -Provider $f.Provider -Thumbprint $Good2.Thumbprint
$r1.Bound | Should -Be @('MSSQLSERVER', 'DEV')
$snap = Get-Snapshots $f
$snap.Count | Should -Be 1
$before = Get-FileHash $snap[0].FullName
Start-Sleep -Milliseconds 20
$f.Calls.Clear()
$r2 = Invoke-VsqlCertificateDeployment -Provider $f.Provider -Thumbprint $Good2.Thumbprint -RestartInstances
$r2.Bound | Should -BeNullOrEmpty
$r2.AlreadyBound | Should -Be @('MSSQLSERVER', 'DEV')
$r2.Snapshot | Should -BeNullOrEmpty
@($f.Calls | Where-Object { $_ -like 'SetReg*' }) | Should -BeNullOrEmpty
$f.Calls | Should -Contain 'Restart MSSQL$DEV'
(Get-Snapshots $f).Count | Should -Be 1
(Get-FileHash $snap[0].FullName).Hash | Should -Be $before.Hash
$rb = Invoke-VsqlCertificateDeployment -Provider $f.Provider -Rollback
$rb.Restored | Should -Be @('MSSQLSERVER', 'DEV')
$f.Registry[$KeyDefault].Certificate | Should -Be $a
$f.Registry[$KeyDev].Certificate | Should -Be $a
$b | Should -Not -Be $a
}
It 'never replaces an existing snapshot file' {
$path = Join-Path $TestDrive 'immutable.json'
Write-NewJsonFile $path @{ a = 1 }
{ Write-NewJsonFile $path @{ a = 2 } } | Should -Throw
(Get-Content $path -Raw | ConvertFrom-Json).a | Should -Be 1
}
It 'records an absent value as null and an empty value as empty' {
$f = New-FakeHost (Join-Path $TestDrive 'nullempty')
Add-ToStore $f $Good
$f.Registry[$KeyDefault].Remove('Certificate')
Invoke-VsqlCertificateDeployment -Provider $f.Provider -Thumbprint $Good.Thumbprint | Out-Null
$s = Get-Content (Get-Snapshots $f)[0].FullName -Raw | ConvertFrom-Json
$null -eq ($s.instances | Where-Object name -eq 'MSSQLSERVER').previous | Should -BeTrue
($s.instances | Where-Object name -eq 'DEV').previous | Should -BeExactly ''
Invoke-VsqlCertificateDeployment -Provider $f.Provider -Rollback | Out-Null
$f.Registry[$KeyDefault].ContainsKey('Certificate') | Should -BeFalse
$f.Registry[$KeyDev].Certificate | Should -BeExactly ''
}
}
Describe 'Finding 6: rollback restores each instance on its own and verifies' {
BeforeEach {
$f = New-FakeHost (Join-Path $TestDrive ([guid]::NewGuid()))
Add-ToStore $f $Good
$f.Registry[$KeyDefault].Certificate = 'aaaa'; $f.Registry[$KeyDev].Certificate = 'bbbb'
Invoke-VsqlCertificateDeployment -Provider $f.Provider -Thumbprint $Good.Thumbprint | Out-Null
$f.Calls.Clear()
}
It 'restores and verifies every instance' {
$r = Invoke-VsqlCertificateDeployment -Provider $f.Provider -Rollback
$r.Restored | Should -Be @('MSSQLSERVER', 'DEV')
$f.Registry[$KeyDefault].Certificate | Should -Be 'aaaa'
$f.Registry[$KeyDev].Certificate | Should -Be 'bbbb'
$f.Log | Should -Contain 'Restored DEV to bbbb (verified)'
}
It 'keeps going past a failed instance and reports it' {
$f.SetFails[$KeyDefault] = $true
{ Invoke-VsqlCertificateDeployment -Provider $f.Provider -Rollback } | Should -Throw '*Rollback incomplete*MSSQLSERVER*denied*'
$f.Registry[$KeyDev].Certificate | Should -Be 'bbbb'
$f.Log | Should -Not -Contain 'Restored MSSQLSERVER to aaaa (verified)'
}
It 'reports a write that does not stick instead of claiming success' {
$f.SetIgnored[$KeyDev] = $true
{ Invoke-VsqlCertificateDeployment -Provider $f.Provider -Rollback } | Should -Throw "*DEV: reads*after restoring 'bbbb'*"
$f.Registry[$KeyDefault].Certificate | Should -Be 'aaaa'
}
It 'restores only the named instances and rejects names not in the snapshot' {
{ Invoke-VsqlCertificateDeployment -Provider $f.Provider -Rollback -Instances PROD } | Should -Throw '*not in any snapshot*'
@($f.Calls | Where-Object { $_ -like 'SetReg*' }) | Should -BeNullOrEmpty
$r = Invoke-VsqlCertificateDeployment -Provider $f.Provider -Rollback -Instances DEV -RestartInstances
$r.Restored | Should -Be @('DEV')
$f.Calls | Should -Contain 'Restart MSSQL$DEV'
$f.Registry[$KeyDefault].Certificate | Should -Be $Good.Thumbprint.ToLowerInvariant()
# The next default rollback finishes the same snapshot with the instance left.
$f.Calls.Clear()
(Invoke-VsqlCertificateDeployment -Provider $f.Provider -Rollback).Restored | Should -Be @('MSSQLSERVER')
@($f.Calls | Where-Object { $_ -like "SetReg $KeyDev*" }) | Should -BeNullOrEmpty
$f.Registry[$KeyDefault].Certificate | Should -Be 'aaaa'
}
It 'retries an instance whose restore failed' {
$f.SetFails[$KeyDefault] = $true
{ Invoke-VsqlCertificateDeployment -Provider $f.Provider -Rollback } | Should -Throw '*Rollback incomplete*'
$f.SetFails.Clear()
(Invoke-VsqlCertificateDeployment -Provider $f.Provider -Rollback).Restored | Should -Be @('MSSQLSERVER')
$f.Registry[$KeyDefault].Certificate | Should -Be 'aaaa'
}
It 'defaults to the newest snapshot not yet rolled back; -Snapshot picks one' {
Add-ToStore $f $Good2
Start-Sleep -Milliseconds 20
Invoke-VsqlCertificateDeployment -Provider $f.Provider -Thumbprint $Good2.Thumbprint | Out-Null
$snaps = Get-Snapshots $f
$snaps.Count | Should -Be 2
(Invoke-VsqlCertificateDeployment -Provider $f.Provider -Rollback).Snapshot | Should -Be $snaps[1].FullName
$f.Registry[$KeyDev].Certificate | Should -Be $Good.Thumbprint.ToLowerInvariant()
(Invoke-VsqlCertificateDeployment -Provider $f.Provider -Rollback).Snapshot | Should -Be $snaps[0].FullName
$f.Registry[$KeyDev].Certificate | Should -Be 'bbbb'
# Both rolled back: a third run does nothing, it does not re-apply the newer snapshot.
$f.Calls.Clear()
(Invoke-VsqlCertificateDeployment -Provider $f.Provider -Rollback).Snapshot | Should -BeNullOrEmpty
@($f.Calls | Where-Object { $_ -like 'SetReg*' }) | Should -BeNullOrEmpty
$f.Registry[$KeyDev].Certificate | Should -Be 'bbbb'
(Invoke-VsqlCertificateDeployment -Provider $f.Provider -Rollback -Snapshot $snaps[1].Name).Snapshot | Should -Be $snaps[1].FullName
$f.Registry[$KeyDev].Certificate | Should -Be $Good.Thumbprint.ToLowerInvariant()
}
It 'automatic restore during binding restores each changed instance on its own' {
Add-ToStore $f $Good2
$f.SetFails[$KeyDev] = $true
$err = $null
try { Invoke-VsqlCertificateDeployment -Provider $f.Provider -Thumbprint $Good2.Thumbprint } catch { $err = $_.Exception.Message }
$err | Should -Match 'Binding failed'
$err | Should -Match 'restore FAILED for:\s+- DEV'
$f.Registry[$KeyDefault].Certificate | Should -Be $Good.Thumbprint.ToLowerInvariant()
$f.Log | Should -Contain "Restored MSSQLSERVER to $($Good.Thumbprint.ToLowerInvariant()) (verified)"
}
It 'automatic restore after a write that does not stick reports success only when verified' {
Add-ToStore $f $Good2
$f.SetIgnored[$KeyDev] = $true
$err = $null
try { Invoke-VsqlCertificateDeployment -Provider $f.Provider -Thumbprint $Good2.Thumbprint } catch { $err = $_.Exception.Message }
$err | Should -Match 'DEV reads'
$err | Should -Match 'Every instance changed in this run was restored and verified'
$f.Registry[$KeyDefault].Certificate | Should -Be $Good.Thumbprint.ToLowerInvariant()
}
}
Describe 'Second review: recovery paths' {
BeforeEach {
$f = New-FakeHost (Join-Path $TestDrive ([guid]::NewGuid()))
Add-ToStore $f $Good
$f.Registry[$KeyDefault].Certificate = 'aaaa'; $f.Registry[$KeyDev].Certificate = 'bbbb'
Invoke-VsqlCertificateDeployment -Provider $f.Provider -Thumbprint $Good.Thumbprint | Out-Null
$f.Calls.Clear()
}
It 'a rollback whose restart failed is retried, not skipped and not stepped past' {
$f.RestartFails['MSSQL$DEV'] = $true
{ Invoke-VsqlCertificateDeployment -Provider $f.Provider -Rollback -RestartInstances } | Should -Throw '*Rollback incomplete*MSSQL$DEV*'
$f.Registry[$KeyDev].Certificate | Should -Be 'bbbb'
$f.RestartFails.Clear()
$f.Calls.Clear()
$r = Invoke-VsqlCertificateDeployment -Provider $f.Provider -Rollback -RestartInstances
$r.Snapshot | Should -Not -BeNullOrEmpty
$r.Restarted | Should -Be @('DEV')
$f.Calls | Should -Contain 'Restart MSSQL$DEV'
$f.Calls | Should -Not -Contain 'Restart MSSQLSERVER'
$f.Registry[$KeyDev].Certificate | Should -Be 'bbbb'
# Now complete: nothing left to do.
(Invoke-VsqlCertificateDeployment -Provider $f.Provider -Rollback -RestartInstances).Snapshot | Should -BeNullOrEmpty
}
It 'a declined restart under -Confirm also stays open' {
$f.Provider.ShouldProcess = { param($t, $a) $a -notlike 'restart service*' -or $t -ne 'MSSQL$DEV' }
Invoke-VsqlCertificateDeployment -Provider $f.Provider -Rollback -RestartInstances | Out-Null
$f.Provider.ShouldProcess = $null
$r = Invoke-VsqlCertificateDeployment -Provider $f.Provider -Rollback -RestartInstances
$r.Restarted | Should -Be @('DEV')
}
It 'SQL Server Agent is started again after a failed restart is retried' {
$f.RestartFails['MSSQL$DEV'] = $true
{ Invoke-VsqlCertificateDeployment -Provider $f.Provider -Rollback -RestartInstances } | Should -Throw
$f.Running.Contains('SQLAgent$DEV') | Should -BeFalse
$depFile = Join-Path $f.Provider.WorkDir 'dependents-MSSQL_DEV.json'
(Get-Content $depFile -Raw | ConvertFrom-Json).services | Should -Be @('SQLAgent$DEV')
$f.RestartFails.Clear()
Invoke-VsqlCertificateDeployment -Provider $f.Provider -Rollback -RestartInstances | Out-Null
$f.Calls | Should -Contain 'Start SQLAgent$DEV'
$f.Running.Contains('SQLAgent$DEV') | Should -BeTrue
Test-Path $depFile | Should -BeFalse
}
It 'a successful restart starts the agent that ran and leaves no dependents file' {
Invoke-VsqlCertificateDeployment -Provider $f.Provider -Thumbprint $Good.Thumbprint -Instances MSSQLSERVER -RestartInstances | Out-Null
$f.Calls | Should -Contain 'Start SQLSERVERAGENT'
$f.Running.Contains('SQLSERVERAGENT') | Should -BeTrue
@(Get-ChildItem $f.Provider.WorkDir -Filter 'dependents-*.json') | Should -BeNullOrEmpty
}
It 'a registry read failure stops a deployment before any snapshot or change' {
Add-ToStore $f $Good2
$before = @(Get-Snapshots $f).Count
$f.ReadFails[$KeyDev] = $true
{ Invoke-VsqlCertificateDeployment -Provider $f.Provider -Thumbprint $Good2.Thumbprint } | Should -Throw '*not allowed*'
@($f.Calls | Where-Object { $_ -match '^(SetReg|RemoveReg)' }) | Should -BeNullOrEmpty
@(Get-Snapshots $f).Count | Should -Be $before
}
It 'a registry read failure during rollback is a failure, never verified as absent' {
$f.ReadFails[$KeyDev] = $true
{ Invoke-VsqlCertificateDeployment -Provider $f.Provider -Rollback } | Should -Throw '*Rollback incomplete*DEV*not allowed*'
$f.Log | Should -Not -Contain 'Restored DEV to bbbb (verified)'
}
}
Describe 'Real registry operations (HKCU test key)' {
BeforeAll {
$script:TestRoot = "HKCU:\Software\PS2-VSQL-CERT-TEST-$([guid]::NewGuid())"
New-Item -Path "$TestRoot\Instance Names\SQL" -Force | Out-Null
New-ItemProperty -Path "$TestRoot\Instance Names\SQL" -Name 'PSREPORTING' -Value 'MSSQL16.PSREPORTING' | Out-Null
New-ItemProperty -Path "$TestRoot\Instance Names\SQL" -Name 'DEV' -Value 'MSSQL16.DEV' | Out-Null
New-Item -Path "$TestRoot\Empty" -Force | Out-Null
New-ItemProperty -Path "$TestRoot\Empty" -Name 'Certificate' -Value '' | Out-Null
New-Item -Path "$TestRoot\Absent" -Force | Out-Null
$script:Real = Get-VsqlDefaultProvider
}
AfterAll { Remove-Item -LiteralPath $TestRoot -Recurse -Force }
It 'lists every instance, including one whose name starts with PS' {
$map = & $Real.GetInstanceMap $TestRoot
@($map.Keys) | Should -Be @('PSREPORTING', 'DEV')
$map['PSREPORTING'] | Should -Be 'MSSQL16.PSREPORTING'
}
It 'returns an empty map when no instance is registered' {
(& $Real.GetInstanceMap "$TestRoot\Absent").Count | Should -Be 0
}
It 'tells an empty value from an absent one' {
& $Real.GetRegValue "$TestRoot\Empty" 'Certificate' | Should -BeExactly ''
$null -eq (& $Real.GetRegValue "$TestRoot\Absent" 'Certificate') | Should -BeTrue
}
It 'throws, rather than reporting absent, when the key cannot be read' {
{ & $Real.GetRegValue "$TestRoot\Missing" 'Certificate' } | Should -Throw
}
It 'writes, reads back and removes through the real operations' {
& $Real.SetRegValue "$TestRoot\Absent" 'Certificate' 'abcd'
& $Real.GetRegValue "$TestRoot\Absent" 'Certificate' | Should -Be 'abcd'
& $Real.RemoveRegValue "$TestRoot\Absent" 'Certificate'
$null -eq (& $Real.GetRegValue "$TestRoot\Absent" 'Certificate') | Should -BeTrue
}
}
Describe '-WhatIf' {
It 'changes nothing on the real ShouldProcess path' {
$f = New-FakeHost (Join-Path $TestDrive 'whatif-real')
$r = Invoke-VsqlCertificateDeployment -Provider $f.Provider -RestartInstances -WhatIf 6>$null
Get-MutatingCalls $f | Should -BeNullOrEmpty
@(Get-ChildItem $f.Provider.WorkDir -File | Where-Object Name -ne 'machinekey.bin') | Should -BeNullOrEmpty
$r.Bound | Should -BeNullOrEmpty
}
It 'previews every step of a request' {
$f = New-FakeHost (Join-Path $TestDrive 'whatif-steps')
$seen = New-Object System.Collections.ArrayList
$f.Provider.ShouldProcess = { param($t, $a) [void]$seen.Add("$a | $t"); $false }.GetNewClosure()
Invoke-VsqlCertificateDeployment -Provider $f.Provider -RestartInstances -WhatIf | Out-Null
Get-MutatingCalls $f | Should -BeNullOrEmpty
$expect = 'certreq -new', 'certreq -submit', 'certreq -accept', 'grant read to NT Service\MSSQLSERVER', 'grant read to PESCO\svc-sqldev',
'set Certificate=<new thumbprint>* | instance MSSQLSERVER', 'set Certificate=<new thumbprint>* | instance DEV', 'restart service* | MSSQLSERVER', 'restart service* | MSSQL$DEV'
foreach ($e in $expect) { @($seen | Where-Object { $_ -like "*$e*" }).Count | Should -Be 1 -Because "the preview should include '$e'" }
$f.Log -join "`n" | Should -Match 'ProviderType = 12'
}
It 'previews a rollback without changing it' {
$f = New-FakeHost (Join-Path $TestDrive 'whatif-rollback')
Add-ToStore $f $Good
Invoke-VsqlCertificateDeployment -Provider $f.Provider -Thumbprint $Good.Thumbprint | Out-Null
$f.Calls.Clear()
Invoke-VsqlCertificateDeployment -Provider $f.Provider -Rollback -RestartInstances -WhatIf 6>$null | Out-Null
Get-MutatingCalls $f | Should -BeNullOrEmpty
$f.Registry[$KeyDev].Certificate | Should -Be $Good.Thumbprint.ToLowerInvariant()
}
}
Describe 'Account to SID' {
It 'maps the built-in service accounts that icacls cannot name' {
Resolve-AccountSid 'LocalSystem' | Should -Be 'S-1-5-18'
Resolve-AccountSid 'NT AUTHORITY\NetworkService' | Should -Be 'S-1-5-20'
Resolve-AccountSid 'NT AUTHORITY\LocalService' | Should -Be 'S-1-5-19'
}
It 'translates a real account' {
Resolve-AccountSid "$env:USERDOMAIN\$env:USERNAME" | Should -Match '^S-1-5-21-'
}
}
Describe 'Native commands' {
It 'captures stderr and the exit code without throwing under Stop' {
$ErrorActionPreference = 'Stop'
$r = Invoke-NativeCommand 'cmd.exe' @('/c', 'echo out & echo err 1>&2 & exit 3')
$r.ExitCode | Should -Be 3
$r.Output -join ' ' | Should -Match 'err'
}
}