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_exportertls_cert_not_after を引っ張るのも定番です。

それでも、私が欲しかったのは次の条件を全部満たす 1 本の CLI でした:

結果、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-parsernom ベースの純 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::OffsetDateTimecrate:: ルートから明示するか、prelude::* ではなく use x509_parser::prelude::{FromDer, X509Certificate} と絞るのが正解でした。

コンパイラのエラーが丁寧に案内してくれるので迷わないのですが、最初の数秒は「Rust わからん」になります。

5. ANSI カラーは手で書く

Rust のカラー出力ライブラリは充実してますが(coloredtermcolorowo-colorsnu-ansi-term…)、certinfo には依存が足りるほどの色表現はいりません。CyanRedYellowGreenBold があればいい。

そういう時は 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 で、次の「あのサービスの証明書、来月切れてた」が防げるのは悪くない交換レートだと思います。