From fdfb8ae40fcf236081f19deb3b9ffd6ba319ab15 Mon Sep 17 00:00:00 2001 From: Spencer McGuire Date: Sun, 18 Oct 2026 21:15:49 -0600 Subject: [PATCH] scripts: Request-VsqlServerCertificate.ps1 requests and installs a SQL Server TLS certificate from PESCO-PSYLOCKE-CA Co-Authored-By: Claude Fable 5.1 --- scripts/README.md | 4 + scripts/Request-VsqlServerCertificate.ps1 | 181 ++++++++++++++++++++++ 2 files changed, 185 insertions(+) create mode 100644 scripts/Request-VsqlServerCertificate.ps1 diff --git a/scripts/README.md b/scripts/README.md index 60912b6..8829524 100644 --- a/scripts/README.md +++ b/scripts/README.md @@ -6,3 +6,7 @@ systemd timer on docker-1, and a restore script with a rehearsal recorded in doc ## 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. + +## 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`. diff --git a/scripts/Request-VsqlServerCertificate.ps1 b/scripts/Request-VsqlServerCertificate.ps1 new file mode 100644 index 0000000..0e0d2d7 --- /dev/null +++ b/scripts/Request-VsqlServerCertificate.ps1 @@ -0,0 +1,181 @@ +<# +.SYNOPSIS +Requests a SQL Server TLS certificate for vsql from PESCO-PSYLOCKE-CA and installs it on 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. The script: + +1. Writes a certreq INF with the requirements Packet Station needs: RSA 2048, SHA-256, + Server Authentication EKU, subject CN=vsql.pesco.com, subject alternative names + vsql.pesco.com, vsql, the host's own FQDN and short name, key in the Microsoft Software + Key Storage Provider, exportable, in the machine store. +2. Submits the request to the CA and installs the issued certificate. +3. Grants the SQL Server service account(s) read access to the private key. +4. Points each SQL Server instance at the certificate (the same registry value that SQL Server + Configuration Manager sets). ForceEncryption is left as it is. +5. Prints the thumbprint and what to restart. With -RestartInstances it restarts the services + (a short VISUAL outage; run off-hours). + +Afterwards, from docker-1: + docker compose ... run --rm --no-deps -e VISUAL_ENCRYPT=yes worker doctor --skip mfiles,db,volume,smtp,clamav,ldap +should show "visual: Connect PASS" once the image trusts the PESCO CA. + +.PARAMETER CertificateAuthority +The CA config string, host\name. Default DC-02.PESCO.COM\PESCO-PSYLOCKE-CA. + +.PARAMETER Template +The certificate template name. Default WebServer. Use the template IT prefers for servers; it +must allow Server Authentication and an exportable private key is not required but is set. + +.PARAMETER Instances +SQL Server instance names to configure. Default: every instance found on this host +(MSSQLSERVER for the default instance, DEV for vsql\DEV, and so on). + +.PARAMETER RestartInstances +Restart each configured instance so the certificate takes effect. + +.EXAMPLE +.\Request-VsqlServerCertificate.ps1 +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 +.\Request-VsqlServerCertificate.ps1 -WhatIf +Shows what would happen without changing anything. +#> +[CmdletBinding(SupportsShouldProcess)] +param( + [string]$CertificateAuthority = 'DC-02.PESCO.COM\PESCO-PSYLOCKE-CA', + [string]$Template = 'WebServer', + [string[]]$DnsNames = @('vsql.pesco.com', 'vsql'), + [string[]]$Instances, + [switch]$RestartInstances +) + +$ErrorActionPreference = 'Stop' + +if (-not ([Security.Principal.WindowsPrincipal][Security.Principal.WindowsIdentity]::GetCurrent()).IsInRole([Security.Principal.WindowsBuiltInRole]::Administrator)) { + throw 'Run this in an elevated PowerShell on the SQL Server host.' +} + +$fqdn = [System.Net.Dns]::GetHostEntry($env:COMPUTERNAME).HostName +$short = $env:COMPUTERNAME +$names = @($DnsNames + $fqdn + $short) | Select-Object -Unique +$primary = $DnsNames[0] +Write-Host "Host $fqdn. Certificate for CN=$primary with SANs: $($names -join ', ')" + +# --- 1. INF for certreq --------------------------------------------------------------------- +$work = Join-Path $env:ProgramData 'PacketStation\vsql-cert' +New-Item -ItemType Directory -Force -Path $work | Out-Null +$stamp = Get-Date -Format 'yyyyMMdd-HHmmss' +$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" +@" +[Version] +Signature = "`$Windows NT`$" + +[NewRequest] +Subject = "CN=$primary" +KeyLength = 2048 +KeyAlgorithm = RSA +HashAlgorithm = SHA256 +ProviderName = "Microsoft Software Key Storage Provider" +KeySpec = 0 +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 +"@ | Set-Content -Path $inf -Encoding ASCII + +Write-Host "INF written: $inf" + +if (-not $PSCmdlet.ShouldProcess($CertificateAuthority, "request certificate from template $Template")) { return } + +# --- 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 `"$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() + +$keySize = $cert.PublicKey.Key.KeySize +$sig = $cert.SignatureAlgorithm.FriendlyName +Write-Host "Installed: $($cert.Subject) thumbprint $thumb, $keySize-bit, $sig, expires $($cert.NotAfter.ToString('yyyy-MM-dd'))" +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.' } + +# --- 3. private key read access for the SQL Server service accounts ------------------------- +$services = Get-CimInstance Win32_Service | Where-Object { $_.Name -eq 'MSSQLSERVER' -or $_.Name -like 'MSSQL$*' } +if (-not $services) { Write-Warning 'No SQL Server service found on this host. Is this the vsql machine?' } + +$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 ----------------------------------------------- +$instanceRoot = 'HKLM:\SOFTWARE\Microsoft\Microsoft SQL Server' +$instanceMap = Get-ItemProperty "$instanceRoot\Instance Names\SQL" -ErrorAction SilentlyContinue +if (-not $instanceMap) { throw 'No SQL Server instances are registered on this host.' } +$all = $instanceMap.PSObject.Properties | Where-Object { $_.Name -notlike 'PS*' } | ForEach-Object { @{ Name = $_.Name; Id = $_.Value } } +if ($Instances) { $all = $all | Where-Object { $Instances -contains $_.Name } } +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 ', ')" } + +foreach ($inst in $all) { + $key = "$instanceRoot\$($inst.Id)\MSSQLServer\SuperSocketNetLib" + if ($PSCmdlet.ShouldProcess("$($inst.Name) ($key)", "set Certificate=$thumb")) { + Set-ItemProperty -Path $key -Name Certificate -Value $thumb + $force = (Get-ItemProperty $key -Name ForceEncryption -ErrorAction SilentlyContinue).ForceEncryption + Write-Host "Instance $($inst.Name): Certificate set (ForceEncryption=$force, unchanged)" + } +} + +# --- 5. restart ------------------------------------------------------------------------------ +$toRestart = $all | ForEach-Object { if ($_.Name -eq 'MSSQLSERVER') { 'MSSQLSERVER' } else { "MSSQL`$$($_.Name)" } } +if ($RestartInstances) { + foreach ($s in $toRestart) { + if ($PSCmdlet.ShouldProcess($s, 'restart service')) { Restart-Service -Name $s -Force; Write-Host "Restarted $s" } + } +} else { + Write-Host '' + 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" } +} + +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 'Then tell Packet Station: the stacks switch to VISUAL_ENCRYPT=yes (Gitea issue 5).'