In world where everything is connected and exposed to internet, interacting with self-signed certificates can be unique experience.
I'll not explain here how certificated does work but focus on task which is work with such (self-signed - but not limited to those).
How to 'get' remote certificate?
Display certificates
openssl s_client -connect example.com:443 -showcerts
-showcerts - return complete certificate chain
Server certificate
echo | openssl s_client -connect example.com:443 2>/dev/null | sed -ne '/-BEGIN CERTIFICATE-/,/-END CERTIFICATE-/p' > server.crt
server.crt will look like:
-----BEGIN CERTIFICATE-----
[Server Certificate Only]
-----END CERTIFICATE-----
Certificate chain
echo | openssl s_client -showcerts -servername example.com -connect example.com:443 2>/dev/null | sed -ne '/-BEGIN CERTIFICATE-/,/-END CERTIFICATE-/p' > chain.crt
-showcerts - return complete certificate chain
-servername - Enable SNI
chain.crt will look like:
-----BEGIN CERTIFICATE-----
[Server Certificate]
-----END CERTIFICATE-----
-----BEGIN CERTIFICATE-----
[Intermediate Certificate 1]
-----END CERTIFICATE-----
-----BEGIN CERTIFICATE-----
[Intermediate Certificate 2]
-----END CERTIFICATE-----
In all cases when remote certificate expire - server or chain need to be updated.
Certificate expiry
Speaking on expiry use this to see then certificate will expire:
echo | openssl s_client -showcerts -servername example.com -connect example.com:443 2>/dev/null | openssl x509 -noout -enddate
This can be used in automation which when given criteria appear (eg. expiry in next 90/60/30 day) will sent notification.
Automation
What about automation when there is more certificates manually checking them can be tedious and error prone ...
First thing is to have certificates locally to script. I assume all certificates used in your deployment are stored in shared space available to all.
Bash
#!/bin/bash
# Directory containing certificates (change this to your directory)
CERT_DIR="/path/to/certificates"
# Check if directory exists
if [ ! -d "$CERT_DIR" ]; then
echo "Error: Directory $CERT_DIR does not exist"
exit 1
fi
echo "=== Certificate Expiry Checker ==="
echo "Scanning directory: $CERT_DIR"
echo ""
# Counter for processed files
count=0
# Loop through each file in the directory
for cert_file in "$CERT_DIR"/*; do
# Skip if not a regular file
[ -f "$cert_file" ] || continue
# Check if file contains a certificate (has BEGIN CERTIFICATE)
if grep -q "BEGIN CERTIFICATE" "$cert_file" 2>/dev/null; then
echo "File: $(basename "$cert_file")"
# Extract and display expiry date
expiry_date=$(openssl x509 -enddate -noout -in "$cert_file" 2>/dev/null | cut -d= -f2-)
if [ -n "$expiry_date" ]; then
echo " Expires: $expiry_date"
# Convert to epoch for comparison (macOS and Linux compatible)
if [[ "$OSTYPE" == "darwin"* ]]; then
# macOS
expiry_epoch=$(date -j -f "%b %d %H:%M:%S %Y %Z" "$expiry_date" +%s 2>/dev/null)
else
# Linux
expiry_epoch=$(date -d "$expiry_date" +%s 2>/dev/null)
fi
current_epoch=$(date +%s)
# Calculate days until expiry
if [ -n "$expiry_epoch" ]; then
days_left=$(( ($expiry_epoch - $current_epoch) / 86400 ))
if [ $days_left -lt 0 ]; then
echo "EXPIRED $(( -days_left )) days ago"
elif [ $days_left -lt 30 ]; then
echo " Expires in $days_left days (soon!)"
else
echo " Valid for $days_left days"
fi
fi
else
echo "Could not read certificate"
fi
echo ""
((count++))
fi
done
if [ $count -eq 0 ]; then
echo "No certificate files found in $CERT_DIR"
else
echo "=== Summary: $count certificate(s) processed ==="
fi
Example output:
=== Certificate Expiry Checker ===
Scanning directory: certs
File: example_chain.crt
Expires: Oct 19 23:59:59 2026 GMT
Valid for 224 days
File: example_server.crt
Expires: Oct 19 23:59:59 2026 GMT
Valid for 224 days
=== Summary: 2 certificate(s) processed ===
Already here we can think of using bash to get more logic, sorting, etc.
Ansible
File structure:
certs/ # <- directory with certificates in scope
check_local_cert_expiry.yml # <- below playbook
---
- name: Check Local Certificate Expiry
hosts: localhost
connection: local
gather_facts: no
vars:
certs_dir: "{{ playbook_dir }}/certs"
# Thresholds in days
expiry_thresholds: [90, 60, 30]
tasks:
- name: Initialize results dictionary
ansible.builtin.set_fact:
expiry_results: {}
all_certs_by_threshold: {}
- name: Find certificate files in certs directory
ansible.builtin.find:
paths: "{{ certs_dir }}"
patterns: "*.pem,*.crt"
file_type: file
register: cert_files
delegate_to: localhost
run_once: true
- name: Read certificate subject and expiry
ansible.builtin.shell:
cmd: |
openssl x509 -in "{{ item.path }}" -noout -subject -enddate
register: cert_details
changed_when: false
failed_when: false
delegate_to: localhost
run_once: true
loop: "{{ cert_files.files }}"
loop_control:
label: "{{ item.path | basename }}"
- name: Process certificate expiry dates
ansible.builtin.set_fact:
processed_certs: >-
{{
processed_certs | default([]) +
[{
'keystore': certs_dir,
'keytool_path': 'openssl',
'certs': [
{
'alias': item.item.path | basename,
'subject': (item.stdout | regex_findall('^subject=(.+)$', multiline=True) | first | default('UNKNOWN')),
'expiry': (item.stdout | regex_findall('^notAfter=(.+)$', multiline=True) | first | default(''))
}
]
}]
}}
loop: "{{ cert_details.results }}"
loop_control:
label: "{{ item.item.path | basename }}"
run_once: true
- name: Check expiry status for each certificate
ansible.builtin.shell:
cmd: |
EXPIRY_DATE="{{ item.1.expiry }}"
CURRENT_DATE=$(date +%s)
EXPIRY_EPOCH=$(date -d "$EXPIRY_DATE" +%s 2>/dev/null || date -d "$(echo $EXPIRY_DATE | sed 's/\([A-Za-z]*\) \([0-9]*\) \([0-9:]*\) \([A-Z]*\) \([0-9]*\)/\5-\1-\2 \3/')" +%s 2>/dev/null)
if [ $? -ne 0 ]; then
echo "PARSE_ERROR"
exit 0
fi
DAYS_REMAINING=$(( ($EXPIRY_EPOCH - $CURRENT_DATE) / 86400 ))
if [ $DAYS_REMAINING -lt 0 ]; then
echo "EXPIRED:$DAYS_REMAINING"
elif [ $DAYS_REMAINING -le 30 ]; then
echo "CRITICAL:$DAYS_REMAINING"
elif [ $DAYS_REMAINING -le 60 ]; then
echo "WARNING:$DAYS_REMAINING"
elif [ $DAYS_REMAINING -le 90 ]; then
echo "NOTICE:$DAYS_REMAINING"
else
echo "OK:$DAYS_REMAINING"
fi
register: expiry_status
changed_when: false
failed_when: false
loop: "{{ processed_certs | subelements('certs') }}"
loop_control:
label: "{{ item.0.keystore }}: {{ item.1.alias }}"
when: processed_certs is defined and processed_certs | length > 0
- name: Build certificate expiry report
ansible.builtin.set_fact:
cert_expiry_report: >-
{{
cert_expiry_report | default([]) +
[{
'host': inventory_hostname,
'keystore': item.item.0.keystore,
'keytool_path': item.item.0.keytool_path,
'alias': item.item.1.alias,
'subject': item.item.1.subject,
'expiry': item.item.1.expiry,
'status': item.stdout.split(':')[0] if item.rc == 0 and ':' in item.stdout else 'UNKNOWN',
'days_remaining': item.stdout.split(':')[1] | int if item.rc == 0 and ':' in item.stdout else 0
}]
}}
loop: "{{ expiry_status.results }}"
loop_control:
label: "{{ item.item.0.keystore }}: {{ item.item.1.alias }}"
when: expiry_status is defined and expiry_status.results is defined
- name: Aggregate results from all hosts
ansible.builtin.set_fact:
all_expiry_results: "{{ ansible_play_hosts | map('extract', hostvars, 'cert_expiry_report') | select() | list | flatten }}"
run_once: true
- name: Categorize certificates by expiry status
ansible.builtin.set_fact:
expired_certs: "{{ all_expiry_results | selectattr('status', 'equalto', 'EXPIRED') | list }}"
critical_certs: "{{ all_expiry_results | selectattr('status', 'equalto', 'CRITICAL') | list }}"
warning_certs: "{{ all_expiry_results | selectattr('status', 'equalto', 'WARNING') | list }}"
notice_certs: "{{ all_expiry_results | selectattr('status', 'equalto', 'NOTICE') | list }}"
run_once: true
- name: Display certificate expiry report header
ansible.builtin.debug:
msg:
- ""
- "================================================================"
- " CERTIFICATE EXPIRY STATUS REPORT"
- "================================================================"
- ""
- "Report Date: {{ lookup('pipe', 'date +\"%%Y-%%m-%%d %%H:%%M:%%S\"') }}"
- "Total Hosts: {{ ansible_play_hosts | length }}"
- "Total Certificates Checked: {{ all_expiry_results | length }}"
- ""
- "================================================================"
run_once: true
- name: Display EXPIRED certificates
ansible.builtin.debug:
msg: |
EXPIRED CERTIFICATES ({{ expired_certs | length }})
================================================================
{% for cert in expired_certs %}
Host: {{ cert.host }}
Keystore: {{ cert.keystore }}
Alias: {{ cert.alias }}
Subject: {{ cert.subject }}
Expiry Date: {{ cert.expiry }}
Status: EXPIRED {{ cert.days_remaining }} days ago
----------------------------------------------------------------
{% endfor %}
run_once: true
when: expired_certs | length > 0
- name: Display CRITICAL certificates (≤30 days)
ansible.builtin.debug:
msg: |
CRITICAL - Expiring in 30 days or less ({{ critical_certs | length }})
================================================================
{% for cert in critical_certs | sort(attribute='days_remaining') %}
Host: {{ cert.host }}
Keystore: {{ cert.keystore }}
Alias: {{ cert.alias }}
Subject: {{ cert.subject }}
Expiry Date: {{ cert.expiry }}
Days Remaining: {{ cert.days_remaining }}
----------------------------------------------------------------
{% endfor %}
run_once: true
when: critical_certs | length > 0
- name: Display WARNING certificates (31-60 days)
ansible.builtin.debug:
msg: |
WARNING - Expiring in 31-60 days ({{ warning_certs | length }})
================================================================
{% for cert in warning_certs | sort(attribute='days_remaining') %}
Host: {{ cert.host }}
Keystore: {{ cert.keystore }}
Alias: {{ cert.alias }}
Subject: {{ cert.subject }}
Expiry Date: {{ cert.expiry }}
Days Remaining: {{ cert.days_remaining }}
----------------------------------------------------------------
{% endfor %}
run_once: true
when: warning_certs | length > 0
- name: Display NOTICE certificates (61-90 days)
ansible.builtin.debug:
msg: |
NOTICE - Expiring in 61-90 days ({{ notice_certs | length }})
================================================================
{% for cert in notice_certs | sort(attribute='days_remaining') %}
Host: {{ cert.host }}
Keystore: {{ cert.keystore }}
Alias: {{ cert.alias }}
Subject: {{ cert.subject }}
Expiry Date: {{ cert.expiry }}
Days Remaining: {{ cert.days_remaining }}
----------------------------------------------------------------
{% endfor %}
run_once: true
when: notice_certs | length > 0
- name: Display summary
ansible.builtin.debug:
msg: |
================================================================
SUMMARY
================================================================
Expired: {{ expired_certs | length }}
Critical (≤30d): {{ critical_certs | length }}
Warning (31-60d): {{ warning_certs | length }}
Notice (61-90d): {{ notice_certs | length }}
OK (>90d): {{ (all_expiry_results | selectattr('status', 'equalto', 'OK') | list | length) }}
================================================================
{% if expired_certs | length > 0 or critical_certs | length > 0 %}
ACTION REQUIRED: Certificates need immediate attention!
{% elif warning_certs | length > 0 %}
ATTENTION: Certificates will expire soon.
{% elif notice_certs | length > 0 %}
INFO: Some certificates will expire within 90 days.
{% else %}
All certificates are valid for more than 90 days.
{% endif %}
================================================================
- name: Fail if expired or critical certificates found
ansible.builtin.fail:
msg: "FAILURE: Found {{ expired_certs | length }} expired and {{ critical_certs | length }} critical certificates!"
run_once: true
when:
- (expired_certs | length > 0) or (critical_certs | length > 0)
- fail_on_expiry | default(false) | bool
Example result:
## EXPIRED CERTIFICATES (1)
**================================================================**
- **Host:** localhost
- **Keystore:** /path/to/certs
- **Alias:** expired_cert.crt
- **Subject:** C=US, ST=California, L=San Francisco, O=Dummy Certificates Inc., CN=expired.dummy.com
- **Expiry Date:** Feb 7 18:19:42 2026 GMT
- **Status: EXPIRED** (-30 days ago)
**----------------------------------------------------------------**
## WARNING - Expiring in 31-60 days (1)
**================================================================**
- **Host:** localhost
- **Keystore:** /path/to/certs
- **Alias:** expiring_32days_cert.crt
- **Subject:** C=US, ST=California, L=San Francisco, O=Dummy Certificates Inc., CN=expiring-32days.dummy.com
- **Expiry Date:** Apr 10 18:19:42 2026 GMT
- **Days Remaining: 31 days**
**----------------------------------------------------------------**
## OK (>90 days)
**================================================================**
- **Host:** localhost
- **Keystore:** /path/to/certs
- **Alias:** expiring_100days_cert.crt
- **Subject:** C=US, ST=California, L=San Francisco, O=Dummy Certificates Inc., CN=expiring-100days.dummy.com
- **Expiry Date:** Jun 18 18:19:42 2026 GMT
- **Days Remaining: 100 days**
**----------------------------------------------------------------**
## SUMMARY
**================================================================**
| Status | Count |
|:--------------------:|:-----:|
| **Expired** | 1 |
| **Critical (≤30d)** | 0 |
| **Warning (31-60d)** | 1 |
| **Notice (61-90d)** | 0 |
| **OK (>90d)** | 1 |
**================================================================**
### **ACTION REQUIRED:**
- **1 certificate is EXPIRED** - Renew immediately!
- **1 certificate expires in 31 days** - Plan renewal soon
- **1 certificate is valid for 100 days** - No action needed
**================================================================**