HomeLab.net.ua

Твоє залізо. Твої правила. Твоя мова.

Python-скрипт перевірки TLS-сертифікатів з Telegram або email

Опубліковано Оновлено

Python-скрипт перевірки TLS-сертифікатів з Telegram або email

Перевірка TLS-сертифікатів Python – це вузький скрипт для контролю строку дії сертифікатів на списку хостів. Раз на день він підключається до endpoint, читає notAfter і шле alert, якщо сертифікат скоро завершиться або перевірка впала. Для 5-20 зовнішніх сервісів цього зазвичай достатньо.

Такий варіант підходить, коли потрібен саме контроль expiry, а не повний моніторинг доступності. Скрипт можна покласти на будь-який Linux-сервер або VPS. Межа теж очевидна: історії перевірок, UI і складної логіки сповіщень тут немає.

Робочий Python-скрипт з читанням notAfter

Мінімальний придатний варіант відкриває TLS-з’єднання з SNI, читає notAfter, рахує дні до завершення і збирає лише проблемні хости. SNI тут критичний: без нього на shared-хостингу часто повертається чужий сертифікат.

У цьому і сенс перевірки TLS-сертифікатів Python: список хостів лежить у JSON, поріг днів приходить з аргументу командного рядка, а канал сповіщення – зі змінних середовища. Код не доводиться правити при кожній зміні списку або способу доставки alert.

#!/usr/bin/env python3
import argparse
import json
import os
import smtplib
import socket
import ssl
import sys
import urllib.parse
import urllib.request
from datetime import datetime, timezone
from email.message import EmailMessage


def load_hosts(path):
    with open(path, "r", encoding="utf-8") as f:
        data = json.load(f)
    if not isinstance(data, list):
        raise ValueError("hosts file must contain a JSON array")
    return data


def get_cert_expiry_days(hostname, port, timeout):
    context = ssl.create_default_context()
    with socket.create_connection((hostname, port), timeout=timeout) as sock:
        with context.wrap_socket(sock, server_hostname=hostname) as ssock:
            cert = ssock.getpeercert()
    not_after = cert["notAfter"]
    expiry = datetime.strptime(not_after, "%b %d %H:%M:%S %Y %Z").replace(tzinfo=timezone.utc)
    now = datetime.now(timezone.utc)
    delta = expiry - now
    return delta.days, expiry.isoformat()


def send_telegram(message):
    token = os.environ.get("TELEGRAM_BOT_TOKEN")
    chat_id = os.environ.get("TELEGRAM_CHAT_ID")
    if not token or not chat_id:
        return False, "Telegram env vars are missing"

    url = f"https://api.telegram.org/bot{token}/sendMessage"
    data = urllib.parse.urlencode({
        "chat_id": chat_id,
        "text": message
    }).encode("utf-8")

    req = urllib.request.Request(url, data=data, method="POST")
    with urllib.request.urlopen(req, timeout=10) as resp:
        if resp.status != 200:
            return False, f"Telegram HTTP {resp.status}"
    return True, "Telegram sent"


def send_email(message, subject):
    host = os.environ.get("SMTP_HOST")
    port = int(os.environ.get("SMTP_PORT", "587"))
    user = os.environ.get("SMTP_USER")
    password = os.environ.get("SMTP_PASS")
    sender = os.environ.get("SMTP_FROM")
    recipient = os.environ.get("SMTP_TO")

    if not all([host, user, password, sender, recipient]):
        return False, "SMTP env vars are missing"

    msg = EmailMessage()
    msg["Subject"] = subject
    msg["From"] = sender
    msg["To"] = recipient
    msg.set_content(message)

    with smtplib.SMTP(host, port, timeout=10) as smtp:
        smtp.starttls()
        smtp.login(user, password)
        smtp.send_message(msg)

    return True, "Email sent"


def main():
    parser = argparse.ArgumentParser()
    parser.add_argument("--hosts-file", default="/opt/tls-check/hosts.json")
    parser.add_argument("--threshold-days", type=int, default=21)
    parser.add_argument("--timeout", type=int, default=5)
    parser.add_argument("--notify", choices=["telegram", "email", "both", "none"], default="telegram")
    args = parser.parse_args()

    try:
        hosts = load_hosts(args.hosts_file)
    except Exception as e:
        print(f"Failed to load hosts file: {e}", file=sys.stderr)
        sys.exit(2)

    problems = []

    for item in hosts:
        hostname = item["host"]
        port = int(item.get("port", 443))
        try:
            days_left, expiry = get_cert_expiry_days(hostname, port, args.timeout)
            if days_left <= args.threshold_days:
                problems.append(f"{hostname}:{port} - {days_left} days left - expires {expiry}")
        except Exception as e:
            problems.append(f"{hostname}:{port} - check failed - {e}")

    if not problems:
        print("OK: no certificates below threshold")
        sys.exit(0)

    body = "TLS certificate alerts:\n\n" + "\n".join(problems)
    print(body)

    if args.notify in ("telegram", "both"):
        ok, status = send_telegram(body)
        print(status)
    if args.notify in ("email", "both"):
        ok, status = send_email(body, "TLS certificate alert")
        print(status)

    sys.exit(1)


if __name__ == "__main__":
    main()

Скрипт завершується кодом 0, якщо проблем немає, і 1, якщо є хоча б один alert. Це спрощує інтеграцію з cron, systemd і зовнішнім моніторингом.

Як передати список хостів і поріг без редагування коду

Найпростіше винести список у JSON, а поріг днів передавати аргументом --threshold-days. Для кількох десятків записів цього вистачає. YAML або база даних тут не дають виграшу, якщо не потрібні теги, групи чи окремі правила на кожен хост.

Створіть каталог і файл зі списком перевірок:

sudo mkdir -p /opt/tls-check
sudo nano /opt/tls-check/hosts.json

Вміст /opt/tls-check/hosts.json:

[
  { "host": "example.com", "port": 443 },
  { "host": "grafana.example.com", "port": 443 },
  { "host": "mail.example.com", "port": 8443 }
]

Запуск із порогом 30 днів:

python3 /opt/tls-check/check_tls.py --hosts-file /opt/tls-check/hosts.json --threshold-days 30 --notify none

Окремі порти потрібні не для рідкісних випадків. Якщо HTTPS сидить за реверс-проксі на 8443 або іншому порту, запит має йти саме туди. Якщо схема побудована через HTTPS через реверс-проксі, перевіряти варто зовнішню точку входу, а не внутрішній контейнер без публічного сертифіката.

Alert у Telegram або email

Сповіщення варто надсилати лише коли сертифікат скоро завершиться або перевірка впала. Постійні повідомлення про успіх швидко стають шумом. Після цього критичний alert пропускають так само, як і все інше.

Telegram через Bot API

Для Telegram достатньо токена бота і chat_id. Канал зручний тим, що піднімається швидко і не залежить від локального SMTP.

export TELEGRAM_BOT_TOKEN="<bot-token>"
export TELEGRAM_CHAT_ID="<chat-id>"
python3 /opt/tls-check/check_tls.py --hosts-file /opt/tls-check/hosts.json --threshold-days 365 --notify telegram

Поріг 365 днів у тесті майже гарантовано форсує сповіщення. Якщо потрібна окрема схема для месенджера, підійде матеріал про Telegram-сповіщення в Uptime Kuma. Для цієї задачі власний скрипт простіший.

Email через SMTP

Email доречний там, де Telegram не підходить або alert треба відправляти в спільну скриньку. Критичний момент один: код використовує STARTTLS через smtplib.SMTP, тому параметри SMTP мають збігатися з режимом сервера.

export SMTP_HOST="smtp.example.com"
export SMTP_PORT="587"
export SMTP_USER="[email protected]"
export SMTP_PASS="<smtp-password>"
export SMTP_FROM="[email protected]"
export SMTP_TO="[email protected]"

python3 /opt/tls-check/check_tls.py --hosts-file /opt/tls-check/hosts.json --threshold-days 365 --notify email

Якщо сервер працює лише на implicit TLS через 465, код доведеться змінити на smtplib.SMTP_SSL. Саме тут такі скрипти часто сипляться після копіювання без перевірки.

cron чи systemd timer для щоденного запуску

cron підходить для одного скрипта без додаткової логіки. systemd timer зручніший, коли потрібні окремий юніт, журнал у journalctl і простий ручний запуск. Для домашнього сервера або невеликого VPS обидва варіанти нормальні.

Запуск через cron

Спочатку краще винести змінні середовища в окремий файл. Тримати токени прямо в crontab незручно і брудно.

sudo nano /opt/tls-check/tls-check.env

Приклад /opt/tls-check/tls-check.env:

TELEGRAM_BOT_TOKEN=<bot-token>
TELEGRAM_CHAT_ID=<chat-id>

Права на файл краще обмежити:

sudo chmod 600 /opt/tls-check/tls-check.env

Запис у crontab:

0 8 * * * . /opt/tls-check/tls-check.env; /usr/bin/python3 /opt/tls-check/check_tls.py --hosts-file /opt/tls-check/hosts.json --threshold-days 21 --notify telegram >> /var/log/tls-check.log 2>&1

Запуск через systemd timer

Коли вже є моніторинг домашнього сервера, systemd зазвичай зручніший. Видно історію запусків, коди завершення і текст помилок без окремого лог-файла.

Файл сервісу /etc/systemd/system/tls-check.service:

[Unit]
Description=TLS certificate check

[Service]
Type=oneshot
EnvironmentFile=/opt/tls-check/tls-check.env
ExecStart=/usr/bin/python3 /opt/tls-check/check_tls.py --hosts-file /opt/tls-check/hosts.json --threshold-days 21 --notify telegram

Файл таймера /etc/systemd/system/tls-check.timer:

[Unit]
Description=Run TLS certificate check daily

[Timer]
OnCalendar=daily
Persistent=true

[Install]
WantedBy=timers.target

Активація:

sudo systemctl daemon-reload
sudo systemctl enable --now tls-check.timer
sudo systemctl list-timers --all | grep tls-check

Як перевірити, що скрипт бачить дату сертифіката і шле повідомлення

Перевірка TLS-сертифікатів Python має тестуватися окремими кроками. Спершу треба перевірити TCP-з’єднання до хоста, потім розбір notAfter, а вже після цього доставку alert. Інакше незрозуміло, де саме поломка.

Тест без сповіщень

Спочатку запустіть скрипт без відправки повідомлень:

python3 /opt/tls-check/check_tls.py --hosts-file /opt/tls-check/hosts.json --threshold-days 21 --notify none
echo $?

Код 0 означає, що проблемних сертифікатів немає. Код 1 означає близьке завершення або помилку перевірки. Для цього сценарію це штатна поведінка.

Форсований alert

Щоб перевірити доставку, задайте завищений поріг:

python3 /opt/tls-check/check_tls.py --hosts-file /opt/tls-check/hosts.json --threshold-days 365 --notify telegram

Для email достатньо замінити канал на email або both. Такий тест потрібен завжди, бо TLS-перевірка може бути коректною, а SMTP або Bot API – ні.

Додаткова звірка через openssl

Якщо дата виводиться дивно або хост падає з помилкою, варто звірити результат напряму:

openssl s_client -connect example.com:443 -servername example.com < /dev/null 2>/dev/null | openssl x509 -noout -dates

Цей виклик швидко показує дві типові причини: неправильний SNI або помилковий порт. Якщо зовнішній доступ побудований через Cloudflare Tunnel на Debian і Ubuntu, перевіряти треба публічний hostname, який бачить клієнт.

Де зазвичай ламається перевірка

Більшість збоїв зводиться до трьох причин: не передали server_hostname, поставили занадто малий timeout або перевіряється не той endpoint. На одному-двох хостах це може не вилізти. На списку з десятка адрес помилки починають повторюватися системно.

Без SNI повертається чужий сертифікат

На shared IP один сервер часто обслуговує кілька доменів. Без server_hostname=hostname у wrap_socket() OpenSSL може віддати дефолтний сертифікат іншого сайту. Строк дії тоді читається коректно, але належить не тому домену.

Малий timeout дає випадкові падіння

Значення 2-3 секунди інколи вистачає в LAN. Для зовнішніх хостів цього часто мало. Якщо адреси сидять за CDN або тунелем, краще ставити 5-10 секунд, інакше скрипт почне плодити шумові timeout-и.

На нестандартному порту перевіряють не той endpoint

Таке часто трапляється з проксі та панелями керування. Зовнішній сертифікат може жити на 443, а внутрішній сервіс – на 8443 з іншим ланцюжком або самопідписаним сертифікатом. Перевіряти треба адресу, куди реально приходить клієнтський трафік.

До кількох десятків хостів цей підхід живе спокійно. Далі повільні вузли починають розтягувати загальний час проходу. У цей момент або додають паралельні перевірки, або переносять задачу в окремий моніторинг.

Коли скрипта достатньо, а коли потрібен Uptime Kuma

Перевірка TLS-сертифікатів Python підходить, коли потрібен один вузький контроль: строк дії сертифіката на кількох хостах і простий alert. Uptime Kuma кращий, коли потрібні веб-інтерфейс, історія, статус-сторінка, кілька каналів сповіщення і перевірки HTTP, TCP та DNS.

Сценарій Python-скрипт Uptime Kuma
5-20 хостів, контроль expiry Підходить Надлишково
Потрібен Telegram або email alert Підходить Підходить
Історія перевірок і UI Доведеться доробляти Є з коробки
Моніторинг доступності, а не лише сертифікатів Слабкий варіант Кращий вибір

Плюс скрипта в тому, що весь ланцюжок видно одразу: куди він підключається, що читає і чому шле alert. Але після появи mute-вікон, кількох каналів, історії інцидентів і додаткових перевірок самописний код швидко починає заважати. У цій точці простіше взяти Uptime Kuma.

Для вузької задачі перевірка TLS-сертифікатів Python закриває потребу без зайвої інфраструктури. Якщо треба стежити ще й за доступністю сервісів, реверс-проксі та домашніх веб-панелей, скрипт краще залишити як точкову перевірку, а решту віддати окремій системі.

Коментарі

Коментарів ще немає. Обговорення - у Telegram.