SSL証明書の残り日数を出すスクリプトを書いたら、残り -20670日 という値が返りました。有効期限は数ヶ月先なので明らかにおかしい数字です。原因は macOS の date コマンドが Oct のような英語の月名をパースできないことでした。日本語ロケールで動かしていると %b が日本語の月表記を期待するためです。LC_TIME=C を付けると解決します。
残り日数がマイナス2万日になった
最初に書いたのはこういうコードです。openssl で有効期限を取り出し、date でエポック秒に変換して差を日数にする、という素直な作りです。
for d in example.com; do
END=$(openssl s_client -connect $d:443 -servername $d </dev/null 2>/dev/null \
| openssl x509 -noout -enddate | cut -d= -f2)
DAYS=$(( ( $(date -j -f "%b %d %T %Y %Z" "$END" +%s 2>/dev/null) - $(date +%s) ) / 86400 ))
echo "$d: $END (残り ${DAYS}日)"
done
結果はこうなりました。
example.com: Oct 21 08:49:07 2026 GMT (残り -20670日)
有効期限の表示自体は正しく取れています。壊れているのは日数計算の部分です。
種明かしをすると、date -j -f がパースに失敗して空文字列を返していました。2>/dev/null でエラーを捨てていたので失敗が見えず、算術式の中で空文字列が0として扱われた結果、0 - 現在時刻 の計算になっていました。1970年からの経過秒数を日数にすると約2万日なので、それがマイナスで出ていたわけです。
エラー出力を捨てるとこうなります。デバッグ中は 2>/dev/null を外すのが先でした。
GMTを取り除いても直らなかった
最初に疑ったのは末尾の GMT です。%Z がタイムゾーン名を解釈できていないと考えて、文字列から削ってみました。
END="Oct 21 08:49:07 2026 GMT"
TZ=UTC date -j -f "%b %d %T %Y" "${END% GMT}" +%s
Failed conversion of ``Oct 21 08:49:07 2026'' using format ``%b %d %T %Y''
date: illegal time format
まだ失敗します。${END% GMT} で末尾を削れているのは出力からも確認できるので、GMT は原因ではありませんでした。
次に %T(時刻の短縮指定)を疑って %H:%M:%S に展開してみましたが、これも同じエラーです。フォーマット指定を1つずつ削って、どこで落ちているかを絞りました。
TZ=UTC date -j -f "%T" "08:49:07" +%s
1785919747
TZ=UTC date -j -f "%b %d" "Oct 21" +%s
Failed conversion of ``Oct 21'' using format ``%b %d''
%T は単体なら通ります。%b %d が通りません。つまり月名の解釈で失敗しているという切り分けができました。
原因はロケールだった
%b は「月名の省略形」ですが、これはロケールに依存します。手元の環境を確認するとこうなっていました。
locale
LANG="ja_JP.UTF-8"
LC_COLLATE="ja_JP.UTF-8"
LC_CTYPE="ja_JP.UTF-8"
日本語ロケールで %b が何を出すか見ると、答えが出ます。
date "+%b"
8
日本語ロケールでの月名省略形は 8 という数字表記です。英語の Oct とは一致しないので、パースが失敗していました。openssl は常に英語の月名で日付を出すため、日本語ロケールの date との組み合わせで必ず衝突します。
LC_TIME=C を付けると、時刻の解釈だけをCロケール(英語)に切り替えられます。
LC_TIME=C TZ=UTC date -j -f "%b %d %T %Y" "Oct 21 08:49:07 2026" +%s
1792572547
通りました。LC_ALL=C でも動きますが、こちらは全カテゴリを切り替えるので、必要な範囲に絞る LC_TIME=C のほうが影響が小さくて済みます。
日が1桁のときのスペース2個詰めも通る
openssl の日付出力は、日が1桁だとスペースが2個入って Oct 1 のようになります。これも %d で読めるか確認しておきました。
for END in "Oct 21 08:49:07 2026 GMT" "Oct 1 08:49:07 2026 GMT" "Dec 5 23:11:02 2026 GMT"; do
EPOCH=$(LC_TIME=C TZ=UTC date -j -f "%b %d %T %Y" "${END% GMT}" +%s 2>/dev/null)
echo "[$END] -> epoch=$EPOCH"
done
[Oct 21 08:49:07 2026 GMT] -> epoch=1792572547
[Oct 1 08:49:07 2026 GMT] -> epoch=1790844547
[Dec 5 23:11:02 2026 GMT] -> epoch=1796512262
3件すべて変換できています。スペースの数は問題にならないので、ロケールだけ揃えれば足ります。
閾値の判定はopensslに任せる
「30日以内に失効するか」を知りたいだけなら、日数計算をせずに openssl の -checkend を使えます。指定した秒数の先まで有効かどうかを終了コードで返してくれます。
openssl x509 -noout -checkend 2592000
2592000秒は30日です。有効なら終了コード0、期限内に切れるなら1が返ります。日付のパースを自分でやらないので、ロケールの問題を避けられます。
残り日数の表示と閾値判定を両方やる形にまとめると、最終的にこうなりました。
for d in example.com; do
CERT=$(openssl s_client -connect $d:443 -servername $d </dev/null 2>/dev/null)
END=$(echo "$CERT" | openssl x509 -noout -enddate | cut -d= -f2)
EPOCH=$(LC_TIME=C TZ=UTC date -j -f "%b %d %T %Y" "${END% GMT}" +%s 2>/dev/null)
DAYS=$(( (EPOCH - $(date +%s)) / 86400 ))
if echo "$CERT" | openssl x509 -noout -checkend 2592000 >/dev/null 2>&1; then
echo "✅ $d: $END (残り${DAYS}日)"
else
echo "⚠️ $d: $END (残り${DAYS}日) 30日以内に失効"
fi
done
実行結果です。
✅ example.com: Oct 21 08:49:07 2026 GMT (残り76日)
s_client の結果を変数に入れて2回使い回しているのは、接続を1回で済ませるためです。-enddate と -checkend で別々に繋ぐと接続が2回発生します。
この手のバグを早く見つけるために
今回いちばん時間を無駄にしたのは、2>/dev/null でエラーを隠していたことです。パースが失敗している事実がずっと見えず、出力された数字だけを見て原因を推測していました。空文字列が算術式で0になる仕様と組み合わさると、エラーではなく「もっともらしく間違った値」が出てくるので厄介です。
コマンド置換の結果を計算に使うときは、空になる可能性を先に潰しておくと安全です。
EPOCH=$(LC_TIME=C TZ=UTC date -j -f "%b %d %T %Y" "${END% GMT}" +%s 2>/dev/null)
if [ -z "$EPOCH" ]; then
echo "⚠️ 日付のパースに失敗: $END"
exit 1
fi
空チェックを入れておけば、次に同じ問題を踏んだときは原因が即座に分かります。日付の扱いは環境差が出やすい領域なので、変換が失敗しうる前提でスクリプトを書くのが現実的です。