Skip to content

Instantly share code, notes, and snippets.

@JohnLBevan
Last active November 29, 2022 14:31
Show Gist options
  • Star 0 You must be signed in to star a gist
  • Fork 0 You must be signed in to fork a gist
  • Save JohnLBevan/c7974c2839e1486345d63ab6bd76523c to your computer and use it in GitHub Desktop.
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...
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