Expired certificates are the most preventable outage there is. This stdlib-only Ruby script connects to every endpoint, reads the real certificate, and tells cron exactly what is about to expire.
Step through the build below:
Every sysadmin has been bitten by an expired certificate: the renewal job failed silently, nobody noticed, and one morning a load balancer starts throwing handshake errors. Monitoring suites can catch this, but a 70-line Ruby script gives you the same protection anywhere there is a cron job or CI runner. It reads the certificate the server actually presents (not the one on disk), computes days remaining, and exits with a Nagios-style code so any scheduler can alert on it.
#!/usr/bin/env ruby
# frozen_string_literal: true
# tls_cert_expiry.rb - check TLS certificate expiry for a list of host:port endpoints.
# Pure stdlib (socket, openssl, optparse, json, timeout). Ruby 2.7+.
# Exit codes: 0 all OK, 1 warning, 2 critical/expired/error (Nagios-style, cron friendly)
require 'socket'
require 'openssl'
require 'optparse'
require 'json'
require 'timeout'
Result = Struct.new(:target, :status, :days_left, :not_after, :subject, :issuer, :error, keyword_init: true)
# Fetch the peer certificate. We disable chain verification ON PURPOSE:
# we want to report on expired/self-signed certs rather than fail the handshake.
def fetch_cert(host, port, timeout)
Timeout.timeout(timeout) do
tcp = TCPSocket.new(host, port)
ctx = OpenSSL::SSL::SSLContext.new
ctx.verify_mode = OpenSSL::SSL::VERIFY_NONE
ssl = OpenSSL::SSL::SSLSocket.new(tcp, ctx)
ssl.hostname = host # SNI, required by most shared hosts
ssl.connect
cert = ssl.peer_cert
ssl.close
tcp.close
cert
end
end
def classify(days, warn, crit)
return 'EXPIRED' if days < 0
return 'CRITICAL' if days <= crit
return 'WARNING' if days <= warn
'OK'
end
def check(target, warn:, crit:, timeout:, now: Time.now)
host, port = target.split(':')
port = (port || 443).to_i
cert = fetch_cert(host, port, timeout)
days = ((cert.not_after - now) / 86_400).floor
Result.new(target: target, status: classify(days, warn, crit), days_left: days,
not_after: cert.not_after.utc.strftime('%Y-%m-%d'),
subject: cert.subject.to_s, issuer: cert.issuer.to_s)
rescue StandardError, Timeout::Error => e
Result.new(target: target, status: 'ERROR', error: "#{e.class}: #{e.message}")
end
if $PROGRAM_NAME == __FILE__
opts = { warn: 30, crit: 14, timeout: 5, json: false }
OptionParser.new do |o|
o.banner = 'Usage: tls_cert_expiry.rb [options] host[:port] ...'
o.on('-w', '--warn DAYS', Integer, 'warning threshold (default 30)') { |v| opts[:warn] = v }
o.on('-c', '--crit DAYS', Integer, 'critical threshold (default 14)') { |v| opts[:crit] = v }
o.on('-t', '--timeout SEC', Integer, 'connect timeout (default 5)') { |v| opts[:timeout] = v }
o.on('-j', '--json', 'JSON output') { opts[:json] = true }
end.parse!
abort 'no targets given' if ARGV.empty?
# One thread per endpoint so a slow host cannot stall the whole run.
results = ARGV.map { |t| Thread.new { check(t, **opts.slice(:warn, :crit, :timeout)) } }.map(&:value)
results.sort_by! { |r| r.days_left || -99_999 }
if opts[:json]
puts JSON.pretty_generate(results.map(&:to_h))
else
printf("%-10s %-24s %6s %s\n", 'STATUS', 'TARGET', 'DAYS', 'EXPIRES')
results.each do |r|
printf("%-10s %-24s %6s %s\n", r.status, r.target, r.days_left || '-', r.not_after || r.error)
end
end
worst = results.map(&:status)
exit(worst.any? { |s| %w[CRITICAL EXPIRED ERROR].include?(s) } ? 2 : (worst.include?('WARNING') ? 1 : 0))
end
1. Connect with SNI. ssl.hostname = host sends Server Name Indication. Without it, shared hosts return a default certificate and you report on the wrong site.
2. Disable verification on purpose. VERIFY_NONE looks alarming, but a monitor must read expired and self-signed certs rather than fail the handshake. We only read peer_cert; no data is sent.
3. Compute days remaining. (not_after - now) / 86400 floored. Negative means already expired. classify maps the number to OK / WARNING / CRITICAL / EXPIRED using the --warn and --crit thresholds.
4. Run endpoints in parallel. One Thread per target, wrapped in Timeout.timeout, so one dead host cannot block the others. Failures become ERROR rows.
5. Report and exit. Results are sorted most-urgent first. Exit 0 = all OK, 1 = warning, 2 = critical/expired/error, so cron or Nagios can alert without parsing text.
$ test run (Linux sandbox)
STATUS TARGET DAYS EXPIRES ERROR 127.0.0.1:24449 - Errno::ECONNREFUSED: Connection refused - connect(2) for "127.0.0.1" port 24449 EXPIRED 127.0.0.1:24444 -4 2026-10-01 CRITICAL 127.0.0.1:24443 4 2026-10-09 WARNING 127.0.0.1:24442 19 2026-10-24 OK 127.0.0.1:24441 89 2027-01-02 exit code: 2
Full script + README on GitHub: ruby-devops-toolkit/tls-cert-expiry
What you need
- Ruby 2.7 or newer (tested on 3.3.6). No gems: only
socket,openssl,optparse,jsonandtimeoutfrom the standard library. - Any OS: Linux, macOS or Windows (RubyInstaller).
- Outbound network access to the ports you are checking.
Full code
#!/usr/bin/env ruby
# frozen_string_literal: true
# tls_cert_expiry.rb - check TLS certificate expiry for a list of host:port endpoints.
# Pure stdlib (socket, openssl, optparse, json, timeout). Ruby 2.7+.
# Exit codes: 0 all OK, 1 warning, 2 critical/expired/error (Nagios-style, cron friendly)
require 'socket'
require 'openssl'
require 'optparse'
require 'json'
require 'timeout'
Result = Struct.new(:target, :status, :days_left, :not_after, :subject, :issuer, :error, keyword_init: true)
# Fetch the peer certificate. We disable chain verification ON PURPOSE:
# we want to report on expired/self-signed certs rather than fail the handshake.
def fetch_cert(host, port, timeout)
Timeout.timeout(timeout) do
tcp = TCPSocket.new(host, port)
ctx = OpenSSL::SSL::SSLContext.new
ctx.verify_mode = OpenSSL::SSL::VERIFY_NONE
ssl = OpenSSL::SSL::SSLSocket.new(tcp, ctx)
ssl.hostname = host # SNI, required by most shared hosts
ssl.connect
cert = ssl.peer_cert
ssl.close
tcp.close
cert
end
end
def classify(days, warn, crit)
return 'EXPIRED' if days < 0
return 'CRITICAL' if days <= crit
return 'WARNING' if days <= warn
'OK'
end
def check(target, warn:, crit:, timeout:, now: Time.now)
host, port = target.split(':')
port = (port || 443).to_i
cert = fetch_cert(host, port, timeout)
days = ((cert.not_after - now) / 86_400).floor
Result.new(target: target, status: classify(days, warn, crit), days_left: days,
not_after: cert.not_after.utc.strftime('%Y-%m-%d'),
subject: cert.subject.to_s, issuer: cert.issuer.to_s)
rescue StandardError, Timeout::Error => e
Result.new(target: target, status: 'ERROR', error: "#{e.class}: #{e.message}")
end
if $PROGRAM_NAME == __FILE__
opts = { warn: 30, crit: 14, timeout: 5, json: false }
OptionParser.new do |o|
o.banner = 'Usage: tls_cert_expiry.rb [options] host[:port] ...'
o.on('-w', '--warn DAYS', Integer, 'warning threshold (default 30)') { |v| opts[:warn] = v }
o.on('-c', '--crit DAYS', Integer, 'critical threshold (default 14)') { |v| opts[:crit] = v }
o.on('-t', '--timeout SEC', Integer, 'connect timeout (default 5)') { |v| opts[:timeout] = v }
o.on('-j', '--json', 'JSON output') { opts[:json] = true }
end.parse!
abort 'no targets given' if ARGV.empty?
# One thread per endpoint so a slow host cannot stall the whole run.
results = ARGV.map { |t| Thread.new { check(t, **opts.slice(:warn, :crit, :timeout)) } }.map(&:value)
results.sort_by! { |r| r.days_left || -99_999 }
if opts[:json]
puts JSON.pretty_generate(results.map(&:to_h))
else
printf("%-10s %-24s %6s %s\n", 'STATUS', 'TARGET', 'DAYS', 'EXPIRES')
results.each do |r|
printf("%-10s %-24s %6s %s\n", r.status, r.target, r.days_left || '-', r.not_after || r.error)
end
end
worst = results.map(&:status)
exit(worst.any? { |s| %w[CRITICAL EXPIRED ERROR].include?(s) } ? 2 : (worst.include?('WARNING') ? 1 : 0))
end
Step by step
Connect with SNI
ssl.hostname = host sends Server Name Indication. Without it, shared hosts return a default certificate and you report on the wrong site.
Disable verification on purpose
VERIFY_NONE looks alarming, but a monitor must read expired and self-signed certs rather than fail the handshake. We only read peer_cert; no data is sent.
Compute days remaining
(not_after - now) / 86400 floored. Negative means already expired. classify maps the number to OK / WARNING / CRITICAL / EXPIRED using the --warn and --crit thresholds.
Run endpoints in parallel
One Thread per target, wrapped in Timeout.timeout, so one dead host cannot block the others. Failures become ERROR rows.
Report and exit
Results are sorted most-urgent first. Exit 0 = all OK, 1 = warning, 2 = critical/expired/error, so cron or Nagios can alert without parsing text.
What a run looks like
When it misbehaves
- ECONNREFUSED / timeout: firewall or wrong port. Test with
openssl s_client -connect host:port. - Wrong certificate shown: the host needs SNI; make sure you pass the real hostname, not an IP.
- STARTTLS services (SMTP 587, IMAP): this script speaks direct TLS only; see Extending.
- Testing note: verified in the Linux sandbox against four local TLS servers with generated certs (90, 20, 5 and -3 days). No public hosts were contacted.
Extending the script
- Read targets from an inventory file or Consul/Kubernetes ingress list.
- Add
cert.extensionsparsing to check the SAN list covers the hostname. - Post failures to Slack or a webhook with
Net::HTTP. - Add STARTTLS support for SMTP/IMAP via a plaintext handshake before
connect.