はじめに
SSL 証明書の期限切れは、ユーザーに「この接続は安全ではありません」と表示され、サイトへのアクセスが実質的に遮断される深刻な障害になる。Let’s Encrypt の普及で自動更新が一般的になったとはいえ、更新処理の失敗・設定ミス・ホスティング側の制限などによって期限切れは依然として起こりうる。
複数のクライアントサイトを保守していると、証明書の状態を個別に管理画面から確認するのは手間がかかる。前回・前々回の記事で構築したエックスサーバーのディスク監視システムに続き、今回は全保守サイトの SSL 証明書有効期限を自動チェックする仕組みを PHP で構築した。
設計上の重要なポイントは「ホスティング会社に依存しない」ことだ。
TLS 直接接続方式:ホスティングを問わず動作する
SSL 証明書の期限を取得する方法は大きく2つある。
| 方法 | 取得先 | 対象 |
|---|---|---|
| ホスティング API | エックスサーバー等の API | そのホスティングが管理する証明書のみ |
| TLS 直接接続 | ドメインへの TCP 443 接続 | どのホスティングでも取得可能 |
保守対象サイトはエックスサーバー・さくらインターネット・ロリポップなど複数のホスティングに分散している。ホスティング API を使う場合、サービスごとに API クライアントを実装する必要があり、API キーも複数管理しなければならない。
今回は管理サーバーからドメインへ直接 TLS 接続し、証明書を取得する方式を採用した。PHP の stream_socket_client を使えばどのドメインへも接続でき、ホスティングが何であっても同じコードで動作する。
管理サーバー(monitor.xsrv.jp)
│
├─ cron(毎日)─── ssl_check.php ──┬─ TLS → example-a.com:443
│ ├─ TLS → example-b.jp:443
│ └─ TLS → example-c.co.jp:443
│
└─ cron(毎週月曜)── ssl_report.php ── 同上
実装
ファイル構成
今回のシステムは、以前構築したディスク監視(xserver_monitor)と同じサーバー上に設置する。Env.php(.env ローダー)と Notifier.php(通知クラス)はどちらのシステムでも同じコードを使うため、共有ライブラリとして切り出した。
manage/
├── lib/ ← 共有ライブラリ
│ ├── Env.php # .env ローダー(全モジュール共用)
│ └── Notifier.php # 通知クラス(全モジュール共用)
├── xserver_monitor/
│ └── lib/
│ ├── XServerApi.php
│ └── CsvLogger.php
└── ssl_monitor/
├── .env # SMTP 認証情報等(Git 管理外)
├── config.php # 監視ドメイン・閾値・通知グループ設定
├── ssl_check.php # 日次実行:閾値チェック・アラート
├── ssl_report.php # 週次実行:サマリーレポート
├── deploy.sh # デプロイスクリプト
└── lib/
├── SslChecker.php # TLS 直接接続で証明書情報を取得
└── SslCsvLogger.php # CSV 記録クラス
サーバー上も同じ構造で配置される。shell/lib/ が両モジュールから参照されるため、Notifier.php を修正する場合はどちらのモジュールの deploy.sh を実行しても更新できる。
SslChecker — TLS 直接接続で証明書を取得
stream_socket_client で ssl:// スキームを指定すると、TLS ハンドシェイク後に証明書情報を取得できる。
class SslChecker
{
public function check(string $domain): array
{
$ctx = stream_context_create(['ssl' => [
'capture_peer_cert' => true, // 証明書オブジェクトを取得
'verify_peer' => false, // 期限切れ証明書も取得するためスキップ
'verify_peer_name' => false,
'SNI_enabled' => true,
'peer_name' => $domain, // SNI: 共有ホスティング対応
]]);
$sock = @stream_socket_client(
"ssl://{$domain}:443",
$errno, $errstr,
$this->timeout,
STREAM_CLIENT_CONNECT,
$ctx
);
if ($sock === false) {
return ['domain' => $domain, 'error' => $errstr, ...];
}
$params = stream_context_get_params($sock);
$cert = $params['options']['ssl']['peer_certificate'];
$info = openssl_x509_parse($cert);
$expiresAt = date('Y-m-d', $info['validTo_time_t']);
$daysRemaining = (int)ceil(($info['validTo_time_t'] - time()) / 86400);
fclose($sock);
return [
'domain' => $domain,
'common_name' => $info['subject']['CN'] ?? null,
'issuer' => $info['issuer']['O'] ?? null,
'expires_at' => $expiresAt,
'days_remaining' => $daysRemaining,
'error' => null,
];
}
}
verify_peer => false は証明書の内容を取得するための設定であり、セキュリティを無視しているわけではない。期限切れの証明書はそのままでは TLS 検証で接続を拒否されてしまうが、「期限切れであることを検出する」には接続して中身を読まなければならない。取得後は days_remaining が 0 以下かどうかで期限切れを判定する。
SNI(Server Name Indication)を有効にしているのは、複数ドメインを1つのサーバーで共有しているケースに対応するためだ。共有ホスティングでは1つの IP アドレスに複数のドメインが紐づいており、SNI を指定しないと誤った証明書を取得してしまうことがある。
SslCsvLogger — ステータス付きで記録
チェック結果を CSV に追記するクラス。ディスク監視の CsvLogger と同様だが、残り日数を閾値と比較して status を付与する点が異なる。
datetime,domain,common_name,issuer,expires_at,days_remaining,status
"2026-04-23 09:00:00",example.com,example.com,"Let's Encrypt",2026-07-09,78,ok
"2026-04-23 09:00:00",example.jp,example.jp,"Let's Encrypt",2026-05-01,8,critical
status の定義は以下の通り。
| status | 条件 |
|---|---|
ok | 残り日数 > WARNING 閾値(デフォルト 30日) |
warning | CRITICAL 閾値 < 残り日数 ≤ WARNING 閾値 |
critical | 0 < 残り日数 ≤ CRITICAL 閾値(デフォルト 7日) |
expired | 残り日数 ≤ 0 |
error | TLS 接続エラー |
日次チェック(ssl_check.php)と週次レポート(ssl_report.php)
SSL 証明書の監視は2つのスクリプトで担う。
ssl_check.php(毎日実行) は問題が発生したときだけ通知する。全ドメインが ok の場合はサイレント。
// 全ドメインのデータを収集
$results = [];
foreach ($config['domains'] as $domainCfg) {
$result = $checker->check($domainCfg['domain']);
$row = $logger->append($result, $warnDays, $critDays);
$results[$domainCfg['domain']] = ['result' => $result, 'status' => $row['status'], ...];
}
// 通知グループごとにフィルタして送信
foreach ($config['notification_groups'] as $group) {
$issues = array_filter(
$results,
fn($d) => domainInGroup($d['result']['domain'], $group['domains'])
&& in_array($d['status'], ['warning', 'critical', 'expired', 'error'])
);
if (empty($issues)) continue;
(new Notifier($group['channels']))->send('[SSL監視] 証明書アラート', buildAlertBody($issues));
}
ssl_report.php(毎週月曜実行) は全ドメインのサマリーを常に送信する。件名は問題の有無で切り替わる。
$subject = $hasIssue
? '[SSL監視] 週次レポート *** 要確認あり *** ' . date('Y-m-d')
: '[SSL監視] 週次レポート ' . date('Y-m-d');
ステータスの深刻度順(expired → critical → warning → ok → error)にソートして出力するため、問題のあるドメインが先頭に並ぶ。
通知グループ:受信者別の送信範囲制御
ディスク監視と同じ notification_groups パターンを使い、受信者ごとに監視対象ドメインの範囲を制御できる。
'notification_groups' => [
// 運営者:全ドメインを把握
[
'label' => '運営者(全体)',
'domains' => '*',
'channels' => [
'email' => ['enabled' => true, 'to' => 'admin@example.com', ...],
],
],
// クライアントA:担当ドメインのみ
[
'label' => 'クライアントA',
'domains' => ['example-a.com', 'example-a.jp'],
'channels' => [
'email' => ['enabled' => true, 'to' => Env::get('NOTIFY_EMAIL_CLIENT_A'), ...],
],
],
],
domains に '*' を指定すると全ドメイン対象になる。配列で指定すれば特定のドメインだけに絞れる。グループを追加しても config.php だけを変更すればよく、スクリプト本体には触れない。
config.php — 監視対象ドメインの定義
'domains' => [
[
'domain' => 'example.com',
'label' => '表示名',
'hosting' => 'xserver', // メモ用。将来の API 連携に使用予定
'xserver' => ['servername' => '', 'api_key' => ''],
],
[
'domain' => 'example.co.jp',
'label' => 'さくらのサイト',
'hosting' => 'sakura', // ホスティングが違っても同じ方法でチェックできる
],
],
'thresholds' => [
'warning' => 30, // 残り30日以内で WARNING
'critical' => 7, // 残り7日以内で CRITICAL
],
ホスティングの識別子(hosting)はメモ用で、実際のチェックには使われない。将来的に XServer API との連携を追加する場合の拡張ポイントとして残してある。
実行結果:週次レポートのサンプル
実際に動作させると、以下のような週次レポートがメールで届く。
件名: [SSL監視] 週次レポート 2026-04-21
SSL 証明書 有効期限 週次レポート
チェック日時: 2026-04-21 09:00:00
------------------------------------------------------------
[CRITICAL] example-a.jp 残り 5日 → 2026-04-26 Let's Encrypt
[WARNING] example-b.com 残り 23日 → 2026-05-14 Let's Encrypt
[OK] example-c.co.jp 残り 78日 → 2026-07-07 Let's Encrypt
[OK] example-d.com 残り 69日 → 2026-06-29 Japan Registry Services Co., Ltd.
[OK] example-e.jp 残り 46日 → 2026-06-05 Let's Encrypt
合計: 5 ドメイン
CRITICAL → WARNING → OK の順に並ぶため、問題があるドメインは先頭で確認できる。さくらインターネットで有料 SSL を使っているサイトは Japan Registry Services Co., Ltd.(JPRS)が発行者として表示された。
デプロイと cron の自動化
デプロイは rsync ベースの deploy.sh で行う。共有ライブラリも一緒に転送される。
# 転送内容を確認(デフォルトはドライラン)
./deploy.sh
# 本番デプロイ
./deploy.sh --deploy
転送後、リモートで PHP の構文チェックが自動実行される。
No syntax errors detected in /home/shell/lib/Env.php
No syntax errors detected in /home/shell/lib/Notifier.php
No syntax errors detected in /home/shell/ssl_monitor/lib/SslChecker.php
No syntax errors detected in /home/shell/ssl_monitor/lib/SslCsvLogger.php
No syntax errors detected in /home/shell/ssl_monitor/ssl_check.php
No syntax errors detected in /home/shell/ssl_monitor/ssl_report.php
cron 設定も XServer API から登録する。
# 日次チェック(毎日 9:00)
curl -s -X POST \
-H "Authorization: Bearer xs_xxxxxxxxxx" \
-H "Content-Type: application/json" \
-d '{
"minute": "0", "hour": "9", "day": "*", "month": "*", "weekday": "*",
"command": "/opt/php-8.3/bin/php $HOME/shell/ssl_monitor/ssl_check.php",
"comment": "SSL証明書監視:日次チェック(毎朝9時)"
}' \
"https://api.xserver.ne.jp/v1/server/your-server.xsrv.jp/cron"
# 週次レポート(毎週月曜 9:00)
curl -s -X POST \
-H "Authorization: Bearer xs_xxxxxxxxxx" \
-H "Content-Type: application/json" \
-d '{
"minute": "0", "hour": "9", "day": "*", "month": "*", "weekday": "1",
"command": "/opt/php-8.3/bin/php $HOME/shell/ssl_monitor/ssl_report.php",
"comment": "SSL証明書監視:週次レポート(毎週月曜9時)"
}' \
"https://api.xserver.ne.jp/v1/server/your-server.xsrv.jp/cron"
まとめ
今回構築したシステムの要点を整理する。
| 項目 | 採用した方法 |
|---|---|
| 証明書取得方式 | TLS 直接接続(stream_socket_client)。ホスティング非依存 |
| 対応ホスティング | エックスサーバー・さくらインターネット・ロリポップなど何でも可 |
| 期限切れ証明書の検出 | verify_peer => false で接続後、残り日数で判定 |
| 共有ホスティング対応 | SNI(peer_name 指定)で正しい証明書を取得 |
| ステータス管理 | ok / warning / critical / expired / error の5段階 |
| 通知タイミング | 日次チェック(問題時のみ)+週次レポート(常時) |
| 通知グループ | notification_groups でドメイン範囲を受信者別に制御 |
| 共有ライブラリ | Env.php / Notifier.php を複数モジュールで共用 |
| cron 設定 | XServer API 経由で自動登録 |
TLS 直接接続方式の最大の利点は、ホスティングが増えても対応コストがゼロな点だ。新しいドメインを追加するときは config.php にエントリを1行追記してデプロイするだけでよい。
将来的には XServer API との組み合わせで API 経由の情報もマージし、より詳細な証明書管理(自動更新の有効化状態など)も把握できるようにしたいと考えている。


コメント