Сертификат истёк в пятницу вечером. Сайт интернет-магазина начал показывать «Небезопасное соединение» — браузеры заблокировали переход, а конверсия за выходные упала на 70%. В другом случае корпоративный Exchange перестал принимать входящую почту от внешних серверов: STARTTLS-рукопожатие обрывалось из-за просроченного сертификата на SMTP-порту, и письма клиентов уходили в никуда три дня, пока не нашли причину. Третья история — VPN-шлюз с SSL-сертификатом для WebVPN: сотрудники на удалёнке потеряли доступ в корпоративную сеть ровно в день дедлайна квартального отчёта. Всё это объединяет одно: сертификат можно было обновить заранее, если бы кто-то его проверял.
Ручная проверка через браузер — нажал на замочек, увидел дату — работает, пока у тебя пять сайтов и хорошая память. Когда доменов двадцать, а ещё есть внутренние серверы с LDAPS, RDP, почтовыми реле и VPN-шлюзами — ручной подход превращается в лотерею. PowerShell позволяет автоматизировать проверку срока сертификата на любом количестве узлов, запустить её по расписанию и получить алёрт до того, как браузер выдаст страшную красную страницу.
PowerShell работает поверх .NET, и именно .NET-классы выполняют всю криптографическую работу. Когда нужно получить SSL-сертификат удалённого сервера, используется класс System.Net.Sockets.TcpClient для установки TCP-соединения, затем поверх него создаётся System.Net.Security.SslStream — зашифрованный поток, который при инициализации выполняет TLS-рукопожатие.
После успешного рукопожатия у объекта SslStream появляется свойство RemoteCertificate типа System.Security.Cryptography.X509Certificates.X509Certificate2. Этот объект содержит всё: NotAfter (дата истечения), NotBefore (дата выдачи), Subject, Issuer, список SubjectAlternativeNames и отпечаток Thumbprint.
Альтернативный путь — Invoke-WebRequest с параметром -Certificate, но он неудобен: нельзя легко перехватить сертификат внутри запроса без кастомного обработчика. Метод через TcpClient + SslStream даёт прямой доступ к объекту сертификата и работает на любом TCP-порту, не только на 443.
SslStream проверяет цепочку доверия и отклоняет самоподписанные сертификаты. Для внутренней инфраструктуры нужно передавать кастомный RemoteCertificateValidationCallback, который возвращает $true всегда — только для чтения даты, не для доверия.
Начнём с минимального рабочего примера — он проверяет один домен и выводит количество дней до истечения сертификата.
# Домен для проверки и порт (443 — стандартный HTTPS)
$hostname = "example.com"
$port = 443
# Создаём TCP-соединение с таймаутом 5 секунд
$tcp = New-Object System.Net.Sockets.TcpClient
$tcp.ConnectAsync($hostname, $port).Wait(5000) | Out-Null
# Создаём SSL-поток поверх TCP-соединения
# Callback всегда возвращает $true — нам важна дата, а не доверие
$ssl = New-Object System.Net.Security.SslStream(
$tcp.GetStream(), $false,
{ $true } # RemoteCertificateValidationCallback
)
# Выполняем TLS-рукопожатие: сервер передаёт свой сертификат
$ssl.AuthenticateAsClient($hostname)
# Извлекаем сертификат из установленного соединения
$cert = New-Object System.Security.Cryptography.X509Certificates.X509Certificate2(
$ssl.RemoteCertificate
)
# Вычисляем количество дней до истечения
$daysLeft = ($cert.NotAfter - (Get-Date)).Days
# Выводим результат
Write-Host "Домен: $hostname"
Write-Host "Истекает: $($cert.NotAfter.ToString('dd.MM.yyyy'))"
Write-Host "Дней осталось: $daysLeft"
Write-Host "Выдан: $($cert.Issuer)"
# Закрываем соединения
$ssl.Close()
$tcp.Close()
Скрипт выполняется за долю секунды и возвращает точную дату истечения. Если сервер недоступен или сертификат уже просрочен (TLS-соединение всё равно устанавливается, просто NotAfter будет в прошлом), скрипт корректно обработает оба случая.
Для проверки десятков доменов читаем список из CSV-файла и используем фоновые задания (Start-Job) для параллельного выполнения. Это сокращает общее время проверки в разы.
Файл domains.csv имеет простой формат:
hostname,port,description
itfresh.ru,443,Основной сайт
mail.itfresh.ru,443,Веб-почта
crm.itfresh.ru,443,CRM-система
vpn.itfresh.ru,443,VPN-шлюз
ldap.corp.local,636,Контроллер домена LDAPS
Скрипт массовой проверки:
# Путь к файлу со списком доменов
$csvPath = "C:\Scripts\SSL\domains.csv"
$warningDays = 30 # жёлтая зона
$criticalDays = 10 # красная зона
# Функция проверки одного узла (запускается в отдельном job)
$checkScript = {
param($hostname, $port, $description)
$result = [PSCustomObject]@{
Description = $description
Hostname = $hostname
Port = $port
DaysLeft = $null
Expiry = $null
Issuer = $null
Status = "ERROR"
Error = ""
}
try {
$tcp = New-Object System.Net.Sockets.TcpClient
if (-not $tcp.ConnectAsync($hostname, $port).Wait(8000)) {
$result.Error = "Таймаут подключения"
return $result
}
$ssl = New-Object System.Net.Security.SslStream(
$tcp.GetStream(), $false, { $true }
)
$ssl.AuthenticateAsClient($hostname)
$cert = New-Object System.Security.Cryptography.X509Certificates.X509Certificate2(
$ssl.RemoteCertificate
)
$daysLeft = ($cert.NotAfter - (Get-Date)).Days
$result.DaysLeft = $daysLeft
$result.Expiry = $cert.NotAfter.ToString("dd.MM.yyyy")
$result.Issuer = $cert.Issuer -replace "CN=","" -replace ",.*",""
$result.Status = if ($daysLeft -lt 0) { "EXPIRED" }
elseif ($daysLeft -lt 10) { "CRITICAL" }
elseif ($daysLeft -lt 30) { "WARNING" }
else { "OK" }
$ssl.Close(); $tcp.Close()
}
catch { $result.Error = $_.Exception.Message }
return $result
}
# Читаем домены и запускаем параллельные задания
$domains = Import-Csv $csvPath
$jobs = foreach ($d in $domains) {
Start-Job -ScriptBlock $checkScript -ArgumentList $d.hostname, $d.port, $d.description
}
# Ждём завершения всех заданий (максимум 30 секунд)
$jobs | Wait-Job -Timeout 30 | Out-Null
# Собираем результаты и выводим таблицу
$results = $jobs | Receive-Job | Sort-Object DaysLeft
$jobs | Remove-Job -Force
$results | Format-Table Description, Hostname, DaysLeft, Expiry, Status -AutoSize
Вывод в консоль PowerShell с цветовым выделением делает таблицу мгновенно читаемой. Три статуса покрывают все ситуации:
| Статус | Условие | Цвет | Действие |
|---|---|---|---|
| OK | Более 30 дней | Зелёный | Ничего не делать |
| WARNING | 10–30 дней | Жёлтый | Запланировать обновление |
| CRITICAL / EXPIRED | Менее 10 дней или просрочен | Красный | Немедленное обновление |
foreach ($r in $results) {
$color = switch ($r.Status) {
"OK" { "Green" }
"WARNING" { "Yellow" }
"CRITICAL" { "Red" }
"EXPIRED" { "Magenta" }
default { "Gray" }
}
$line = "{0,-30} {1,-25} {2,8} дн. [{3}]" -f `
$r.Description, $r.Hostname, $r.DaysLeft, $r.Status
Write-Host $line -ForegroundColor $color
}
Когда скрипт находит сертификаты в состоянии WARNING или CRITICAL, он должен немедленно отправить письмо ответственному администратору. PowerShell содержит встроенный командлет Send-MailMessage.
# Параметры SMTP-сервера
$smtpParams = @{
SmtpServer = "smtp.itfresh.ru"
Port = 587
UseSsl = $true
Credential = Get-Credential # или хранить в защищённом файле
From = "monitoring@itfresh.ru"
To = "admin@itfresh.ru"
}
# Фильтруем только проблемные сертификаты
$alerts = $results | Where-Object { $_.Status -in "WARNING","CRITICAL","EXPIRED" }
if ($alerts) {
# Формируем тело письма в HTML
$tableRows = $alerts | ForEach-Object {
$bgColor = if ($_.Status -eq "WARNING") { "#fff3cd" } else { "#f8d7da" }
"
$($_.Description)
$($_.Hostname)
$($_.DaysLeft)
$($_.Expiry)
$($_.Status)
"
}
$body = @"
<h2>SSL-сертификаты требуют внимания</h2>
<table border='1' cellpadding='6' cellspacing='0' style='border-collapse:collapse'>
<tr><th>Описание</th><th>Домен</th><th>Дней</th><th>Истекает</th><th>Статус</th></tr>
$($tableRows -join '')
</table>
<p>Сформировано: $(Get-Date -Format 'dd.MM.yyyy HH:mm')</p>
"@
Send-MailMessage @smtpParams `
-Subject "ALERT: Истекают SSL-сертификаты ($($alerts.Count) шт.)" `
-Body $body -BodyAsHtml
}
Export-Clixml для сохранения зашифрованных учётных данных: Get-Credential | Export-Clixml "C:\Scripts\SSL\creds.xml", а при чтении: $cred = Import-Clixml "C:\Scripts\SSL\creds.xml". Шифрование привязано к учётной записи Windows и текущей машине.
Скрипт должен запускаться автоматически — каждый день в 8:00, до начала рабочего дня. Самый надёжный способ — создать задачу через PowerShell без ручного взаимодействия с GUI Планировщика.
# Параметры задачи
$taskName = "SSL Certificate Monitor"
$scriptPath = "C:\Scripts\SSL\Check-SSLCerts.ps1"
$logPath = "C:\Scripts\SSL\Logs\ssl-check.log"
# Действие: запуск PowerShell со скриптом, вывод в лог
$action = New-ScheduledTaskAction `
-Execute "powershell.exe" `
-Argument "-NonInteractive -ExecutionPolicy Bypass -File `"$scriptPath`" >> `"$logPath`" 2>&1"
# Триггер: каждый день в 08:00
$trigger = New-ScheduledTaskTrigger -Daily -At "08:00"
# Настройки: запускать под системной учёткой, даже если никто не залогинен
$settings = New-ScheduledTaskSettingsSet `
-ExecutionTimeLimit (New-TimeSpan -Minutes 10) `
-RestartCount 2 `
-RestartInterval (New-TimeSpan -Minutes 5)
$principal = New-ScheduledTaskPrincipal `
-UserId "SYSTEM" -RunLevel Highest
# Регистрируем задачу
Register-ScheduledTask `
-TaskName $taskName `
-Action $action `
-Trigger $trigger `
-Settings $settings `
-Principal $principal `
-Description "Ежедневная проверка SSL-сертификатов" `
-Force
После регистрации задача появится в Планировщике в разделе корневого каталога. Проверить её можно командой Get-ScheduledTask -TaskName "SSL Certificate Monitor", запустить вручную — Start-ScheduledTask -TaskName "SSL Certificate Monitor".
Большинство статей по мониторингу SSL ограничиваются проверкой порта 443. Но в корпоративной инфраструктуре сертификаты живут везде.
LDAPS (порт 636) — контроллеры домена Active Directory. Просроченный сертификат ломает аутентификацию LDAPS-клиентов и может вывести из строя всё, что к нему обращается.
RDP (порт 3389) — серверы с пользовательскими SSL-сертификатами для Remote Desktop. Клиенты получат предупреждение безопасности и могут отказаться подключаться.
SMTP TLS (порт 587 или 465) — почтовые реле с принудительным STARTTLS. Внешние серверы отклонят соединение.
Хранилище Windows — сертификаты, установленные локально, можно проверить без сетевого соединения:
# Проверка сертификатов в локальном хранилище компьютера
$store = New-Object System.Security.Cryptography.X509Certificates.X509Store(
"My", "LocalMachine"
)
$store.Open("ReadOnly")
$expiringSoon = $store.Certificates | Where-Object {
$_.NotAfter -lt (Get-Date).AddDays(30) -and $_.NotAfter -gt (Get-Date)
}
$expiringSoon | Select-Object Subject, NotAfter,
@{N="DaysLeft"; E={($_.NotAfter - (Get-Date)).Days}},
Thumbprint | Format-Table -AutoSize
$store.Close()
Наш базовый скрипт с TcpClient + SslStream работает для всех TCP-сервисов — достаточно указать нужный порт. Единственное отличие для LDAPS: при вызове AuthenticateAsClient нужно передать имя хоста точно как в сертификате (обычно это FQDN контроллера домена).
Красивый HTML-отчёт удобно открывать в браузере, прикладывать к задаче в Jira или отправлять руководству. PowerShell строит его из тех же данных $results.
$reportPath = "C:\Scripts\SSL\Reports\ssl-report-$(Get-Date -Format 'yyyyMMdd').html"
$rows = $results | ForEach-Object {
$bg = switch ($_.Status) {
"OK" { "#d4edda" }
"WARNING" { "#fff3cd" }
"CRITICAL" { "#f8d7da" }
"EXPIRED" { "#f5c6cb" }
default { "#e2e3e5" }
}
"<tr style='background:$bg'>
<td>$($_.Description)</td>
<td>$($_.Hostname):$($_.Port)</td>
<td>$($_.Expiry)</td>
<td style='text-align:center;font-weight:bold'>$($_.DaysLeft)</td>
<td>$($_.Issuer)</td>
<td><b>$($_.Status)</b></td>
</tr>"
}
$html = @"
<!DOCTYPE html><html lang='ru'><head>
<meta charset='UTF-8'>
<title>SSL Report $(Get-Date -Format 'dd.MM.yyyy')</title>
<style>body{font-family:Arial;padding:20px} table{border-collapse:collapse;width:100%}
th{background:#1a0a00;color:#fff;padding:10px} td{padding:8px;border-bottom:1px solid #ddd}</style>
</head><body>
<h1>Отчёт по SSL-сертификатам</h1>
<p>Сформирован: $(Get-Date -Format 'dd.MM.yyyy HH:mm')</p>
<table><tr>
<th>Описание</th><th>Сервер</th><th>Дата истечения</th>
<th>Дней</th><th>Издатель</th><th>Статус</th>
</tr>$($rows -join '')</table></body></html>
"@
$html | Out-File $reportPath -Encoding UTF8
Write-Host "Отчёт сохранён: $reportPath" -ForegroundColor Cyan
Скрипт легко адаптируется для работы с системами мониторинга через стандартные exit-коды: 0 — OK, 1 — WARNING, 2 — CRITICAL. Nagios и Zabbix читают их автоматически.
# Nagios/Zabbix-совместимая обёртка для проверки одного домена
param(
[string]$Hostname,
[int]$Port = 443,
[int]$WarnDays = 30,
[int]$CritDays = 10
)
try {
$tcp = New-Object System.Net.Sockets.TcpClient
if (-not $tcp.ConnectAsync($Hostname, $Port).Wait(8000)) {
Write-Output "UNKNOWN: Таймаут подключения к ${Hostname}:${Port}"
exit 3
}
$ssl = New-Object System.Net.Security.SslStream($tcp.GetStream(), $false, { $true })
$ssl.AuthenticateAsClient($Hostname)
$cert = New-Object System.Security.Cryptography.X509Certificates.X509Certificate2(
$ssl.RemoteCertificate
)
$days = ($cert.NotAfter - (Get-Date)).Days
$ssl.Close(); $tcp.Close()
if ($days -lt 0) {
Write-Output "CRITICAL: Сертификат просрочен $($cert.NotAfter.ToString('dd.MM.yyyy')) | days=$days"
exit 2
} elseif ($days -lt $CritDays) {
Write-Output "CRITICAL: Осталось $days дней до $($cert.NotAfter.ToString('dd.MM.yyyy')) | days=$days"
exit 2
} elseif ($days -lt $WarnDays) {
Write-Output "WARNING: Осталось $days дней до $($cert.NotAfter.ToString('dd.MM.yyyy')) | days=$days"
exit 1
} else {
Write-Output "OK: Сертификат действителен $days дней до $($cert.NotAfter.ToString('dd.MM.yyyy')) | days=$days"
exit 0
}
}
catch {
Write-Output "UNKNOWN: $($_.Exception.Message)"
exit 3
}
Для Zabbix настройте внешнюю проверку (External Check) или используйте UserParameter в Zabbix Agent: UserParameter=ssl.days[*],powershell -File C:\Scripts\SSL\Check-SSL-Single.ps1 -Hostname $1 -Port $2. Zabbix передаст имя хоста и порт как аргументы и получит числовое значение дней для построения графика и триггеров.
Финальный скрипт объединяет все функции: загрузку CSV, параллельную проверку, цветовой вывод, email-алёрт и HTML-отчёт. Достаточно один раз настроить параметры в начале файла и добавить задачу в Task Scheduler.
#Requires -Version 5.1
# Check-SSLCerts.ps1 — мониторинг SSL-сертификатов v2.0
# АйТи Фреш | itfresh.ru
param(
[string]$CsvPath = "C:\Scripts\SSL\domains.csv",
[string]$ReportDir = "C:\Scripts\SSL\Reports",
[string]$SmtpServer = "smtp.itfresh.ru",
[int] $SmtpPort = 587,
[string]$MailFrom = "monitoring@itfresh.ru",
[string]$MailTo = "admin@itfresh.ru",
[string]$CredFile = "C:\Scripts\SSL\creds.xml",
[int] $WarnDays = 30,
[int] $CritDays = 10
)
$checkBlock = {
param($h, $p, $d)
$r = [PSCustomObject]@{
Desc="$d"; Host="$h"; Port="$p"
Days=$null; Expiry=$null; Issuer=$null; Status="ERROR"; Err=""
}
try {
$tcp = New-Object System.Net.Sockets.TcpClient
if (-not $tcp.ConnectAsync($h,$p).Wait(8000)) { $r.Err="Timeout"; return $r }
$ssl = New-Object System.Net.Security.SslStream($tcp.GetStream(),$false,{ $true })
$ssl.AuthenticateAsClient($h)
$cert = New-Object System.Security.Cryptography.X509Certificates.X509Certificate2($ssl.RemoteCertificate)
$r.Days = ($cert.NotAfter-(Get-Date)).Days
$r.Expiry = $cert.NotAfter.ToString("dd.MM.yyyy")
$r.Issuer = ($cert.Issuer -replace "CN=","" -replace ",.*","").Trim()
$r.Status = if ($r.Days -lt 0) {"EXPIRED"} elseif ($r.Days -lt $CritDays) {"CRITICAL"} `
elseif ($r.Days -lt $WarnDays) {"WARNING"} else {"OK"}
$ssl.Close(); $tcp.Close()
} catch { $r.Err = $_.Exception.Message }
$r
}
$domains = Import-Csv $CsvPath
$jobs = $domains | ForEach-Object {
Start-Job $checkBlock -Args $_.hostname, $_.port, $_.description
}
$jobs | Wait-Job -Timeout 30 | Out-Null
$results = $jobs | Receive-Job | Sort-Object Days
$jobs | Remove-Job -Force
$colors = @{ OK="Green"; WARNING="Yellow"; CRITICAL="Red"; EXPIRED="Magenta"; ERROR="Gray" }
foreach ($r in $results) {
$line = "{0,-28} {1,-22} {2,6} дн [{3}]" -f $r.Desc, $r.Host, $r.Days, $r.Status
Write-Host $line -ForegroundColor $colors[$r.Status]
}
$alerts = $results | Where-Object { $_.Status -in "WARNING","CRITICAL","EXPIRED" }
if ($alerts -and (Test-Path $CredFile)) {
$cred = Import-Clixml $CredFile
$rows = $alerts | ForEach-Object {
"<tr><td>$($_.Desc)</td><td>$($_.Host)</td><td>$($_.Days)</td><td>$($_.Expiry)</td><td>$($_.Status)</td></tr>"
}
$body = "<h2>SSL Alert</h2><table border=1 cellpadding=6 style='border-collapse:collapse'>" +
"<tr><th>Описание</th><th>Домен</th><th>Дней</th><th>Истекает</th><th>Статус</th></tr>" +
($rows -join '') + "</table>"
Send-MailMessage -SmtpServer $SmtpServer -Port $SmtpPort -UseSsl -Credential $cred `
-From $MailFrom -To $MailTo `
-Subject "SSL ALERT: $($alerts.Count) сертификатов требуют внимания" `
-Body $body -BodyAsHtml
}
$null = New-Item $ReportDir -ItemType Directory -Force
$rFile = Join-Path $ReportDir "ssl-$(Get-Date -Format yyyyMMdd).html"
$tRows = $results | ForEach-Object {
$bg = @{OK="#d4edda";WARNING="#fff3cd";CRITICAL="#f8d7da";EXPIRED="#f5c6cb";ERROR="#e2e3e5"}[$_.Status]
"<tr style='background:$bg'><td>$($_.Desc)</td><td>$($_.Host):$($_.Port)</td><td>$($_.Expiry)</td><td>$($_.Days)</td><td>$($_.Status)</td></tr>"
}
"<!DOCTYPE html><html><head><meta charset='UTF-8'><title>SSL Report</title></head><body>" +
"<h1>SSL Report $(Get-Date -Format 'dd.MM.yyyy')</h1>" +
"<table border=1 cellpadding=6 style='border-collapse:collapse'>$($tRows -join '')</table></body></html>" |
Out-File $rFile -Encoding UTF8
Write-Host "`nОтчёт сохранён: $rFile" -ForegroundColor Cyan
Просроченный сертификат — одна из тех аварий, которых легче всего избежать. В отличие от отказа железа или DDoS, дата истечения известна заранее — за год. Скрипт PowerShell превращает эту информацию из «кто-то помнит» в «система предупредила за 30 дней». Добавьте файл domains.csv со всеми узлами, зарегистрируйте задачу в Task Scheduler — и забудьте о внезапных простоях из-за SSL.
Не хватает времени или своих специалистов — мы настроим, оптимизируем и возьмём вашу IT-инфраструктуру на постоянное сопровождение. Работаем с юридическими лицами в Москве и регионах. Собственный дата-центр, команда из 8 серверов Dell Xeon Platinum 8280 на базе МТС.