Skip to content

Commit 6b004ac

Browse files
Get-DbaNetworkEncryption - Add new command to retrieve TLS certificate from SQL Server network
Adds Get-DbaNetworkEncryption which retrieves the TLS/SSL certificate presented by a SQL Server instance during the TLS handshake, without requiring Windows host access or WinRM. Fixes #9112 Key features: - Connects directly to SQL Server's TCP port via TLS/SSL - No Windows host access required (no WinRM) - Handles named instances via SQL Browser (UDP 1434) with proper DNS resolution - Returns Subject, Issuer, Thumbprint, Expiration, DNS SANs, and more - Graceful error handling for instances without TLS configured - Tests skip cleanly when no certificate is configured (do Get-DbaNetworkEncryption) Co-authored-by: Andreas Jordan <andreasjordan@users.noreply.github.com>
1 parent 63c906f commit 6b004ac

4 files changed

Lines changed: 348 additions & 0 deletions

File tree

dbatools.psd1

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -333,6 +333,7 @@
333333
'Get-DbaNetworkActivity',
334334
'Get-DbaNetworkCertificate',
335335
'Get-DbaNetworkConfiguration',
336+
'Get-DbaNetworkEncryption',
336337
'Get-DbaOpenTransaction',
337338
'Get-DbaOperatingSystem',
338339
'Get-DbaPageFileSetting',

dbatools.psm1

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1064,6 +1064,7 @@ if ($PSVersionTable.PSVersion.Major -lt 5) {
10641064
'Test-DbaComputerCertificateExpiration',
10651065
'Test-DbaNetworkCertificate',
10661066
'Get-DbaNetworkCertificate',
1067+
'Get-DbaNetworkEncryption',
10671068
'Set-DbaNetworkCertificate',
10681069
'Remove-DbaDbLogShipping',
10691070
'Invoke-DbaDbLogShipping',
Lines changed: 287 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,287 @@
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+
}
Lines changed: 59 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,59 @@
1+
#Requires -Module @{ ModuleName="Pester"; ModuleVersion="5.0" }
2+
param(
3+
$ModuleName = "dbatools",
4+
$CommandName = "Get-DbaNetworkEncryption",
5+
$PSDefaultParameterValues = $TestConfig.Defaults
6+
)
7+
8+
Describe $CommandName -Tag UnitTests {
9+
Context "Parameter validation" {
10+
It "Should have the expected parameters" {
11+
$hasParameters = (Get-Command $CommandName).Parameters.Values.Name | Where-Object { $PSItem -notin ("WhatIf", "Confirm") }
12+
$expectedParameters = $TestConfig.CommonParameters
13+
$expectedParameters += @(
14+
"SqlInstance",
15+
"SqlCredential",
16+
"EnableException"
17+
)
18+
Compare-Object -ReferenceObject $expectedParameters -DifferenceObject $hasParameters | Should -BeNullOrEmpty
19+
}
20+
}
21+
}
22+
23+
Describe $CommandName -Tag IntegrationTests {
24+
BeforeAll {
25+
$PSDefaultParameterValues["*-Dba*:EnableException"] = $true
26+
$PSDefaultParameterValues.Remove("*-Dba*:EnableException")
27+
}
28+
29+
Context "Certificate retrieval" {
30+
BeforeAll {
31+
# Attempt to retrieve the certificate - not all environments have TLS configured
32+
$result = Get-DbaNetworkEncryption -SqlInstance $TestConfig.instance1 -WarningAction SilentlyContinue
33+
}
34+
35+
It "Should not throw an error when connecting" {
36+
# If result is null it means no certificate is configured, which is a valid state
37+
# We just verify the command runs without throwing a terminating error
38+
{ Get-DbaNetworkEncryption -SqlInstance $TestConfig.instance1 -WarningAction SilentlyContinue } | Should -Not -Throw
39+
}
40+
41+
It "Should return certificate with expected properties when TLS is configured" {
42+
if ($null -eq $result) {
43+
Set-ItResult -Skipped -Because "No TLS certificate is configured on this SQL Server instance"
44+
}
45+
$result.SqlInstance | Should -Not -BeNullOrEmpty
46+
$result.Port | Should -BeGreaterThan 0
47+
$result.Subject | Should -Not -BeNullOrEmpty
48+
$result.Thumbprint | Should -Not -BeNullOrEmpty
49+
}
50+
51+
It "Should return valid expiration date when TLS is configured" {
52+
if ($null -eq $result) {
53+
Set-ItResult -Skipped -Because "No TLS certificate is configured on this SQL Server instance"
54+
}
55+
$result.Expires | Should -BeOfType [datetime]
56+
$result.NotBefore | Should -BeOfType [datetime]
57+
}
58+
}
59+
}

0 commit comments

Comments
 (0)