Last active
November 29, 2022 14:31
-
-
Save JohnLBevan/c7974c2839e1486345d63ab6bd76523c to your computer and use it in GitHub Desktop.
A first pass at a script for monitoriong SMTPS certificicate lifetimes in PRTG. Notes on usage here: https://www.paessler.com/manuals/prtg/exe_script_advanced_sensor. Note; doesn't currently cover other TCP TLS certs (e.g. FTPS), but likely could with some additional tweaks...
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Param( | |
[Parameter(Mandatory = $true)] | |
[string]$ComputerName | |
, | |
[Parameter()] | |
[int]$Port = 25 | |
, | |
[Parameter()] | |
[System.Security.Authentication.SslProtocols]$SslProtocols = [System.Security.Authentication.SslProtocols]::GetValues([System.Security.Authentication.SslProtocols]) | |
, | |
[Parameter()] | |
[int]$TimeoutMS = 10000 | |
, | |
[Parameter()] | |
[string]$ClientFqdn = [System.Net.Dns]::Resolve($null).HostName # used in our EHLO command | |
) | |
function ConvertTo-PrtgResult { | |
[CmdletBinding()] | |
Param ( | |
[Parameter(Mandatory = $true, ValueFromPipeline = $true)] | |
[PSCustomObject]$InputObject | |
, | |
[Parameter()] | |
[int]$Depth = 3 | |
, | |
[Parameter()] | |
[Hashtable[]]$ChannelSettings = @() | |
) | |
Process { | |
$result = [PSCustomObject]@{prtg=@{result=[System.Collections.ArrayList]::new()}} | |
$props = $InputObject | Get-Member -MemberType Properties | Select-Object -ExpandProperty 'Name' | |
foreach ($prop in $props) { | |
[Hashtable]$temp = @{channel = $prop; value = $InputObject.$prop} | |
foreach ($item in $ChannelSettings) { | |
if ($item.ChannelName -eq $temp.channel) { | |
$temp = $temp + $item | |
break; | |
} | |
} | |
$result.prtg.result.Add(([PSCustomObject]$temp)) | Out-Null | |
} | |
$result | ConvertTo-Json -Depth $Depth | |
} | |
} | |
function Receive-TcpServerResponse { | |
[CmdletBinding()] | |
Param ( | |
[Parameter(Mandatory = $true)] | |
[System.IO.StreamReader]$StreamReader | |
) | |
# useful notes on SMTP https://www.rfc-editor.org/rfc/rfc5321.html / http://www.tcpipguide.com/free/t_SMTPRepliesandReplyCodes-3.htm / FTP on https://www.w3.org/Protocols/rfc959/4_FileTransfer.html | |
$return = [PSCustomObject]@{PSTypeName='TcpResponse';Code=$null;Text=''} | |
$hasMoreData = $true | |
#Write-Verbose ': Awaiting Server Response' | |
while ($hasMoreData -and ($StreamReader.EndOfStream -ne $true)) { # had odd issues where EndOfStream was null, despite being a non-nullable bool :/ - hence `-ne $true` | |
$response = $StreamReader.ReadLine(); | |
if ($null -eq $response){break;} # I don't think we'd get this... but doing defensive coding | |
Write-Verbose "<-[$response]" | |
if ($response -match '^(?<Code>\d{3})(?<Cont>[-\s])(?<Text>.*)$') { | |
if ($null -ne $return.Code) {$return} | |
$return.Code = $Matches['Code'] | |
$return.Text = $Matches['Text'] | |
$hasMoreData = $Matches['Cont'] -eq '-' | |
} else { | |
$return.Text += "`r`n$response" # maybe we should trim the space from the start... couldn't find documentation (it just says where the line begins with numbers use neutral text like space(s) to show it's not a command)... :/ | |
} | |
#Write-Verbose "HAS MORE DATA [$hasMoreData]" | |
} | |
#Write-Verbose ": End of Server Response (end of stream = [$($StreamReader.EndOfStream)])" # There's always a delay when reading EndOfStream, and it often turns out to be null :S | |
if ($null -ne $return.Code) {$return} | |
} | |
function Send-TcpClientMessage { | |
[CmdletBinding()] | |
Param ( | |
[Parameter(Mandatory = $true)] | |
[System.IO.StreamWriter]$StreamWriter | |
, | |
[Parameter(Mandatory = $true)] | |
[AllowEmptyString()] | |
[string]$Message | |
) | |
Write-Verbose "->[$Message]" | |
$StreamWriter.WriteLine($Message) | |
} | |
function Get-TcpSessionCertificate { | |
[CmdletBinding()] | |
Param ( | |
[Parameter(Mandatory = $true)] | |
[string]$ComputerName | |
, | |
[Parameter(Mandatory = $true)] | |
#[System.Net.Security.SslStream]$Stream # we can't implicitly convert as LeaveInnerStreamOpen then defaults to False | |
[System.IO.Stream]$Stream | |
, | |
[Parameter()] | |
[System.Security.Authentication.SslProtocols]$SslProtocols = [System.Security.Authentication.SslProtocols]::GetValues([System.Security.Authentication.SslProtocols]) | |
, | |
[Parameter()] | |
[Switch]$IncludeSslSessionInfo | |
) | |
$sslStream = [System.Net.Security.SslStream]::new($Stream, $true) | |
try { | |
$sslStream.AuthenticateAsClient($ComputerName, $null, $SslProtocols, $false) # $null = client certs for client auth, $false = check for certificate revocation | |
$cert = [System.Security.Cryptography.X509Certificates.X509Certificate2]::new( $sslStream.RemoteCertificate ) # convert to x509cert2 to get dates as dates rather than strings | |
if ($IncludeSslSessionInfo) { | |
([PSCustomObject]@{ | |
Certificate = $cert | |
# note: these will be the highest negotiated versions supported by client & server; not all the server's supported versions (for that we'd have to test each option) | |
SslProtocol = $sslStream.SslProtocol | |
CipherAlgorithm = $sslStream.CipherAlgorithm | |
CipherStrength = $sslStream.CipherStrength | |
KeyExchangeAlgorithm = $sslStream.KeyExchangeAlgorithm | |
KeyExchangeStrength = $sslStream.KeyExchangeStrength | |
}) | |
} else { | |
$cert | |
} | |
} finally { | |
$sslStream.Dispose() | |
} | |
} | |
function Test-HostnameOnSanlist { | |
[CmdletBinding(DefaultParameterSetName = 'ByCertificate')] | |
Param ( | |
[Parameter(Mandatory = $true)] | |
[string]$ComputerName | |
, | |
[Parameter(Mandatory = $true, ParameterSetName = 'ByCertificate')] | |
[System.Security.Cryptography.X509Certificates.X509Certificate2]$Certificate | |
, | |
[Parameter(Mandatory = $true, ParameterSetName = 'BySanList')] | |
[string[]]$SANList | |
) | |
if ($PSCmdlet.ParameterSetName -eq 'ByCertificate') { | |
$SANList = $Certificate.DnsNameList.Unicode | |
} | |
($SANList | ForEach-Object {"^$([System.Text.RegularExpressions.Regex]::Escape($_) -replace '^\\\*', '[^.]+')`$"} | Where-Object {$ComputerName -match $_} | Select-Object -First 1).Count -eq 1 | |
} | |
function Test-ExplicitTlsCertificate { | |
[CmdletBinding()] | |
Param( | |
[Parameter(Mandatory = $true)] | |
[string]$ComputerName | |
, | |
[Parameter()] | |
[int]$Port = 25 | |
, | |
[Parameter()] | |
[System.Security.Authentication.SslProtocols]$SslProtocols = [System.Security.Authentication.SslProtocols]::GetValues([System.Security.Authentication.SslProtocols]) | |
, | |
[Parameter()] | |
[int]$TimeoutMS = 10000 | |
, | |
[Parameter()] | |
[string]$ClientFqdn = [System.Net.Dns]::Resolve($null).HostName # used in our EHLO command | |
) | |
$result = [PSCustomObject]@{ | |
TcpConnectionSucceeded = $false | |
TlsConnectionSucceeded = $false | |
FqdnValidForSAN = $false | |
DaysRemaining = 0 | |
ExceptionMessage = '' | |
} | |
try { | |
# ESTABLISH CONNECTION | |
$client = [System.Net.Sockets.TcpClient]::new($ComputerName, $Port) | |
$result.TcpConnectionSucceeded = $true | |
$clientStream = $client.GetStream() | |
$clientStream.ReadTimeout = $TimeoutMS | |
$clientStream.WriteTimeout = $TimeoutMS | |
$r = [System.IO.StreamReader]::new($clientStream) | |
$w = [System.IO.StreamWriter]::new($clientStream) | |
$w.AutoFlush = $true | |
# SERVER READY | |
$response = Receive-TcpServerResponse -StreamReader $r # we should only get one code... but cater incase... | |
Write-verbose "Tcp Connected: [$response]" | |
if ($response.Code -notcontains 220){ | |
throw [System.NotSupportedException]::new("Received an unexpected response from the server: [$($response.Code -join ';')]:[$($response.Text -join ';`r`n...')]") | |
} | |
# SEND "EHLO"... Note: I'll assume EHLO is supported rather than also coding for HELO as we need ESMTP in order for STARTTLS to be a valid command (note: some solutions only send EHLO if they get ESMTP in the 220 response; others revert to HELO if EHLO isn't supported; details here: https://cr.yp.to/smtp/ehlo.html / https://www.samlogic.net/articles/smtp-commands-reference.htm | |
Send-TcpClientMessage -StreamWriter $w -Message "EHLO $ClientFqdn" | |
if (!([bool](Receive-TcpServerResponse -StreamReader $r | Where-Object {$_.Code -eq 250} | Where-Object {$_.Text -eq 'STARTTLS'}))){ | |
throw [System.NotSupportedException]::new("Did not receive [STARTTLS] from server!") | |
} | |
# BEGIN TLS SESSION | |
Send-TcpClientMessage -StreamWriter $w -Message 'STARTTLS' | |
$response = Receive-TcpServerResponse -StreamReader $r | |
if ($response.Code -notcontains 220){ | |
throw [System.NotSupportedException]::new("Failed to establish TLS Session: [$($response.Code -join ';')]:[$($response.Text -join ';`r`n...')]") | |
} | |
$result.TlsConnectionSucceeded = $true | |
# FETCH CERTIFICATE | |
$cert = Get-TcpSessionCertificate -ComputerName $ComputerName -Stream $clientStream -SslProtocols $SslProtocols | |
$result.DaysRemaining = ($cert.NotAfter - (Get-Date)).Days | |
$result.FqdnValidForSAN = Test-HostnameOnSanlist -ComputerName $ComputerName -Certificate $cert | |
} catch { | |
$result.ExceptionMessage = $_.Exception.ToString() | |
} | |
finally | |
{ | |
foreach ($toDispose in @( | |
$sslStream, | |
$w, | |
$r, | |
$clientStream, | |
$client | |
)) { | |
try{if ($null -ne $toDispose){$toDispose.Dispose()}}catch{Write-Warning $_.Exception.ToString()} | |
} | |
} | |
$result | |
} | |
#Test-ExplicitTlsCertificate @PSBoundParameters | ConvertTo-PrtgResult # pass individual parameters rather than bound for now; otherwise default values may be out of sync / for now I'd rather default in both places to make the function easier to reuse in isolation. | |
Test-ExplicitTlsCertificate -ComputerName $ComputerName -Port $Port -SslProtocols $SslProtocols -TimeoutMS $TimeoutMS -ClientFqdn $ClientFqdn | Select-Object DaysRemaining | ConvertTo-PrtgResult -ChannelSettings @(@{ChannelName = 'DaysRemaining';LimitMinWarning=30;LimitMinError=7;Unit='Custom';CustomUnit='Days'}) | |
# Note: it seems PRTG can only handle integer channels - so I've stripped all channels other than `DaysRemaining` via the above `select-object` clause. The boolean channels may have been OK... I've not got the access to easily experiment though | |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment