A TLS Certificate CLI in Rust — No OpenSSL, No Ceremony, Just a Fail-the-Build Exit Code
A tiny Rust CLI that opens a TLS connection, pulls the cert chain, prints expiry / issuer / SAN, and fails your cron job when a cert is about to die. 1.1 MB binary, zero OpenSSL, 20 tests.
An expired TLS certificate is the most avoidable kind of public outage. The failure is announced in advance. It hurts every user. It requires one manual step to fix. It happens anyway, because nobody was looking.
There is no shortage of tools that check certs. openssl s_client -connect host:443 -servername host < /dev/null | openssl x509 -noout -dates -ext subjectAltName will show you the dates if you can remember the incantation. ssl-cert-check has been a trusty Bash script for fifteen years. Prometheus blackbox_exporter exposes probe_ssl_earliest_cert_expiry as a gauge.
What I wanted was a single CLI with all four of these properties:
-
A static binary with no external dependencies — drop it into a container, an Alpine image, a
FROM scratchbuild. - No OpenSSL linkage — vendor-specific OpenSSL version skew is not a war I want to fight.
- Able to inspect certs that your browser rejected — expired, self-signed, hostname mismatched. That is, in fact, the whole point of a diagnostic tool. A cert you can't inspect because it's broken is useless.
-
Exit codes and JSON for monitors — pluggable into Nagios, Mackerel, a Datadog Agent
custom_check, or a shell one-liner.
The answer turned out to be Rust plus rustls plus x509-parser. Seven dependencies, a 1.1 MB binary, 20 tests. I called it certinfo.
GitHub: https://github.com/sen-ltd/certinfo
The shape of the tool
$ certinfo sen.ltd
sen.ltd:443
───────────
Subject : sen.ltd
Issuer : Amazon RSA 2048 M01
Serial : 0f:d9:10:30:fb:9a:cb:95:c4:d2:9f:6b:2d:93:ba:0d
Not before : 2025-09-15T00:00:00Z
Not after : 2026-10-14T23:59:59Z 181 days left
Signature : sha256WithRSAEncryption
SAN : DNS:sen.ltd, DNS:*.sen.ltd
The three-value exit code is the feature that matters most:
| Code | Meaning |
|---|---|
0 |
Certificate is valid (and, if --threshold was given, above it). |
1 |
Certificate is already expired, or below the threshold. |
2 |
Network error, parse error, or invalid input. |
That lets you drop certinfo into a cron job or a CI pipeline and say "fail the thing if we have fewer than 30 days left":
certinfo --threshold 30 api.example.com
JSON comes out as a single flat document you can pipe to jq:
$ certinfo --json api.example.com \
| jq '{host, days: .leaf.days_remaining, expires: .leaf.not_after}'
{ "host": "api.example.com", "days": 181, "expires": "2026-10-14T23:59:59Z" }
And because the tool stops at the TLS handshake — it never sends an HTTP request — it works against any TLS endpoint, not just web servers:
certinfo imap.example.com:993
certinfo smtp.example.com:465
certinfo ldap.example.com:636
certinfo postgres.example.com:5432
Five design decisions worth writing down
1. A TLS verifier that accepts everything
This is the central design choice. A normal TLS client library verifies the cert chain during the handshake. If the cert is bad, the connection fails. That is the correct behaviour for production code. It is exactly the wrong behaviour for a diagnostic tool: I want to see the cert precisely when the cert is bad.
Rustls handles this cleanly. There's a dangerous() escape hatch that lets you install a custom ServerCertVerifier. Mine is deliberately trivial — it accepts every certificate, every signature, every scheme:
impl ServerCertVerifier for AcceptAny {
fn verify_server_cert(
&self,
_end: &CertificateDer<'_>,
_inter: &[CertificateDer<'_>],
_name: &ServerName<'_>,
_ocsp: &[u8],
_now: UnixTime,
) -> Result<ServerCertVerified, rustls::Error> {
Ok(ServerCertVerified::assertion())
}
// verify_tls12_signature / verify_tls13_signature return HandshakeSignatureValid::assertion()
}
Your browser verifies certs. Your HTTP client verifies certs. This tool's job is to tell you why your browser is refusing to speak to a server. You cannot do that if you refuse to speak to the server either.
The fact that rustls makes the permissive mode explicitly opt-in via a method called dangerous() is exactly right — the library keeps you on the safe default and makes you type the scary word to leave it.
2. Stop at the handshake
After the handshake completes, certinfo sends close_notify and walks away:
while conn.is_handshaking() {
let (rd, wr) = conn.complete_io(&mut sock)
.map_err(|e| Error::Handshake(e.to_string()))?;
if rd == 0 && wr == 0 { break; }
}
let owned: Vec<Vec<u8>> = {
let certs = conn.peer_certificates().ok_or(Error::NoCerts)?;
certs.iter().map(|c| c.as_ref().to_vec()).collect()
};
conn.send_close_notify();
let _ = conn.complete_io(&mut sock);
No HTTP request is ever sent. That's what makes the tool protocol-agnostic. IMAPS, SMTPS, LDAPS, and "Postgres with sslmode=require" all send the server certificate right after ClientHello, so anything that speaks TLS-first is inspectable.
Protocols that do STARTTLS — plaintext first, then upgrade — don't fit this pattern. Supporting them would mean writing per-protocol plaintext preambles, gated by a --starttls imap flag. I left that for later.
3. Borrow-checker interlude: the peer certificate lifetime
rustls::ClientConnection::peer_certificates() returns Option<&[CertificateDer<'_>]>, and that slice immutably borrows the connection. Which means you cannot call conn.send_close_notify() while the slice is in scope.
My first draft didn't compile:
let certs = conn.peer_certificates().ok_or(Error::NoCerts)?; // &conn
let owned: Vec<Vec<u8>> = certs.iter().map(|c| c.as_ref().to_vec()).collect();
conn.send_close_notify(); // error[E0502]: cannot borrow as mutable
The fix is a trivial block scope that ends the immutable borrow before we need the mutable one:
let owned: Vec<Vec<u8>> = {
let certs = conn.peer_certificates().ok_or(Error::NoCerts)?;
certs.iter().map(|c| c.as_ref().to_vec()).collect()
}; // certs dropped here
conn.send_close_notify();
Using a bare block to shorten a borrow is, in my experience, the single most common "tiny Rust trick" in real code. It doesn't warrant a helper function at this size, and it's more honest than clone-all-the-things.
4. Leave ASN.1 parsing to the professionals
Parsing DER by hand is a tragic life choice. Use x509-parser. It's pure-Rust (nom-based), no C linkage, and it gives you everything you need from one call:
use x509_parser::prelude::{FromDer, X509Certificate};
let (_, cert) = X509Certificate::from_der(der)?;
let subject = cert.subject().to_string();
let issuer = cert.issuer().to_string();
let not_before = cert.validity().not_before.to_datetime();
let not_after = cert.validity().not_after.to_datetime();
let sans = cert.subject_alternative_name().ok().flatten();
let sig_algo = &cert.signature_algorithm.algorithm; // OID
There is one sharp edge worth flagging: x509_parser::prelude::* re-exports a module called time, which collides with the time crate. The compiler produces an E0659 (ambiguous name) error that's a little spooky the first time you see it. The fix is either:
- refer to the crate explicitly as
::time::OffsetDateTime, or - drop the glob import and write
use x509_parser::prelude::{FromDer, X509Certificate}instead.
I went with the explicit-paths approach. Imports should be boring.
5. Hand-rolled ANSI is fine
Rust has excellent colour crates (colored, termcolor, owo-colors, nu-ansi-term). certinfo needs six colours and no styling features — so rather than take a dependency, I wrote the six lines:
fn label(text: &str, style: Style, color: bool) -> String {
if !color { return text.to_string(); }
let code: &str = match style {
Style::Bold => "\x1b[1m",
Style::Dim => "\x1b[2m",
Style::Key => "\x1b[36m", // cyan
Style::Ok => "\x1b[32m", // green
Style::Warn => "\x1b[33m", // yellow
Style::Error => "\x1b[31m", // red
};
format!("{code}{text}\x1b[0m")
}
TTY detection, which used to require the is-terminal or atty crates, is now in the standard library:
use std::io::IsTerminal;
let color = !cli.no_color && std::io::stdout().is_terminal();
std::io::IsTerminal stabilised in Rust 1.70. One of the nicest small-dependency-removing changes of the last few years.
6. The days-remaining badge does a lot of work
The value of the colour isn't the colour itself — it's that humans parse colour faster than they parse numbers. "181 days left" in green and "5 days left" in red are processed at very different speeds. The thresholds I picked come from common SRE practice:
fn days_badge(days: i64, opts: &RenderOptions) -> String {
let (text, style) = if days < 0 {
(format!("EXPIRED {} days ago", -days), Style::Error)
} else if days < 7 {
(format!("{days} days left"), Style::Error) // fire drill
} else if days < 30 {
(format!("{days} days left"), Style::Warn) // plan the renewal
} else {
(format!("{days} days left"), Style::Ok) // ignore for now
};
label(&text, style, opts.color)
}
Under a week is a "stop what you're doing" event. Under a month is a "put it on Friday's task list" event. Beyond a month is silence. Most operations teams organise their cert renewal work around exactly that rhythm.
Tests
The CLI has 20 tests split across three surfaces:
| Surface | # | Covered |
|---|---|---|
lib unit |
11 | day math, serial formatting, SAN rendering (IPv4 + IPv6), JSON schema smoke, OID → name mapping |
main unit |
5 |
host:port splitter, IPv6 literals, bad port rejection |
| CLI integration | 4 |
--help coverage, exit code 2 on bad port, exit code 2 on unreachable host |
Deliberately, there are no tests that hit the network. Network-dependent tests are the single biggest cause of flaky CI I've ever seen; when I need to know the live path works I run a release build against sen.ltd, expired.badssl.com, and self-signed.badssl.com by hand.
Release profile
The usual size-squeeze in Cargo.toml:
[profile.release]
strip = true
lto = true
codegen-units = 1
opt-level = "z"
panic = "abort"
Result: 1.1 MB on macOS arm64, ring crypto included. You can scp that to a jumpbox without noticing.
Why not just use openssl or a Prom exporter?
Both are fine. openssl is everywhere; blackbox_exporter is production-grade. certinfo occupies the space between them — smaller than a Prometheus stack, more ergonomic than openssl s_client, and usable by every developer on the team without an SRE onboarding meeting.
It's also the tool I reach for when the Prometheus stack itself is what has an expired cert.
Closing
TLS expiry monitoring is one of those tasks everyone assumes is handled, and often is — by a Bash script nobody has opened in three years. certinfo replaces that corner with a single 1.1 MB binary, a three-value exit code, and a JSON mode that fits in one shell pipe.
It's a small tool. Its whole job is to prevent the cheapest class of embarrassing outage. That trade seems fair.