Check TLS Certificate Expiry with Python: Verification, Warnings, and Local Tests
Port 443 accepts connections, but the certificate has only two days left. Waiting for browsers to report an error can turn routine renewal into an outage. This tutorial builds a Python standard-library checker that validates TLS, reads certificate expiry, and reports JSON with exit codes for existing automation.
For beginner-to-intermediate readers, no pip packages are needed. We first reproduce valid, expiring, untrusted, and hostname-mismatch results using a local certificate, then check a real site.
1. Method: validate identity, then calculate remaining time
- Create a client context with
ssl.create_default_context(), keeping certificate-chain and hostname verification enabled. - Connect over TCP, then pass the expected identity as
server_hostname; a DNS hostname also enables SNI. - After a successful handshake, read
notAfterfrom the server’s leaf certificate and convert it to a UTC timestamp. - Return
expiringwhen remaining seconds are at most--warn-days × 86400; otherwise returnok.
This follows the official Python TLS client example and getpeercert() documentation. Use ssl.cert_time_to_seconds() for conversion so GMT in a certificate is not interpreted as local time.
If the connection fails before TLS, start with Python TCP port checks. A successful TCP connection does not establish that the certificate is valid.
2. Setup: get the scripts and local test tools
Use Python 3.9 or newer with a working ssl module. The local experiment also needs an OpenSSL command supporting -addext, plus curl for downloading. Examples were tested on Linux with Python 3.11.4 and the system Python 3.13; no global dependency installation is required.
- Create a demo directory and download both complete scripts. Run subsequent commands in this directory:
python3 --version
openssl version
mkdir tls-expiry-demo
cd tls-expiry-demo
curl -fS https://www.bobobk.com/downloads/tls-expiry/check_tls.py -o check_tls.py
curl -fS https://www.bobobk.com/downloads/tls-expiry/test_tls.py -o test_tls.py
python3 check_tls.py --help
check_tls.pycontains the complete implementation in the next section.test_tls.pyis the companion local self-test. Without curl, save both downloads in a browser. Checking an ordinary public HTTPS certificate only requirescheck_tls.py; the OpenSSL command is for local testing.
3. Complete implementation: one JSON line and clear exit codes
"""Check one TLS endpoint's verified leaf certificate. Python 3.9+."""
import argparse
from datetime import datetime, timezone
import json
import math
import socket
import ssl
import time
def check(host, port, timeout, warn_days, cafile=None):
report = {"host": host, "port": port, "status": "error"}
try:
context = ssl.create_default_context(cafile=cafile)
with socket.create_connection((host, port), timeout=timeout) as raw:
with context.wrap_socket(raw, server_hostname=host) as tls:
certificate = tls.getpeercert()
expires = ssl.cert_time_to_seconds(certificate["notAfter"])
remaining = expires - time.time()
report.update(
status="expiring" if remaining <= warn_days * 86400 else "ok",
expires_utc=datetime.fromtimestamp(expires, timezone.utc).isoformat(),
days_remaining=round(remaining / 86400, 3),
)
except ssl.SSLCertVerificationError as exc:
report.update(status="verify_error", error=exc.verify_message)
except (OSError, ValueError, KeyError, OverflowError) as exc:
report.update(error=str(exc))
return report
def main():
parser = argparse.ArgumentParser(description=__doc__, allow_abbrev=False)
parser.add_argument("host", help="DNS hostname or IP address, without a URL")
parser.add_argument("--port", type=int, default=443)
parser.add_argument("--timeout", type=float, default=3)
parser.add_argument("--warn-days", type=int, default=14)
parser.add_argument("--cafile", help="explicit PEM trust file, e.g. for a lab")
args = parser.parse_args()
if not args.host or any(c.isspace() for c in args.host) or "/" in args.host:
parser.error("host must be a hostname or IP address without a URL")
if not 1 <= args.port <= 65535:
parser.error("--port must be between 1 and 65535")
if not math.isfinite(args.timeout) or not 0 < args.timeout <= 60:
parser.error("--timeout must be finite and greater than 0, at most 60")
if not 0 <= args.warn_days <= 36500:
parser.error("--warn-days must be between 0 and 36500")
report = check(args.host, args.port, args.timeout, args.warn_days, args.cafile)
print(json.dumps(report, ensure_ascii=False))
return 0 if report["status"] == "ok" else 1
if __name__ == "__main__":
raise SystemExit(main())
days_remaining is rounded only for display; the warning comparison uses unrounded seconds. An expired certificate normally fails during the handshake and produces verify_error. The checker keeps verification enabled.
| Exit code | JSON status or behavior |
|---|---|
| 0 | ok: verification succeeded and remaining time exceeds the threshold. |
| 1 | expiring: valid but too close to expiry; verify_error: verification failed; error: another TLS, network, or file error. |
| 2 | Invalid arguments; a message goes to standard error and no JSON is emitted. |
socket.create_connection() resolves hostnames and can try the resulting addresses. --timeout applies to connection and handshake operations, not a whole-process deadline; DNS resolution and multiple address attempts can extend total runtime. This tool checks direct TLS services, without SMTP STARTTLS or HTTP page requests.
4. Local experiment: a two-day certificate triggers a three-day warning
- In terminal A, inside the demo directory, create a dedicated configuration and test certificate. Start a TLS server listening only on loopback:
umask 077
printf '[req]\ndistinguished_name = dn\n[dn]\n' > demo-openssl.cnf
openssl req -x509 -newkey rsa:2048 -nodes \
-config demo-openssl.cnf -days 2 -subj '/CN=localhost' \
-addext 'subjectAltName=DNS:localhost' \
-addext 'basicConstraints=critical,CA:TRUE' \
-addext 'keyUsage=critical,digitalSignature,keyEncipherment,keyCertSign' \
-addext 'subjectKeyIdentifier=hash' \
-addext 'authorityKeyIdentifier=keyid:always' \
-keyout demo-key.pem -out demo-cert.pem
openssl s_server -accept 127.0.0.1:18443 \
-cert demo-cert.pem -key demo-key.pem -www
The minimal configuration declares the request’s distinguished-name section; extensions are supplied explicitly. This avoids duplicate extensions from a system configuration combined with -addext. See the official OpenSSL req manual and s_server manual for the options. -nodes produces an unencrypted demo private key, so we use umask 077. These files are for this local experiment.
- Leave terminal A running and enter the same directory in terminal B:
python3 check_tls.py localhost --port 18443 --cafile demo-cert.pem --warn-days 0
if python3 check_tls.py localhost --port 18443 --cafile demo-cert.pem --warn-days 3; then
printf 'Certificate has enough time remaining\n'
else
tls_exit=$?
printf 'Check needs attention: exit=%s\n' "$tls_exit"
fi
The first command returns status: "ok". The second returns status: "expiring" and exit code 1 because roughly two days remain and the threshold is three days. JSON includes expires_utc and days_remaining; actual dates depend on when you run it.
- Verify trust and hostname failures separately:
if python3 check_tls.py localhost --port 18443; then
printf 'Unexpected trust result\n'
else
printf 'Expected trust failure: exit=%s\n' "$?"
fi
if python3 check_tls.py 127.0.0.1 --port 18443 --cafile demo-cert.pem; then
printf 'Unexpected hostname result\n'
else
printf 'Expected hostname failure: exit=%s\n' "$?"
fi
Both checks should return verify_error. The first does not trust the demo certificate. The second explicitly trusts it, but its SAN lists only DNS:localhost, without an IP identity for 127.0.0.1. --cafile selects trust for this invocation; it neither installs system certificates nor disables hostname checks. Use trust files whose source you have already verified.
Press Ctrl+C in terminal A when finished. If the port is occupied, choose 18444 and update terminal B’s --port too, without stopping another program. Regenerate the demo files if their two-day certificate has expired.
5. Validation: automatically test failure branches
Run the downloaded test_tls.py:
python3 test_tls.py
Expected output:
OK: valid, expiring, untrusted, hostname mismatch, refused, arguments, timeout
The self-test generates a certificate in a temporary directory and starts a local TLS server on a dynamically assigned 127.0.0.1 port. Subprocess calls verify JSON and exit codes. A bound but non-listening port exercises connection failure; invalid timeouts, ports, and warning periods exercise argument handling. A mocked timeout checks error handling without depending on public-network packet loss. After the thread ends, sockets close and its generated certificate and private key are removed automatically, leaving section 4’s files intact.
After downloading, the self-test makes no public-network requests. It exercises real local handshakes, trust rejection, and hostname rejection. The injected timeout tests handling rather than real network timing, and the test does not simulate an expired certificate.
6. Automation and troubleshooting
- Check a real domain you manage, passing a hostname without
https://. For example, check this site:
python3 check_tls.py www.bobobk.com --warn-days 14 --timeout 3
Remaining time depends on renewal, so do not assert a fixed number of days in tests. When warned, follow the issuer’s renewal procedure and confirm the public service presents the replacement certificate. CDN or load-balancer nodes may present different certificates; one connection observes only the selected endpoint.
- Preserve both JSON and nonzero exits in the caller, routing exit code 1 to an existing alerting process. Avoid hiding failures with
|| true. For scheduled execution, use absolute interpreter, script, and trust-file paths; see scheduled-job path and environment troubleshooting.
| Symptom | Concrete action |
|---|---|
expiring |
Renew, then recheck the public endpoint. Updating a disk file without loading it into the serving process leaves the warning in place. |
verify_error mentioning expiry |
Check the monitoring host’s clock and the server certificate, deploy the renewal, then rerun the same check. |
verify_error mentioning issuer/trust |
Check the intermediate chain served by the endpoint and the trusted CA files used by this Python installation. For a private CA, select a verified --cafile. |
verify_error mentioning hostname/IP |
Use a DNS name covered by the certificate SAN, or obtain a certificate with the required IP SAN. |
error mentioning timeout/refused |
Verify DNS resolution, target port, listening service, and the network path. |
error mentioning PEM/file paths |
Check the --cafile path, read permissions, and PEM contents. |
- After the certificate passes, check HTTPS status and actual page content using the curl deployment checks. A valid certificate does not prove the page loads correctly. This tool is not a complete revocation-status or chain-wide expiry audit.
7. Summary
Keeping TLS identity verification enabled and applying a remaining-time threshold to the server leaf certificate helps surface renewal windows, trust failures, and hostname errors in automation. Validate success and failure branches with a local two-day certificate before connecting the checker to existing monitoring. After renewal, recheck the real endpoint and continue with HTTP response validation.
- 原文作者:春江暮客
- 原文链接:https://www.bobobk.com/en/python-tls-certificate-expiry.html
- 版权声明:本作品采用 知识共享署名-非商业性使用-禁止演绎 4.0 国际许可协议 进行许可,非商业转载请注明出处(作者,原文链接),商业转载请联系作者获得授权。
相关文章
- Check Scheduled Output Freshness with Python: File Age, Empty Files, and Exit Codes
- Probe TCP Ports with Python: Distinguish Refusal, Timeout, and System Errors
- Check ZIP Backups with Python: CRC, Duplicate Members, and Missing Files
- Check Disk Space with Python Before Starting a Batch Job
- Find Broken Symlinks with Python: Separate Missing Targets, Permission Errors, and Loops