我直接给你完整的脚本。这个脚本属于服务器基础设施,不放进项目代码库,你按下面的路径保存即可。
保存路径
/www/server/panel/script/sync_cdn_ssl.php
完整脚本
<!--?php <br ?--> /**
* 同步宝塔 Let's Encrypt SSL 证书到阿里云 CDN
*
* 用法:
* php sync_cdn_ssl.php # 正常执行(检测指纹,无变化则跳过)
* php sync_cdn_ssl.php --force # 强制同步(忽略指纹检测)
* php sync_cdn_ssl.php --check # 仅检测证书状态,不执行同步
*
* 退出码:
* 0 = 成功 / 无需更新
* 1 = 失败
*
* 部署位置: /www/server/panel/script/sync_cdn_ssl.php
* 日志位置: /www/server/panel/logs/cdn_ssl_sync.log
*/
// ==================== 配置区 ====================
// 阿里云 RAM 子账号 AccessKey(建议仅授权 AliyunDNSFullAccess + AliyunCDNFullAccess)
$accessKeyId = 'LTAI5tXXXXXXXXXXXXXXXX'; // TODO: 替换为你的 AccessKeyId
$accessKeySecret = 'XXXXXXXXXXXXXXXXXXXXXXXXXX'; // TODO: 替换为你的 AccessKeySecret
// CDN 加速域名
$cdnDomain = 'img.hao.hsx.ltd';
// 宝塔证书目录(网站域名应与 CDN 域名一致)
$certDir = '/www/server/panel/vhost/cert/img.hao.hsx.ltd';
// 证书文件路径
$certPubPath = $certDir . '/fullchain.pem';
$certPriPath = $certDir . '/privkey.pem';
// 状态记录文件(记录上次同步的证书指纹,避免重复调用 API)
$stateFile = '/www/server/panel/script/.cdn_ssl_sync_state';
// 日志文件
$logFile = '/www/server/panel/logs/cdn_ssl_sync.log';
// 阿里云 CDN API 端点
$apiEndpoint = 'https://cdn.aliyuncs.com/';
// ==================== 主流程 ====================
$force = in_array('--force', $argv ?? [], true);
$check = in_array('--check', $argv ?? [], true);
logMsg("====== 同步任务开始 ======");
logMsg("CDN 域名: {$cdnDomain}");
logMsg("证书目录: {$certDir}");
logMsg("运行模式: " . ($check ? '仅检测' : ($force ? '强制同步' : '常规同步')));
// 1. 检查证书文件是否存在
if (!file_exists($certPubPath) || !file_exists($certPriPath)) {
logMsg("[ERROR] 证书文件不存在: {$certPubPath} 或 {$certPriPath}", 'ERROR');
exit(1);
}
// 2. 读取证书内容
$sslPub = file_get_contents($certPubPath);
$sslPri = file_get_contents($certPriPath);
if ($sslPub === false || $sslPri === false || $sslPub === '' || $sslPri === '') {
logMsg("[ERROR] 证书文件读取失败或为空", 'ERROR');
exit(1);
}
// 3. 解析证书信息
$x509 = openssl_x509_parse($sslPub);
if ($x509 === false) {
logMsg("[ERROR] 证书解析失败,可能文件损坏", 'ERROR');
exit(1);
}
$validFrom = $x509['validFrom_time_t'];
$validTo = $x509['validTo_time_t'];
$daysLeft = intval(($validTo - time()) / 86400);
$issuer = $x509['issuer']['O'] ?? 'Unknown';
$subject = $x509['subject']['CN'] ?? 'Unknown';
logMsg("证书 Subject: {$subject}");
logMsg("签发机构: {$issuer}");
logMsg("有效期: " . date('Y-m-d H:i:s', $validFrom) . " ~ " . date('Y-m-d H:i:s', $validTo));
logMsg("剩余天数: {$daysLeft}");
// 4. 证书过期检查
if ($validTo < time()) { logMsg("[ERROR] 证书已过期,不同步到 CDN", 'ERROR'); exit(1); } // 5. 仅检测模式 if ($check) { logMsg("仅检测模式,不执行同步"); logMsg("====== 检测完成 ======\n"); exit(0); } // 6. 指纹检测(避免重复同步) $fp = md5($sslPub . $sslPri); if (!$force && file_exists($stateFile) && trim(file_get_contents($stateFile)) === $fp) { logMsg("证书指纹未变化,无需同步"); logMsg("====== 同步跳过 ======\n"); exit(0); } // 7. 调用阿里云 CDN API 部署证书 logMsg("开始调用阿里云 CDN API 部署证书..."); $result = callAliyunCdnApi($accessKeyId, $accessKeySecret, $cdnDomain, $sslPub, $sslPri, $apiEndpoint); if (!$result['success']) { logMsg("[ERROR] 阿里云 API 调用失败: " . $result['message'], 'ERROR'); logMsg("[ERROR] 原始响应: " . $result['response'], 'ERROR'); logMsg("====== 同步失败 ======\n"); exit(1); } // 8. 记录指纹 $writeResult = file_put_contents($stateFile, $fp); if ($writeResult === false) { logMsg("[WARN] 指纹记录写入失败,但不影响本次同步结果", 'WARN'); } logMsg("[SUCCESS] 证书已成功同步到阿里云 CDN ({$cdnDomain})"); logMsg("====== 同步成功 ======\n"); exit(0); // ==================== 函数定义 ==================== /** * 调用阿里云 CDN API: SetCdnDomainSSLCertificate */ function callAliyunCdnApi($accessKeyId, $accessKeySecret, $domain, $sslPub, $sslPri, $endpoint) { // 公共参数 $params = [ 'Action' => 'SetCdnDomainSSLCertificate',
'DomainName' => $domain,
'SSLProtocol' => 'https',
'SSLPub' => $sslPub,
'SSLPri' => $sslPri,
'Format' => 'JSON',
'Version' => '2018-05-10',
'AccessKeyId' => $accessKeyId,
'SignatureMethod' => 'HMAC-SHA1',
'Timestamp' => gmdate('Y-m-d\TH:i:s\Z'),
'SignatureVersion' => '1.0',
'SignatureNonce' => uniqid('ssl_', true),
];
// 计算签名
$params['Signature'] = signRpcRequest($params, $accessKeySecret);
// 发送 POST 请求
$ch = curl_init($endpoint);
curl_setopt_array($ch, [
CURLOPT_POST => true,
CURLOPT_POSTFIELDS => http_build_query($params),
CURLOPT_RETURNTRANSFER => true,
CURLOPT_TIMEOUT => 30,
CURLOPT_CONNECTTIMEOUT => 10,
CURLOPT_HTTPHEADER => [
'Content-Type: application/x-www-form-urlencoded',
'Accept: application/json',
],
]);
$response = curl_exec($ch);
$httpCode = curl_getinfo($ch, CURLINFO_HTTP_CODE);
$curlErr = curl_error($ch);
curl_close($ch);
if ($response === false) {
return [
'success' => false,
'message' => 'HTTP 请求失败: ' . $curlErr,
'response' => '',
];
}
$data = json_decode($response, true);
// 阿里云成功响应: HTTP 200 且无 Code 字段
if ($httpCode === 200 && !isset($data['Code'])) {
return [
'success' => true,
'message' => 'OK',
'response' => $response,
];
}
$errMsg = $data['Message'] ?? $data['ErrorMessage'] ?? '未知错误';
$errCode = $data['Code'] ?? 'Unknown';
return [
'success' => false,
'message' => "[{$errCode}] {$errMsg}",
'response' => $response,
];
}
/**
* 阿里云 RPC API 签名算法
* 参考: https://help.aliyun.com/zh/sdk/product-overview/v3-request-structure-and-signature
*/
function signRpcRequest(array $params, $accessKeySecret) {
// 1. 按参数名升序排序
ksort($params);
// 2. 构造规范化请求字符串
$canonicalized = '';
foreach ($params as $k => $v) {
$canonicalized .= '&' . rpcEncode($k) . '=' . rpcEncode($v);
}
$canonicalized = substr($canonicalized, 1);
// 3. 构造待签名字符串
$stringToSign = 'POST&' . rpcEncode('/') . '&' . rpcEncode($canonicalized);
// 4. 计算签名
$signature = base64_encode(hash_hmac('sha1', $stringToSign, $accessKeySecret . '&', true));
return $signature;
}
/**
* 阿里云 RPC API 特殊 URL 编码
* - 使用 rawurlencode
* - + → %20
* - * → %2A
* - %7E → ~
*/
function rpcEncode($s) {
return str_replace(['+', '*', '%7E'], ['%20', '%2A', '~'], rawurlencode($s));
}
/**
* 记录日志(同时输出到 stdout 和日志文件)
*/
function logMsg($msg, $level = 'INFO') {
$line = '[' . date('Y-m-d H:i:s') . '] [' . $level . '] ' . $msg;
// 输出到 stdout
echo $line . "\n";
// 追加到日志文件
global $logFile;
$dir = dirname($logFile);
if (!is_dir($dir)) {
@mkdir($dir, 0755, true);
}
@file_put_contents($logFile, $line . "\n", FILE_APPEND);
}
使用方法
1. 保存脚本
将上面的脚本保存到服务器:
# 在服务器上执行
mkdir -p /www/server/panel/script
nano /www/server/panel/script/sync_cdn_ssl.php
# 粘贴脚本内容,保存退出
2. 填写配置
编辑脚本,修改这两行:
$accessKeyId = 'LTAI5tXXXXXXXXXXXXXXXX'; // 你的 AccessKeyId
$accessKeySecret = 'XXXXXXXXXXXXXXXXXXXXXXXXXX'; // 你的 AccessKeySecret
3. 设置权限
chmod 600 /www/server/panel/script/sync_cdn_ssl.php
chown root:root /www/server/panel/script/sync_cdn_ssl.php
4. 测试脚本
# 先检测证书状态(不会执行同步)
/usr/bin/php /www/server/panel/script/sync_cdn_ssl.php --check
# 正式同步
/usr/bin/php /www/server/panel/script/sync_cdn_ssl.php
# 强制重新同步(忽略指纹)
/usr/bin/php /www/server/panel/script/sync_cdn_ssl.php --force
5. 验证 CDN 证书
# 查看 CDN 证书签发方(应显示 Let's Encrypt)
curl -vI https://img.hao.hsx.ltd 2>&1 | grep -E "issuer|subject"
脚本特性说明
| 特性 | 说明 |
|---|---|
| 指纹检测 | 用 MD5 记录上次同步的证书指纹,证书未变化时秒级跳过,不浪费 API 调用 |
| 强制模式 | --force 参数可忽略指纹强制同步(用于排错) |
| 检测模式 | --check 参数仅查看证书状态,不执行同步 |
| 过期保护 | 证书已过期则不同步,避免把坏证书推到 CDN |
| 双重日志 | 同时输出到 stdout(便于计划任务邮件)和文件(便于回溯) |
| 完整签名 | 严格按阿里云 RPC API V1 签名规范实现,含特殊编码 |
| 退出码 | 0/1 标准退出码,便于在 Shell 脚本中判断成功失败 |
配套计划任务
在宝塔 → 计划任务 中添加:
任务 1:每周一 3:00 续签并同步
#!/bin/bash
/www/server/panel/pyenv/bin/python -u /www/server/panel/class/acme_v2.py --renew=1 >> /www/server/panel/logs/ssl_renew.log 2>&1
sleep 10
/usr/bin/php /www/server/panel/script/sync_cdn_ssl.php >> /www/server/panel/logs/cdn_ssl_sync.log 2>&1
任务 2:每天 4:00 兜底检测同步
#!/bin/bash
/usr/bin/php /www/server/panel/script/sync_cdn_ssl.php >> /www/server/panel/logs/cdn_ssl_sync.log 2>&1
配置好后,证书的续签和 CDN 同步将完全自动化,90 天周期内无需人工干预。