Google Geminiに
「発行/別サーバからインポートしたSSL証明書の整合性確認を行うシェルスクリプト」
を書いてほしいと尋ねました。
条件は
コマンド [SSL証明書の入ったディレクトリ]
形式- 証明書は
- ドメイン名.crt.yyyymm
- Let's Encryptのように証明書と中間証明書が結合された形式
- 秘密鍵は
- ドメイン名.key.yyyymm
の2つが入ったディレクトリを想定。
できあがったスクリプト
#!/bin/bash
# SSL証明書と秘密鍵の情報を表示するスクリプト
# サーバー証明書の有効期限、ドメイン名、発行局を表示します。
# また、対応する秘密鍵の存在と一致確認も行います。
# 使用法を表示する関数
usage() {
echo "使用法: $0 <証明書と鍵が格納されているディレクトリ>"
echo "ディレクトリには '[ドメイン名].crt.yyyymm' のような名前の証明書ファイルを配置してください。"
echo "また、対応する秘密鍵ファイルは '[ドメイン名].key.yyyymm' の形式です。"
echo "証明書ファイルは、サーバー証明書と中間証明書が結合された形式を想定しています。"
exit 1
}
# 引数が指定されているか確認
if [ -z "$1" ]; then
usage
fi
TARGET_DIR="$1"
# ディレクトリが存在するか確認
if [ ! -d "$TARGET_DIR" ]; then
echo "エラー: ディレクトリ '$TARGET_DIR' が見つかりません。"
exit 1
fi
# openssl コマンドが利用可能か確認
if ! command -v openssl &> /dev/null; then
echo "エラー: openssl コマンドが見つかりません。OpenSSLをインストールしてください。"
exit 1
fi
echo "ディレクトリ '$TARGET_DIR' 内のSSL証明書情報:"
echo "=================================================="
CERT_FILES_FOUND=0 # 処理した証明書ファイルのカウンタを初期化
# プロセス置換を使用して、CERT_FILES_FOUND が現在のシェルで更新されるようにする
while IFS= read -r -d $'\0' CERT_FILE; do
CERT_FILES_FOUND=$((CERT_FILES_FOUND + 1)) # カウンタを増やす
FILENAME=$(basename "$CERT_FILE")
echo "処理中の証明書: $FILENAME"
# --- 有効期限 ---
EXPIRY_DATE_FULL=$(openssl x509 -noout -enddate -in "$CERT_FILE" 2>/dev/null)
if [ $? -ne 0 ] || [ -z "$EXPIRY_DATE_FULL" ]; then
EXPIRY_DATE_DISPLAY="有効期限の抽出エラー"
else
EXPIRY_DATE_DISPLAY=$(echo "$EXPIRY_DATE_FULL" | sed 's/notAfter=//')
fi
echo " 有効期限: $EXPIRY_DATE_DISPLAY"
# --- ドメイン名 (サブジェクトのコモンネーム(CN) および サブジェクト代替名(SANs)) ---
DOMAIN_NAME_DISPLAY="N/A"
SUBJECT_CN_RAW=$(openssl x509 -noout -subject -nameopt multiline -in "$CERT_FILE" 2>/dev/null | grep 'commonName')
SUBJECT_CN=""
if [ $? -eq 0 ] && [ -n "$SUBJECT_CN_RAW" ]; then # grep で commonName が見つかった場合
SUBJECT_CN=$(echo "$SUBJECT_CN_RAW" | sed -e 's/^\s*commonName\s*=\s*//' -e 's/\s*(Default)\s*$//' | head -n 1)
fi
# SANs を取得 (もしあれば最初の SAN 行のみ)
SANS_TEXT=$(openssl x509 -noout -text -in "$CERT_FILE" 2>/dev/null | awk '/X509v3 Subject Alternative Name:/ {found=1; getline; print; exit} END{if(!found)print ""}')
SANS_TEXT_CLEANED=$(echo "$SANS_TEXT" | sed 's/^\s*//') # 先頭の空白を削除
SANS_LIST=""
PRIMARY_SAN_DOMAIN=""
if [[ "$SANS_TEXT_CLEANED" == "DNS:"* ]]; then
# SANsリストを整形: DNS:プレフィックスを除去、空白除去、カンマ区切り、重複除去
SANS_LIST=$(echo "$SANS_TEXT_CLEANED" | sed 's/DNS://g' | tr -d ' ' | tr ',' '\n' | awk '!seen[$0]++' | tr '\n' ',' | sed 's/,$//')
if [ -n "$SANS_LIST" ]; then
PRIMARY_SAN_DOMAIN=$(echo "$SANS_LIST" | cut -d',' -f1) # 最初のSANを主要ドメインとする
fi
fi
if [ -n "$PRIMARY_SAN_DOMAIN" ]; then # 主要SANがある場合
DOMAIN_NAME_DISPLAY="$PRIMARY_SAN_DOMAIN"
# 主要SAN以外のSANがあれば表示
OTHER_SANS=$(echo "$SANS_LIST," | sed "s/$PRIMARY_SAN_DOMAIN,//" | sed 's/,$//')
if [ -n "$OTHER_SANS" ]; then
DOMAIN_NAME_DISPLAY="$PRIMARY_SAN_DOMAIN (他のSAN: $OTHER_SANS)"
fi
# CNが存在し、かつ主要SANと異なる場合にCNも表示
if [ -n "$SUBJECT_CN" ] && [ "$SUBJECT_CN" != "$PRIMARY_SAN_DOMAIN" ]; then
DOMAIN_NAME_DISPLAY="$DOMAIN_NAME_DISPLAY [CNも存在: $SUBJECT_CN]"
fi
elif [ -n "$SUBJECT_CN" ]; then # 主要SANが無く、CNがある場合
DOMAIN_NAME_DISPLAY="$SUBJECT_CN"
if [ -n "$SANS_LIST" ]; then # SANリスト自体は存在する (CNが主要となるケース)
IS_CN_IN_SANS=0
# SANS_LISTを配列に変換して確認しやすくする
IFS=',' read -r -a SAN_ARRAY <<< "$SANS_LIST"
for san_entry in "${SAN_ARRAY[@]}"; do
if [ "$san_entry" = "$SUBJECT_CN" ]; then IS_CN_IN_SANS=1; break; fi
done
if [ $IS_CN_IN_SANS -eq 0 ]; then # CNがSANリストに含まれていない場合
# SANリストがCNと異なる場合にのみSANリストを表示 (CNが唯一のSANである場合を除く)
if [ "$SANS_LIST" != "$SUBJECT_CN" ]; then
DOMAIN_NAME_DISPLAY="$SUBJECT_CN (SAN: $SANS_LIST)" # SAN -> SANs に統一すべきか検討
fi
else # CNがSANリストに含まれている場合、CN以外のSANがあれば表示
OTHER_SANS_THAN_CN=""
TEMP_SANS_LIST=""
for san_entry in "${SAN_ARRAY[@]}"; do
if [ "$san_entry" != "$SUBJECT_CN" ]; then
TEMP_SANS_LIST="${TEMP_SANS_LIST}${san_entry},"
fi
done
OTHER_SANS_THAN_CN=$(echo "$TEMP_SANS_LIST" | sed 's/,$//')
if [ -n "$OTHER_SANS_THAN_CN" ]; then
DOMAIN_NAME_DISPLAY="$SUBJECT_CN (他のSAN: $OTHER_SANS_THAN_CN)"
fi
fi
fi
else # CNもSANも取得できなかった場合
DOMAIN_NAME_DISPLAY="エラー: コモンネームまたはサブジェクト代替名を抽出できませんでした。"
fi
echo " ドメイン名: $DOMAIN_NAME_DISPLAY"
# --- 発行局 (サーバー証明書を発行した中間認証局) ---
# 証明書ファイル内の最初の証明書のIssuerを取得
ISSUER_CN_RAW=$(openssl x509 -noout -issuer -nameopt multiline -in "$CERT_FILE" 2>/dev/null | grep 'commonName')
ISSUER_CN="N/A"
if [ $? -eq 0 ] && [ -n "$ISSUER_CN_RAW" ]; then # grepでcommonNameが見つかったか確認
ISSUER_CN=$(echo "$ISSUER_CN_RAW" | sed -e 's/^\s*commonName\s*=\s*//' -e 's/\s*(Default)\s*$//' | head -n 1)
fi
ISSUER_FULL_RAW=$(openssl x509 -noout -issuer -nameopt oneline,-space_eq,-show_type -in "$CERT_FILE" 2>/dev/null)
ISSUER_FULL="N/A"
if [ $? -eq 0 ] && [ -n "$ISSUER_FULL_RAW" ]; then
ISSUER_FULL=$(echo "$ISSUER_FULL_RAW" | sed 's/issuer=//')
fi
if [ "$ISSUER_CN" != "N/A" ]; then
echo " 発行局 (CN): $ISSUER_CN"
echo " 発行局 (詳細): $ISSUER_FULL"
else
echo " 発行局 (詳細): $ISSUER_FULL (CNが見つからないか、CNの抽出エラーです)"
fi
# --- 秘密鍵の確認 ---
# 証明書ファイル名から対応する秘密鍵ファイル名を推測 (例: domain.crt.yyyymm -> domain.key.yyyymm)
KEY_FILE_CANDIDATE_NAME=$(basename "$CERT_FILE" | sed -E 's/\.crt\.([0-9]{6})$/.key.\1/')
POTENTIAL_KEY_FILE="$TARGET_DIR/$KEY_FILE_CANDIDATE_NAME"
if [ -f "$POTENTIAL_KEY_FILE" ]; then
echo " 秘密鍵ファイル: $(basename "$POTENTIAL_KEY_FILE")"
KEY_MATCH_STATUS="未確定 (エラーまたは非対応の鍵タイプ)"
KEY_TYPE="不明"
CERT_PUBKEY=$(openssl x509 -noout -pubkey -in "$CERT_FILE" 2>/dev/null) # 証明書から公開鍵を抽出
KEY_IS_ENCRYPTED_OR_BAD_FORMAT=0
PRIV_KEY_PUBKEY="" # 初期化
# 鍵の種類を特定し、その公開鍵コンポーネントを取得 ('openssl pkey' は汎用的な鍵読み取り試行)
if openssl pkey -in "$POTENTIAL_KEY_FILE" -noout >/dev/null 2>&1; then # まず鍵として読めるか
if openssl rsa -in "$POTENTIAL_KEY_FILE" -noout -check >/dev/null 2>&1; then # RSA鍵か
KEY_TYPE="RSA"
PRIV_KEY_PUBKEY=$(openssl rsa -in "$POTENTIAL_KEY_FILE" -pubout 2>/dev/null) # RSA秘密鍵から公開鍵を抽出
elif openssl ec -in "$POTENTIAL_KEY_FILE" -noout -check >/dev/null 2>&1; then # EC鍵か
KEY_TYPE="EC"
PRIV_KEY_PUBKEY=$(openssl ec -in "$POTENTIAL_KEY_FILE" -pubout 2>/dev/null) # EC秘密鍵から公開鍵を抽出
else # その他の鍵タイプ (DSAなど) か、上記チェックをパスしないがpkeyでは読める鍵
KEY_TYPE="その他 ('openssl pkey'で解析可能)"
PRIV_KEY_PUBKEY=$(openssl pkey -in "$POTENTIAL_KEY_FILE" -pubout 2>/dev/null) # 汎用的に公開鍵抽出を試行
fi
if [ $? -ne 0 ] || [ -z "$PRIV_KEY_PUBKEY" ]; then # 公開鍵の抽出に失敗した場合
KEY_IS_ENCRYPTED_OR_BAD_FORMAT=1 # 暗号化されているか、形式に問題がある可能性
fi
else # 'openssl pkey' で解析不可の場合
KEY_IS_ENCRYPTED_OR_BAD_FORMAT=1 # 破損または暗号化の可能性が高い
KEY_TYPE="読み取り不可または暗号化"
fi
if [ "$KEY_IS_ENCRYPTED_OR_BAD_FORMAT" -eq 1 ]; then
KEY_MATCH_STATUS="鍵の読み取りエラー (暗号化、パスワード保護、または不正な形式の可能性あり)。検出された鍵タイプ: $KEY_TYPE"
elif [ -n "$CERT_PUBKEY" ] && [ -n "$PRIV_KEY_PUBKEY" ]; then # 証明書の公開鍵と秘密鍵由来の公開鍵が両方取得できた場合
if [ "$CERT_PUBKEY" = "$PRIV_KEY_PUBKEY" ]; then # 公開鍵同士を比較
KEY_MATCH_STATUS="証明書と一致 ($KEY_TYPE)"
else
KEY_MATCH_STATUS="証明書と不一致 ($KEY_TYPE)"
fi
elif [ -z "$CERT_PUBKEY" ]; then # 証明書から公開鍵が取得できなかった場合
KEY_MATCH_STATUS="証明書からの公開鍵生成エラー"
else # 秘密鍵ファイルから公開鍵が取得できなかった場合 (鍵は読めたが公開鍵抽出に失敗)
KEY_MATCH_STATUS="秘密鍵ファイルからの公開鍵生成エラー ($KEY_TYPE)"
fi
echo " 鍵の状態: $KEY_MATCH_STATUS"
else
echo " 秘密鍵ファイル: $(basename "$KEY_FILE_CANDIDATE_NAME") 見つかりません"
fi
echo "--------------------------------------------------"
done < <(find "$TARGET_DIR" -type f -name "*.crt.*[0-9][0-9][0-9][0-9][0-9][0-9]" -print0) # プロセス置換の終了
# このチェックで、ファイルが処理されたかどうかが正しく反映される
if [ "$CERT_FILES_FOUND" -eq 0 ]; then
echo "ディレクトリ '$TARGET_DIR' 内に *.crt.*[数字6桁] のパターンに一致する証明書ファイルは見つかりませんでした。"
echo "(例: domain.com.crt.202312)"
fi
これを `ssl-check.sh` として保存し、実行権限を付けます。
使用例
./ssl-check.sh /ディレクトリのフルパス
で実行した結果、
ディレクトリ '/hoge/' 内のSSL証明書情報:
==================================================
処理中の証明書: hoge.example.com.crt.202505
有効期限: Aug 28 23:17:58 2025 GMT
ドメイン名: *.hoge.example.com
発行局 (CN): E5
発行局 (詳細): C=US, O=Let's Encrypt, CN=E5
秘密鍵ファイル: hoge.example.com.202505
鍵の状態: 証明書と一致 (EC)
--------------------------------------------------
と、
- 有効期限
- ドメイン名
- 自己証明でないか(発行局から発行されているか)
が明示的に分かるようになりました。