TLS 証明書の有効期限を叫ぶ CLI を Rust で書いた ― 依存 OpenSSL ゼロ
きっかけ
TLS 証明書の有効期限切れは、Web サービスが人類に与える最も惜しい種類の障害だと思っています。
原因は事前に分かっているし、影響は全ユーザーに及ぶし、対処は手動のコピペ 1 回で終わる。ただ「誰も気付かなかった」だけで起きる。
世の中にはもちろんチェックツールがあります。openssl s_client -connect host:443 -servername host < /dev/null | openssl x509 -noout -dates -ext subjectAltName と打てば日付は見られるし、ssl-cert-check という老舗の Bash スクリプトもあります。Prometheus blackbox_exporter の tls_cert_not_after を引っ張るのも定番です。
それでも、私が欲しかったのは次の条件を全部満たす 1 本の CLI でした:
- 依存ゼロで配れる静的バイナリ — コンテナにも Alpine にも放り込める
- OpenSSL に依存しない — ベンダーごとの OpenSSL バージョン差異で戦いたくない
- 期限切れ証明書も検査できる — 本来それが検査の目的のはずなのに
curlやopenssl verifyだと拒否されがち - 監視向けの exit code と JSON — Nagios / Mackerel / Datadog-Agent の custom check から直接使える
- 人間向けの見やすい出力 — 「あと 12 日」が赤く光る
結果、Rust + rustls + x509-parser の 3 本柱で certinfo を書きました。依存 7 個、バイナリ 1.1 MB、テスト 20 本。
📦 GitHub: https://github.com/sen-ltd/certinfo

使い方
# 基本
$ 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
# しきい値チェック — 30 日未満なら exit 1
$ certinfo --threshold 30 api.example.com
# JSON で監視スクリプトへ
$ certinfo --json api.example.com \
| jq '{host, days: .leaf.days_remaining, expires: .leaf.not_after}'
# チェーン全体を確認
$ certinfo --chain sen.ltd
# 非 HTTPS の TLS エンドポイントも
$ certinfo imap.example.com:993
$ certinfo smtp.example.com:465
--threshold N が本体の売り所です。cert has N days left を表示するだけなら 50 行で書けるのですが、「N 日未満だったら exit 1 で落ちる」を入れると cron に放り込めるツールに化けます。
終了コードは 3 値:
| Code | 意味 |
|---|---|
| 0 | 有効(しきい値以上の残日数) |
| 1 | 期限切れ、もしくは --threshold を下回る |
| 2 | 接続エラー・パースエラー・入力不正 |
設計上のポイント
1. なぜ「検証しない」TLS クライアントで OK なのか
これは certinfo の根幹です。普通の TLS クライアントライブラリは、ハンドシェイク中に証明書を検証します。検証に失敗したら接続ごとエラーで返す。これは本番アプリには正しい挙動ですが、診断ツールとしては致命的 です。期限切れ証明書を検査したいのに、期限切れだと接続が張れない。
rustls はここを真正面から用意してくれていて、dangerous().with_custom_certificate_verifier(...) で独自の ServerCertVerifier を差し込めます。私のは「何が来ても ServerCertVerified::assertion() を返す」だけの空実装:
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 も同じく assertion() を返す
}
ブラウザはちゃんと検証するし、本番の HTTP クライアントもそうする。診断ツールだけが「証明書の中身を見る権利」を持つ。この線引きが明示的に API 分離されているのは rustls のいいところです。
2. ハンドシェイクだけで止める
certinfo は HTTP リクエストを発行しません。TCP 接続 → TLS ハンドシェイク完了 → peer_certificates() → close_notify 送信、で終わり。
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);
この「ハンドシェイクで止める」設計のおかげで、HTTPS 以外の TLS エンドポイントも検査できます。IMAPS (993)、SMTPS (465)、LDAPS (636)、Postgres over TLS (5432)。プロトコルに関係なく、サーバは ClientHello の直後に Certificate を送ってくるので、そこだけ見ればいい。
STARTTLS 系(平文で STARTTLS を投げてから TLS に移行するやつ)はこのパターンから外れるので、いまは対応していません。将来追加するなら --starttls imap みたいなフラグで平文部分をアプリ層別に書くことになります。
3. peer_certificates() のライフタイム
小さいが悩ましかった borrow checker トピック。rustls::ClientConnection::peer_certificates() は &[CertificateDer<'_>] を返すのですが、このスライスは self を不変借用します。つまり借用中は conn を可変で触れない。
最初は素直に書いて撃沈しました:
let certs = conn.peer_certificates().ok_or(Error::NoCerts)?; // 不変借用
let owned = certs.iter().map(|c| c.as_ref().to_vec()).collect();
conn.send_close_notify(); // error[E0502]: cannot borrow as mutable
スコープを閉じれば済む話で、ブロック式で owned を作り切ってから conn を触る、という形に落ち着きました:
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();
借用のスコープを明示するためだけにブロックを切る、というのは Rust ではよくあるパターンです。関数に切り出す方がより綺麗だけど、20 行程度なら中間スコープで十分。
4. x509 パースは x509-parser に任せる
ASN.1 DER は自分では書きたくない。バグの温床であり、仕様を追うだけで数日溶ける世界です。
x509-parser は nom ベースの純 Rust ASN.1 パーサで、X509Certificate::from_der() 一発で必要なフィールドがほぼ全部取れます:
let (_, cert) = X509Certificate::from_der(der)?;
let subject = cert.subject().to_string();
let issuer = cert.issuer().to_string();
let serial = cert.raw_serial();
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
唯一のハマりどころは time クレートの名前衝突 です。x509_parser::prelude::* を use すると time モジュールが re-export されて、トップレベルの use time::OffsetDateTime が ambiguous に。::time::OffsetDateTime と crate:: ルートから明示するか、prelude::* ではなく use x509_parser::prelude::{FromDer, X509Certificate} と絞るのが正解でした。
コンパイラのエラーが丁寧に案内してくれるので迷わないのですが、最初の数秒は「Rust わからん」になります。
5. ANSI カラーは手で書く
Rust のカラー出力ライブラリは充実してますが(colored、termcolor、owo-colors、nu-ansi-term…)、certinfo には依存が足りるほどの色表現はいりません。Cyan と Red と Yellow と Green と Bold があればいい。
そういう時は 6 行の label() で十分:
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 判定は std::io::IsTerminal が標準入りしているので外部クレート不要:
let color = !cli.no_color && std::io::stdout().is_terminal();
isatty だけのために atty / is-terminal crate を入れていた時代は終わりました。std::io::IsTerminal は Rust 1.70 から安定しています。
6. 残日数のバッジで人間に叫ぶ
残日数の色分けは、ツール単体の価値として意外と効きます。「180 days left」が緑で出るだけで「OK」と分かる。「5 days left」が赤で出たら、放置しない:
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)
} else if days < 30 {
(format!("{days} days left"), Style::Warn)
} else {
(format!("{days} days left"), Style::Ok)
};
label(&text, style, opts.color)
}
7 日と 30 日はよくある SRE 的なしきい値(7 日 = 緊急対応レーン、30 日 = 通常の更新作業ウィンドウ)から決めています。
テスト
| 種類 | 数 | 内容 |
|---|---|---|
| lib unit | 11 | 日付差分、シリアル整形、SAN レンダー、JSON スキーマ、OID マッピング |
| main unit | 5 | host:port 分解、IPv6 リテラル、ポート不正 |
| CLI integration | 4 | --help の中身、exit code 2 経路、到達不能ホストの失敗 |
test result: ok. 11 passed (lib)
test result: ok. 5 passed (main)
test result: ok. 4 passed (cli integration)
ネットワーク依存テストは意図的に入れていません。CI が不安定になる主犯なので、TLS ハンドシェイクの実動作検証は手元の release ビルドで sen.ltd / expired.badssl.com / self-signed.badssl.com の 3 本を回して確認する、という運用です。
リリースプロファイル
毎度おなじみのバイナリサイズ絞り:
[profile.release]
strip = true
lto = true
codegen-units = 1
opt-level = "z"
panic = "abort"
これで target/release/certinfo が macOS (arm64) で 1.1 MB。ring 入りで 1.1 MB に収まるのは割と誇らしい。
おわりに
TLS 証明書の監視は「誰かがやっているはず」と思いがちで、実際には crontab -e の底で openssl s_client が動いていることが多い。certinfo はその雑務を、単一バイナリ 1 本、exit code で返してくる行儀のいい CLI に置き換えます。
コンテナ 1 GB のイメージに詰め込んでもそんなに申し訳ない気がしない、それくらい軽い 1.1 MB で、次の「あのサービスの証明書、来月切れてた」が防げるのは悪くない交換レートだと思います。