|
| 1 | +function Get-DbaNetworkEncryption { |
| 2 | + <# |
| 3 | + .SYNOPSIS |
| 4 | + Retrieves the TLS/SSL certificate presented by a SQL Server instance over the network. |
| 5 | +
|
| 6 | + .DESCRIPTION |
| 7 | + Connects directly to a SQL Server instance's TCP port and retrieves the TLS/SSL certificate |
| 8 | + that the server presents during the TLS handshake. This does not require Windows host access |
| 9 | + or WinRM - it works purely over the network like a client connecting to SQL Server. |
| 10 | +
|
| 11 | + This complements Get-DbaNetworkCertificate, which reads the configured certificate from the |
| 12 | + Windows registry (requires WinRM). This command instead shows what certificate is actually |
| 13 | + being presented to clients over the network, without requiring any host-level access. |
| 14 | +
|
| 15 | + For named instances, the SQL Browser service is queried on UDP port 1434 to determine the |
| 16 | + TCP port number. For default instances, port 1433 is used unless overridden. |
| 17 | +
|
| 18 | + .PARAMETER SqlInstance |
| 19 | + The target SQL Server instance or instances. Accepts pipeline input. |
| 20 | +
|
| 21 | + .PARAMETER SqlCredential |
| 22 | + Not used by this command - included for pipeline compatibility. Authentication is not |
| 23 | + required since this command connects at the TLS layer before SQL Server authentication. |
| 24 | +
|
| 25 | + .PARAMETER EnableException |
| 26 | + By default, when something goes wrong we try to catch it, interpret it and give you a friendly warning message. |
| 27 | + This avoids overwhelming you with "sea of red" exceptions, but is inconvenient because it basically disables advanced scripting. |
| 28 | + Using this switch turns this "nice by default" feature off and enables you to catch exceptions with your own try/catch. |
| 29 | +
|
| 30 | + .NOTES |
| 31 | + Tags: Certificate, Encryption, Security, Network |
| 32 | + Author: the dbatools team + Claude |
| 33 | +
|
| 34 | + Website: https://dbatools.io |
| 35 | + Copyright: (c) 2024 by dbatools, licensed under MIT |
| 36 | + License: MIT https://opensource.org/licenses/MIT |
| 37 | +
|
| 38 | + .LINK |
| 39 | + https://dbatools.io/Get-DbaNetworkEncryption |
| 40 | +
|
| 41 | + .OUTPUTS |
| 42 | + PSCustomObject |
| 43 | +
|
| 44 | + Returns one object per SQL Server instance that successfully presents a TLS certificate. |
| 45 | +
|
| 46 | + Properties: |
| 47 | + - ComputerName: The hostname of the SQL Server |
| 48 | + - InstanceName: The SQL Server instance name (MSSQLSERVER for default) |
| 49 | + - SqlInstance: The full SQL Server instance identifier |
| 50 | + - Port: The TCP port used to connect |
| 51 | + - Subject: The certificate subject (Common Name) |
| 52 | + - Issuer: The certificate issuer |
| 53 | + - Thumbprint: SHA-1 hash thumbprint of the certificate |
| 54 | + - NotBefore: DateTime when the certificate becomes valid |
| 55 | + - Expires: DateTime when the certificate expires |
| 56 | + - DnsNameList: Array of DNS names from the Subject Alternative Names extension |
| 57 | + - SerialNumber: Certificate serial number |
| 58 | + - Certificate: The full X509Certificate2 object |
| 59 | +
|
| 60 | + .EXAMPLE |
| 61 | + PS C:\> Get-DbaNetworkEncryption -SqlInstance sql2016 |
| 62 | +
|
| 63 | + Retrieves the TLS certificate presented by the default SQL Server instance on sql2016. |
| 64 | +
|
| 65 | + .EXAMPLE |
| 66 | + PS C:\> Get-DbaNetworkEncryption -SqlInstance sql2016\sqlexpress |
| 67 | +
|
| 68 | + Retrieves the TLS certificate presented by the named instance sqlexpress on sql2016. |
| 69 | + Queries the SQL Browser service to determine the port. |
| 70 | +
|
| 71 | + .EXAMPLE |
| 72 | + PS C:\> Get-DbaNetworkEncryption -SqlInstance sql2016, sql2017, sql2019 | Select-Object SqlInstance, Subject, Expires, Thumbprint |
| 73 | +
|
| 74 | + Retrieves certificates from multiple SQL Server instances and shows key certificate details. |
| 75 | +
|
| 76 | + .EXAMPLE |
| 77 | + PS C:\> $servers | Get-DbaNetworkEncryption | Where-Object { $_.Expires -lt (Get-Date).AddDays(30) } |
| 78 | +
|
| 79 | + Finds SQL Server instances whose TLS certificates expire within the next 30 days. |
| 80 | + #> |
| 81 | + [CmdletBinding()] |
| 82 | + param ( |
| 83 | + [Parameter(Mandatory, ValueFromPipeline)] |
| 84 | + [DbaInstanceParameter[]]$SqlInstance, |
| 85 | + [PSCredential]$SqlCredential, |
| 86 | + [switch]$EnableException |
| 87 | + ) |
| 88 | + begin { |
| 89 | + function Get-SqlBrowserPort { |
| 90 | + param ( |
| 91 | + [string]$ComputerName, |
| 92 | + [string]$InstanceName |
| 93 | + ) |
| 94 | + try { |
| 95 | + $udpClient = New-Object System.Net.Sockets.UdpClient |
| 96 | + $udpClient.Client.ReceiveTimeout = 3000 |
| 97 | + $udpClient.Client.SendTimeout = 3000 |
| 98 | + |
| 99 | + # Resolve hostname to IP address for UdpClient |
| 100 | + $hostEntry = [System.Net.Dns]::GetHostEntry($ComputerName) |
| 101 | + $ipAddress = $hostEntry.AddressList | Where-Object { |
| 102 | + $PSItem.AddressFamily -eq [System.Net.Sockets.AddressFamily]::InterNetwork |
| 103 | + } | Select-Object -First 1 |
| 104 | + |
| 105 | + if ($null -eq $ipAddress) { |
| 106 | + $ipAddress = $hostEntry.AddressList | Select-Object -First 1 |
| 107 | + } |
| 108 | + |
| 109 | + $endPoint = New-Object System.Net.IPEndPoint($ipAddress, 1434) |
| 110 | + |
| 111 | + # SQL Browser single-instance query: 0x04 followed by the instance name |
| 112 | + $instanceBytes = [System.Text.Encoding]::ASCII.GetBytes($InstanceName) |
| 113 | + $queryBytes = New-Object byte[] ($instanceBytes.Length + 1) |
| 114 | + $queryBytes[0] = 0x04 |
| 115 | + [System.Array]::Copy($instanceBytes, 0, $queryBytes, 1, $instanceBytes.Length) |
| 116 | + |
| 117 | + $null = $udpClient.Send($queryBytes, $queryBytes.Length, $endPoint) |
| 118 | + $receiveEndPoint = New-Object System.Net.IPEndPoint([System.Net.IPAddress]::Any, 0) |
| 119 | + $responseBytes = $udpClient.Receive([ref]$receiveEndPoint) |
| 120 | + $udpClient.Close() |
| 121 | + |
| 122 | + $responseText = [System.Text.Encoding]::ASCII.GetString($responseBytes) |
| 123 | + |
| 124 | + # Parse the response: key=value;key=value;... |
| 125 | + if ($responseText -match "tcp;(\d+)") { |
| 126 | + return [int]$matches[1] |
| 127 | + } |
| 128 | + } catch { |
| 129 | + Write-Message -Level Debug -Message "Failed to query SQL Browser on $ComputerName for instance $InstanceName`: $_" |
| 130 | + } finally { |
| 131 | + if ($null -ne $udpClient) { |
| 132 | + try { $udpClient.Close() } catch { } |
| 133 | + } |
| 134 | + } |
| 135 | + return $null |
| 136 | + } |
| 137 | + |
| 138 | + function Get-TlsCertificate { |
| 139 | + param ( |
| 140 | + [string]$ComputerName, |
| 141 | + [int]$Port, |
| 142 | + [string]$TargetHost |
| 143 | + ) |
| 144 | + $tcpClient = $null |
| 145 | + $sslStream = $null |
| 146 | + |
| 147 | + try { |
| 148 | + $tcpClient = New-Object System.Net.Sockets.TcpClient |
| 149 | + $tcpClient.ReceiveTimeout = 5000 |
| 150 | + $tcpClient.SendTimeout = 5000 |
| 151 | + |
| 152 | + $connectResult = $tcpClient.BeginConnect($ComputerName, $Port, $null, $null) |
| 153 | + $waited = $connectResult.AsyncWaitHandle.WaitOne(5000, $false) |
| 154 | + |
| 155 | + if (-not $waited) { |
| 156 | + throw "Connection timed out to ${ComputerName}:${Port}" |
| 157 | + } |
| 158 | + |
| 159 | + $tcpClient.EndConnect($connectResult) |
| 160 | + |
| 161 | + if (-not $tcpClient.Connected) { |
| 162 | + throw "Failed to connect to ${ComputerName}:${Port}" |
| 163 | + } |
| 164 | + |
| 165 | + $networkStream = $tcpClient.GetStream() |
| 166 | + |
| 167 | + # We need to send a SQL Server pre-login packet before doing TLS, |
| 168 | + # because SQL Server uses STARTTLS-style negotiation |
| 169 | + # Pre-login packet: Type=0x12, Status=0x01, Length=0x002F, SPID=0x0000, PacketID=0x01, Window=0x00 |
| 170 | + # followed by pre-login options |
| 171 | + $preLoginBytes = [byte[]]( |
| 172 | + 0x12, 0x01, 0x00, 0x2F, 0x00, 0x00, 0x01, 0x00, # TDS header |
| 173 | + 0x00, 0x00, 0x15, 0x00, 0x06, 0x01, 0x00, 0x1B, # VERSION option |
| 174 | + 0x00, 0x01, 0x02, 0x00, 0x1C, 0x00, 0x01, 0x03, # ENCRYPTION option |
| 175 | + 0x00, 0x1D, 0x00, 0x00, 0x04, 0x00, 0x1D, 0x00, # INSTOPT + THREADID |
| 176 | + 0x01, 0xFF, 0x0E, 0x00, 0x00, 0x00, 0x00, 0x00, # options |
| 177 | + 0x00, 0x00, 0x01, 0x00, 0x00, 0x00, 0x00 # version + encryption=ENCRYPT_ON |
| 178 | + ) |
| 179 | + |
| 180 | + $networkStream.Write($preLoginBytes, 0, $preLoginBytes.Length) |
| 181 | + $networkStream.Flush() |
| 182 | + |
| 183 | + # Read the pre-login response |
| 184 | + $responseBuffer = New-Object byte[] 4096 |
| 185 | + $bytesRead = $networkStream.Read($responseBuffer, 0, $responseBuffer.Length) |
| 186 | + |
| 187 | + if ($bytesRead -lt 8) { |
| 188 | + throw "Invalid pre-login response from ${ComputerName}:${Port}" |
| 189 | + } |
| 190 | + |
| 191 | + # Now wrap in SSL stream and do TLS handshake |
| 192 | + # The server certificate is captured via the validation callback |
| 193 | + $script:capturedCertificate = $null |
| 194 | + |
| 195 | + $certValidationCallback = { |
| 196 | + param($sender, $certificate, $chain, $sslPolicyErrors) |
| 197 | + $script:capturedCertificate = $certificate |
| 198 | + return $true |
| 199 | + } |
| 200 | + |
| 201 | + $sslStream = New-Object System.Net.Security.SslStream( |
| 202 | + $networkStream, |
| 203 | + $false, |
| 204 | + $certValidationCallback |
| 205 | + ) |
| 206 | + |
| 207 | + $sslStream.AuthenticateAsClient($TargetHost) |
| 208 | + |
| 209 | + return $script:capturedCertificate |
| 210 | + } catch { |
| 211 | + throw |
| 212 | + } finally { |
| 213 | + if ($null -ne $sslStream) { |
| 214 | + try { $sslStream.Close() } catch { } |
| 215 | + } |
| 216 | + if ($null -ne $tcpClient) { |
| 217 | + try { $tcpClient.Close() } catch { } |
| 218 | + } |
| 219 | + } |
| 220 | + } |
| 221 | + } |
| 222 | + process { |
| 223 | + foreach ($instance in $SqlInstance) { |
| 224 | + $computerName = $instance.ComputerName |
| 225 | + $instanceName = $instance.InstanceName |
| 226 | + $sqlInstanceName = $instance.FullName |
| 227 | + |
| 228 | + # Determine port |
| 229 | + $port = $instance.Port |
| 230 | + if ($port -le 0 -or $null -eq $port) { |
| 231 | + if ($instanceName -and $instanceName -ne "MSSQLSERVER") { |
| 232 | + # Named instance - query SQL Browser |
| 233 | + Write-Message -Level Verbose -Message "Querying SQL Browser for $instanceName on $computerName" |
| 234 | + $port = Get-SqlBrowserPort -ComputerName $computerName -InstanceName $instanceName |
| 235 | + if ($null -eq $port) { |
| 236 | + Write-Message -Level Warning -Message "Failed to query SQL Browser for $sqlInstanceName - trying default port 1433" |
| 237 | + $port = 1433 |
| 238 | + } |
| 239 | + } else { |
| 240 | + $port = 1433 |
| 241 | + } |
| 242 | + } |
| 243 | + |
| 244 | + Write-Message -Level Verbose -Message "Connecting to $computerName on port $port to retrieve TLS certificate" |
| 245 | + |
| 246 | + try { |
| 247 | + $rawCert = Get-TlsCertificate -ComputerName $computerName -Port $port -TargetHost $computerName |
| 248 | + |
| 249 | + if ($null -eq $rawCert) { |
| 250 | + Stop-Function -Message "No certificate returned from $sqlInstanceName" -Target $instance -Continue |
| 251 | + continue |
| 252 | + } |
| 253 | + |
| 254 | + $cert = New-Object System.Security.Cryptography.X509Certificates.X509Certificate2($rawCert) |
| 255 | + |
| 256 | + # Extract DNS names from Subject Alternative Names |
| 257 | + $dnsNames = @() |
| 258 | + foreach ($extension in $cert.Extensions) { |
| 259 | + if ($extension.Oid.FriendlyName -eq "Subject Alternative Name") { |
| 260 | + $asnData = New-Object System.Security.Cryptography.AsnEncodedData($extension.Oid, $extension.RawData) |
| 261 | + $sanText = $asnData.Format($false) |
| 262 | + $dnsEntries = $sanText -split ", " | Where-Object { $PSItem -match "^DNS Name=" } |
| 263 | + $dnsNames = $dnsEntries | ForEach-Object { $PSItem -replace "^DNS Name=", "" } |
| 264 | + break |
| 265 | + } |
| 266 | + } |
| 267 | + |
| 268 | + [PSCustomObject]@{ |
| 269 | + ComputerName = $computerName |
| 270 | + InstanceName = $instanceName |
| 271 | + SqlInstance = $sqlInstanceName |
| 272 | + Port = $port |
| 273 | + Subject = $cert.Subject |
| 274 | + Issuer = $cert.Issuer |
| 275 | + Thumbprint = $cert.Thumbprint |
| 276 | + NotBefore = $cert.NotBefore |
| 277 | + Expires = $cert.NotAfter |
| 278 | + DnsNameList = $dnsNames |
| 279 | + SerialNumber = $cert.SerialNumber |
| 280 | + Certificate = $cert |
| 281 | + } |
| 282 | + } catch { |
| 283 | + Stop-Function -Message "Failed to retrieve certificate from $sqlInstanceName | $($PSItem.Exception.Message)" -Target $instance -ErrorRecord $_ -Continue |
| 284 | + } |
| 285 | + } |
| 286 | + } |
| 287 | +} |
0 commit comments