the shed // ruby // tls

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:

tls_cert_expiry.rb

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
Get the code

Full script + README on GitHub: ruby-devops-toolkit/tls-cert-expiry

Never Get Paged for an Expired Cert: A TLS Expiry Monitor in Ruby workflow diagram

Workflow: from target list to exit code.
prerequisites

What you need

  • Ruby 2.7 or newer (tested on 3.3.6). No gems: only socket, openssl, optparse, json and timeout from the standard library.
  • Any OS: Linux, macOS or Windows (RubyInstaller).
  • Outbound network access to the ports you are checking.
the script

Full code

tls_cert_expiry.rbruby
#!/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
walkthrough

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.

example output

What a run looks like

test run
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
troubleshooting

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.
next steps

Extending the script

  • Read targets from an inventory file or Consul/Kubernetes ingress list.
  • Add cert.extensions parsing 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.

Tha-Shed

Type a brief, get a finished film. Characters, voices and music included.

Start creating