Skip to content

Automated SSL/TLS Certificate Expiry Monitor with Python

Expired SSL/TLS certificates can interrupt web and API services. This guide builds a daily monitor that reads the server’s leaf certificate to calculate its expiry date, then makes a separate normal TLS connection to check certificate-chain and hostname validation.

The expiry inspection deliberately disables certificate validation only for the metadata-only probe so it can read an expired certificate. Never send credentials or application data through that probe. The separate verified handshake reports whether a client that uses Python’s default trust store would accept the endpoint.


Step 1: Create an Isolated Environment and Service Account

01

Install the Certificate Parser in a Virtual Environment

Setup

The standard library provides the TLS connection, while the cryptography package parses the returned DER certificate. Use Python 3.10 or newer and a dedicated system account for the scheduled service.

Terminal window
sudo useradd --system --user-group --no-create-home --shell /usr/sbin/nologin certmon
sudo install -d -o root -g root -m 0755 /opt/cert-monitor
sudo python3 -m venv /opt/cert-monitor/venv
sudo /opt/cert-monitor/venv/bin/python -m pip install "cryptography>=42"
❯ View Expected Console Output
Created service account certmon and installed cryptography in /opt/cert-monitor/venv.

Step 2: Inspect Expiry and TLS Trust Separately

02

Build the Asynchronous Certificate Inspector

Python Script

Open /opt/cert-monitor/cert_checker.py with sudoedit and save this script there. Replace the example host with your endpoints. Each connection has a handshake timeout and an overall timeout. DNS resolution can return IPv4 or IPv6 addresses through asyncio.open_connection.

#!/usr/bin/env python3
import asyncio
import ssl
from datetime import datetime, timezone
from cryptography import x509
TARGETS = [
("example.com", 443),
]
WARNING_DAYS = 14
CONNECT_TIMEOUT_SECONDS = 10
HANDSHAKE_TIMEOUT_SECONDS = 5
async def read_leaf_certificate(host, port, context):
reader, writer = await asyncio.open_connection(
host=host,
port=port,
ssl=context,
server_hostname=host,
ssl_handshake_timeout=HANDSHAKE_TIMEOUT_SECONDS,
)
try:
tls_socket = writer.get_extra_info("ssl_object")
if tls_socket is None:
raise RuntimeError("TLS connection did not expose an SSL object")
certificate_der = tls_socket.getpeercert(binary_form=True)
if not certificate_der:
raise RuntimeError("The endpoint did not present a certificate")
return certificate_der
finally:
writer.close()
try:
await writer.wait_closed()
except (OSError, ssl.SSLError):
pass
async def inspect_certificate(host, port):
metadata_context = ssl.SSLContext(ssl.PROTOCOL_TLS_CLIENT)
metadata_context.check_hostname = False
metadata_context.verify_mode = ssl.CERT_NONE
try:
certificate_der = await asyncio.wait_for(
read_leaf_certificate(host, port, metadata_context),
timeout=CONNECT_TIMEOUT_SECONDS,
)
certificate = x509.load_der_x509_certificate(certificate_der)
expires_at = certificate.not_valid_after_utc
except Exception as error:
return {
"host": host,
"port": port,
"status": "FAILED",
"error": str(error),
}
now = datetime.now(timezone.utc)
days_remaining = int((expires_at - now).total_seconds() // 86400)
if days_remaining < 0:
expiry_status = "EXPIRED"
elif days_remaining <= WARNING_DAYS:
expiry_status = "EXPIRING"
else:
expiry_status = "HEALTHY"
trusted_context = ssl.create_default_context()
try:
await asyncio.wait_for(
read_leaf_certificate(host, port, trusted_context),
timeout=CONNECT_TIMEOUT_SECONDS,
)
tls_validation = "VALID"
validation_error = None
except Exception as error:
tls_validation = "INVALID"
validation_error = str(error)
overall_status = (
"HEALTHY"
if expiry_status == "HEALTHY" and tls_validation == "VALID"
else "ALERT"
)
return {
"host": host,
"port": port,
"expires_on": expires_at.date().isoformat(),
"days_remaining": days_remaining,
"expiry_status": expiry_status,
"tls_validation": tls_validation,
"validation_error": validation_error,
"status": overall_status,
}
async def main():
results = await asyncio.gather(
*(inspect_certificate(host, port) for host, port in TARGETS)
)
for result in results:
print(
f"{result['host']}:{result['port']} "
f"status={result['status']} "
f"expiry={result.get('expiry_status', 'UNKNOWN')} "
f"days={result.get('days_remaining', 'N/A')} "
f"tls={result.get('tls_validation', 'UNKNOWN')}"
)
if __name__ == "__main__":
asyncio.run(main())
❯ View Expected Console Output
example.com:443 status=HEALTHY expiry=HEALTHY days=120 tls=VALID

Step 3: Send Discord Alerts for Expiry or Trust Problems

03

Wire the Monitor to a Discord Webhook

Alert Integration

Store the webhook URL in DISCORD_WEBHOOK_URL; do not put it in the script or source control. Add the imports and function below above main(), then replace the existing main() and execution guard with the second block so the alert function is called.

import json
import os
import urllib.request
from urllib.error import URLError
DISCORD_WEBHOOK_URL = os.environ.get("DISCORD_WEBHOOK_URL", "")
def send_alert(alert_items):
if not alert_items:
return
if not DISCORD_WEBHOOK_URL:
print("DISCORD_WEBHOOK_URL is unset; alert was not sent.")
return
lines = [
f"- {item['host']}:{item['port']} "
f"expiry={item.get('expiry_status', 'FAILED')} "
f"days={item.get('days_remaining', 'N/A')} "
f"TLS={item.get('tls_validation', 'UNKNOWN')}"
for item in alert_items
]
payload = json.dumps({
"content": "**SSL/TLS monitor alert**\n" + "\n".join(lines)
}).encode("utf-8")
request = urllib.request.Request(
DISCORD_WEBHOOK_URL,
data=payload,
headers={"Content-Type": "application/json"},
method="POST",
)
try:
with urllib.request.urlopen(request, timeout=10) as response:
response.read()
print(f"Sent Discord alert for {len(alert_items)} endpoint(s).")
except (OSError, URLError) as error:
print(f"Discord webhook request failed: {error}")
async def main():
results = await asyncio.gather(
*(inspect_certificate(host, port) for host, port in TARGETS)
)
alerts = []
for result in results:
print(
f"{result['host']}:{result['port']} "
f"status={result['status']} "
f"expiry={result.get('expiry_status', 'UNKNOWN')} "
f"days={result.get('days_remaining', 'N/A')} "
f"tls={result.get('tls_validation', 'UNKNOWN')}"
)
if result["status"] != "HEALTHY":
alerts.append(result)
send_alert(alerts)
if __name__ == "__main__":
asyncio.run(main())
❯ View Expected Console Output
Sent Discord alert for 1 endpoint(s).

Step 4: Schedule a Daily Systemd Timer

04

Configure a Restricted Daily Service

Scheduling

Create a root-owned environment file for the webhook secret. The service runs as the unprivileged certmon account and writes its output to the systemd journal.

Terminal window
sudo install -o root -g root -m 0600 /dev/null /etc/cert-monitor.env
sudoedit /etc/cert-monitor.env
# Add this line in the editor:
DISCORD_WEBHOOK_URL=https://discord.com/api/webhooks/replace-with-your-secret
Terminal window
sudo tee /etc/systemd/system/cert-monitor.service >/dev/null <<'EOF'
[Unit]
Description=HTTPS certificate expiry and trust monitor
Wants=network-online.target
After=network-online.target
[Service]
Type=oneshot
User=certmon
Group=certmon
EnvironmentFile=/etc/cert-monitor.env
ExecStart=/opt/cert-monitor/venv/bin/python /opt/cert-monitor/cert_checker.py
NoNewPrivileges=true
PrivateTmp=true
ProtectSystem=strict
ProtectHome=true
EOF
sudo tee /etc/systemd/system/cert-monitor.timer >/dev/null <<'EOF'
[Unit]
Description=Run the certificate monitor daily
[Timer]
OnCalendar=*-*-* 08:00:00
Persistent=true
RandomizedDelaySec=5m
[Install]
WantedBy=timers.target
EOF
sudo systemctl daemon-reload
sudo systemctl enable --now cert-monitor.timer
sudo systemctl start cert-monitor.service
sudo journalctl -u cert-monitor.service -n 20 --no-pager
sudo systemctl list-timers cert-monitor.timer
❯ View Expected Console Output
NEXT LEFT LAST PASSED UNIT
Mon 2026-10-05 08:00:00 CEST ...

Comments