Certbot 证书自动化教程 / 第 9 章:续期钩子
第 9 章:续期钩子
9.1 钩子概述
续期钩子(Renewal Hooks)是 Certbot 在证书续期流程的各个阶段执行的自定义脚本。通过钩子,可以实现服务重启、日志记录、通知发送、配置分发等自动化操作。
钩子执行流程
certbot renew
│
▼
┌────────────────┐
│ pre-hook │ 续期尝试前执行(每次运行都执行)
│ 停止服务等 │
└───────┬────────┘
│
▼
┌────────────────┐
│ 尝试续期 │ 检查证书是否需要续期
│ │
└───────┬────────┘
│
┌────┴────┐
│ 续期成功?│
└────┬────┘
是 │ 否
┌────┴──┐ │
▼ │ │
┌────────┐ │ │
│deploy │ │ │ 仅续期成功后执行
│ hook │ │ │ 重载服务/通知等
└───┬────┘ │ │
│ │ │
▼ ▼ ▼
┌────────────────┐
│ post-hook │ 续期尝试后执行(每次运行都执行)
│ 启动服务等 │
└────────────────┘
钩子类型汇总
| 钩子 | CLI 参数 | 配置文件键 | 执行条件 | 典型用途 |
|---|---|---|---|---|
| Pre-hook | --pre-hook | pre_hook | 每次续期尝试前 | 停止占用 80 端口的服务 |
| Post-hook | --post-hook | post_hook | 每次续期尝试后 | 启动之前停止的服务 |
| Deploy-hook | --deploy-hook | deploy | 仅续期成功后 | 重载 Web 服务器、发送通知 |
9.2 服务重启钩子
Standalone 模式的服务管理
#!/bin/bash
# /etc/letsencrypt/renewal-hooks/pre/stop-nginx.sh
# 描述:续期前停止 Nginx 以释放 80 端口
if pgrep -x "nginx" > /dev/null; then
echo "[$(date)] Stopping Nginx for cert renewal..." >> /var/log/certbot-hooks.log
systemctl stop nginx
fi
#!/bin/bash
# /etc/letsencrypt/renewal-hooks/post/start-nginx.sh
# 描述:续期后启动 Nginx
if ! pgrep -x "nginx" > /dev/null; then
echo "[$(date)] Starting Nginx after cert renewal..." >> /var/log/certbot-hooks.log
systemctl start nginx
fi
Web 服务器重载
#!/bin/bash
# /etc/letsencrypt/renewal-hooks/deploy/reload-webserver.sh
# 描述:证书更新成功后重载 Web 服务器
DOMAIN="${RENEWED_DOMAINS%% *}" # 获取第一个域名
LOGFILE="/var/log/certbot-hooks.log"
echo "[$(date)] Certificate renewed for: ${RENEWED_DOMAINS}" >> "$LOGFILE"
echo "[$(date)] Certificate path: ${RENEWED_LINEAGE}" >> "$LOGFILE"
# 检测并重载 Nginx
if systemctl is-active --quiet nginx; then
echo "[$(date)] Reloading Nginx..." >> "$LOGFILE"
systemctl reload nginx
echo "[$(date)] Nginx reloaded successfully" >> "$LOGFILE"
fi
# 检测并重载 Apache
if systemctl is-active --quiet apache2; then
echo "[$(date)] Reloading Apache..." >> "$LOGFILE"
systemctl reload apache2
echo "[$(date)] Apache reloaded successfully" >> "$LOGFILE"
fi
钩子可用的环境变量
Certbot 在执行 deploy hook 时会设置以下环境变量:
| 环境变量 | 说明 | 示例 |
|---|---|---|
RENEWED_DOMAINS | 续期的域名列表(空格分隔) | example.com www.example.com |
RENEWED_LINEAGE | 证书目录路径 | /etc/letsencrypt/live/example.com |
RENEWED_CERT_PATH | 证书文件路径 | /etc/letsencrypt/live/example.com/cert.pem |
RENEWED_KEY_PATH | 私钥文件路径 | /etc/letsencrypt/live/example.com/privkey.pem |
RENEWED_FULLCHAIN_PATH | 完整链路径 | /etc/letsencrypt/live/example.com/fullchain.pem |
9.3 通知钩子
邮件通知
#!/bin/bash
# /etc/letsencrypt/renewal-hooks/deploy/notify-email.sh
# 描述:证书续期成功后发送邮件通知
DOMAINS="${RENEWED_DOMAINS}"
EMAIL="[email protected]"
SUBJECT="SSL Certificate Renewed: ${DOMAINS}"
BODY="Certificate for ${DOMAINS} has been successfully renewed.
Time: $(date)
Path: ${RENEWED_LINEAGE}
"
# 使用 mail 命令发送
echo "$BODY" | mail -s "$SUBJECT" "$EMAIL"
# 或使用 sendmail
# echo -e "To: ${EMAIL}\nSubject: ${SUBJECT}\n\n${BODY}" | sendmail -t
# 或使用 curl 调用邮件 API
# curl -s --user "api:YOUR_API_KEY" \
# https://api.mailgun.net/v3/example.com/messages \
# -F from="Certbot <[email protected]>" \
# -F to="$EMAIL" \
# -F subject="$SUBJECT" \
# -F text="$BODY"
企业微信/钉钉/Slack 通知
#!/bin/bash
# /etc/letsencrypt/renewal-hooks/deploy/notify-wechat.sh
# 描述:发送企业微信通知
DOMAINS="${RENEWED_DOMAINS}"
WEBHOOK_URL="https://qyapi.weixin.qq.com/cgi-bin/webhook/send?key=YOUR_KEY"
curl -s -X POST "$WEBHOOK_URL" \
-H 'Content-Type: application/json' \
-d "{
\"msgtype\": \"markdown\",
\"markdown\": {
\"content\": \"## SSL 证书续期通知\n> 域名: ${DOMAINS}\n> 时间: $(date)\n> 状态: 续期成功\"
}
}"
#!/bin/bash
# /etc/letsencrypt/renewal-hooks/deploy/notify-slack.sh
# 描述:发送 Slack 通知
DOMAINS="${RENEWED_DOMAINS}"
WEBHOOK_URL="https://hooks.slack.com/services/YOUR/WEBHOOK/URL"
curl -s -X POST "$WEBHOOK_URL" \
-H 'Content-Type: application/json' \
-d "{
\"text\": \"✅ SSL Certificate Renewed\",
\"blocks\": [
{
\"type\": \"section\",
\"text\": {
\"type\": \"mrkdwn\",
\"text\": \"*SSL 证书续期成功*\n域名: \`${DOMAINS}\`\n时间: $(date)\"
}
}
]
}"
#!/bin/bash
# /etc/letsencrypt/renewal-hooks/deploy/notify-dingtalk.sh
# 描述:发送钉钉通知
DOMAINS="${RENEWED_DOMAINS}"
WEBHOOK_URL="https://oapi.dingtalk.com/robot/send?access_token=YOUR_TOKEN"
curl -s -X POST "$WEBHOOK_URL" \
-H 'Content-Type: application/json' \
-d "{
\"msgtype\": \"markdown\",
\"markdown\": {
\"title\": \"SSL 证书续期通知\",
\"text\": \"## SSL 证书续期通知\n- 域名: ${DOMAINS}\n- 时间: $(date)\n- 状态: 续期成功\"
}
}"
9.4 配置分发钩子
将证书同步到其他服务器
#!/bin/bash
# /etc/letsencrypt/renewal-hooks/deploy/sync-certs.sh
# 描述:续期后将证书同步到其他服务器
DOMAINS="${RENEWED_DOMAINS}"
LINEAGE="${RENEWED_LINEAGE}"
# 目标服务器列表
SERVERS=(
"web01.example.com"
"web02.example.com"
"web03.example.com"
)
LOGFILE="/var/log/certbot-sync.log"
for SERVER in "${SERVERS[@]}"; do
echo "[$(date)] Syncing certs to ${SERVER}..." >> "$LOGFILE"
# 使用 rsync 同步证书
rsync -avz --delete \
-e "ssh -i /root/.ssh/certbot-sync -o StrictHostKeyChecking=no" \
"${LINEAGE}/" \
"root@${SERVER}:/etc/letsencrypt/live/$(basename ${LINEAGE})/" \
>> "$LOGFILE" 2>&1
if [ $? -eq 0 ]; then
echo "[$(date)] Sync to ${SERVER} successful" >> "$LOGFILE"
# 远程重载 Nginx
ssh -i /root/.ssh/certbot-sync "root@${SERVER}" \
"systemctl reload nginx" >> "$LOGFILE" 2>&1
else
echo "[$(date)] ERROR: Sync to ${SERVER} failed" >> "$LOGFILE"
fi
done
使用 Ansible 分发证书
#!/bin/bash
# /etc/letsencrypt/renewal-hooks/deploy/ansible-distribute.sh
# 描述:使用 Ansible 分发证书到服务器组
DOMAINS="${RENEWED_DOMAINS}"
LINEAGE="${RENEWED_LINEAGE}"
DOMAIN=$(echo "$DOMAINS" | awk '{print $1}')
cat > /tmp/certbot-deploy-playbook.yml << EOF
---
- name: Distribute SSL certificates
hosts: webservers
become: yes
tasks:
- name: Create certificate directory
file:
path: /etc/letsencrypt/live/${DOMAIN}
state: directory
mode: '0755'
- name: Copy certificate files
copy:
src: "${LINEAGE}/{{ item }}"
dest: "/etc/letsencrypt/live/${DOMAIN}/{{ item }}"
mode: '0644'
loop:
- cert.pem
- chain.pem
- fullchain.pem
- privkey.pem
- name: Reload Nginx
systemd:
name: nginx
state: reloaded
EOF
ansible-playbook /tmp/certbot-deploy-playbook.yml
9.5 数据库与应用钩子
数据库 SSL 证书更新
#!/bin/bash
# /etc/letsencrypt/renewal-hooks/deploy/update-mysql-ssl.sh
# 描述:更新 MySQL SSL 证书
DOMAINS="${RENEWED_DOMAINS}"
LINEAGE="${RENEWED_LINEAGE}"
# 复制证书到 MySQL 目录
cp "${LINEAGE}/fullchain.pem" /etc/mysql/ssl/server-cert.pem
cp "${LINEAGE}/privkey.pem" /etc/mysql/ssl/server-key.pem
chown mysql:mysql /etc/mysql/ssl/*.pem
chmod 600 /etc/mysql/ssl/server-key.pem
# 重载 MySQL
systemctl reload mysql
echo "[$(date)] MySQL SSL certificates updated for: ${DOMAINS}" >> /var/log/certbot-hooks.log
Redis SSL 证书更新
#!/bin/bash
# /etc/letsencrypt/renewal-hooks/deploy/update-redis-ssl.sh
# 描述:更新 Redis SSL 证书
LINEAGE="${RENEWED_LINEAGE}"
cp "${LINEAGE}/fullchain.pem" /etc/redis/tls/redis.crt
cp "${LINEAGE}/privkey.pem" /etc/redis/tls/redis.key
chown redis:redis /etc/redis/tls/*.pem
chmod 600 /etc/redis/tls/redis.key
systemctl restart redis-server
9.6 监控与审计钩子
证书续期审计日志
#!/bin/bash
# /etc/letsencrypt/renewal-hooks/deploy/audit-log.sh
# 描述:记录详细的续期审计日志
DOMAINS="${RENEWED_DOMAINS}"
LINEAGE="${RENEWED_LINEAGE}"
AUDIT_LOG="/var/log/certbot-audit.log"
# 获取证书详细信息
CERT_INFO=$(openssl x509 -in "${LINEAGE}/cert.pem" -noout \
-subject -issuer -dates -serial)
echo "========================================" >> "$AUDIT_LOG"
echo "Certificate Renewed: $(date)" >> "$AUDIT_LOG"
echo "Domains: ${DOMAINS}" >> "$AUDIT_LOG"
echo "Path: ${LINEAGE}" >> "$AUDIT_LOG"
echo "Certificate Info:" >> "$AUDIT_LOG"
echo "$CERT_INFO" >> "$AUDIT_LOG"
echo "========================================" >> "$AUDIT_LOG"
Prometheus 指标上报
#!/bin/bash
# /etc/letsencrypt/renewal-hooks/deploy/prometheus-metrics.sh
# 描述:上报证书续期指标到 Pushgateway
DOMAINS="${RENEWED_DOMAINS}"
LINEAGE="${RENEWED_LINEAGE}"
PUSHGATEWAY="http://localhost:9091"
# 计算证书剩余天数
EXPIRY_DATE=$(openssl x509 -in "${LINEAGE}/cert.pem" -noout -enddate | cut -d= -f2)
EXPIRY_EPOCH=$(date -d "$EXPIRY_DATE" +%s)
NOW_EPOCH=$(date +%s)
DAYS_LEFT=$(( (EXPIRY_EPOCH - NOW_EPOCH) / 86400 ))
# 上报指标
for DOMAIN in $DOMAINS; do
cat << EOF | curl -s --data-binary @- "http://${PUSHGATEWAY}/metrics/job/certbot/domain/${DOMAIN}"
# HELP certbot_certificate_expiry_days Days until certificate expiry
# TYPE certbot_certificate_expiry_days gauge
certbot_certificate_expiry_days ${DAYS_LEFT}
# HELP certbot_certificate_renewed_timestamp Certificate renewal timestamp
# TYPE certbot_certificate_renewed_timestamp gauge
certbot_certificate_renewed_timestamp ${NOW_EPOCH}
EOF
done
9.7 错误处理钩子
续期失败通知
#!/bin/bash
# /etc/letsencrypt/renewal-hooks/post/notify-failure.sh
# 描述:续期失败时发送告警
LOGFILE="/var/log/letsencrypt/letsencrypt.log"
# 检查最近一次续期是否失败
if grep -q "No renewals were attempted" "$LOGFILE"; then
exit 0 # 没有需要续期的证书,正常退出
fi
if grep -q "Error" "$LOGFILE" || grep -q "Failed" "$LOGFILE"; then
ERROR_MSG=$(tail -n 20 "$LOGFILE")
WEBHOOK_URL="https://qyapi.weixin.qq.com/cgi-bin/webhook/send?key=YOUR_KEY"
curl -s -X POST "$WEBHOOK_URL" \
-H 'Content-Type: application/json' \
-d "{
\"msgtype\": \"markdown\",
\"markdown\": {
\"content\": \"## ⚠️ SSL 证书续期失败告警\n> 时间: $(date)\n> 详情:\n\`\`\`\n${ERROR_MSG}\n\`\`\`\"
}
}"
fi
9.8 高级钩子脚本模板
通用钩子管理脚本
#!/bin/bash
# /usr/local/bin/certbot-hook-manager.sh
# 描述:通用的 Certbot 钩子管理脚本
# 用法: certbot-hook-manager.sh [pre|post|deploy]
ACTION="$1"
DOMAINS="${RENEWED_DOMAINS:-unknown}"
LINEAGE="${RENEWED_LINEAGE:-}"
LOGFILE="/var/log/certbot-hooks.log"
NOTIFY_WEBHOOK="${CERTBOT_NOTIFY_WEBHOOK:-}"
log() {
echo "[$(date '+%Y-%m-%d %H:%M:%S')] [$ACTION] $1" >> "$LOGFILE"
}
notify() {
local message="$1"
if [ -n "$NOTIFY_WEBHOOK" ]; then
curl -s -X POST "$NOTIFY_WEBHOOK" \
-H 'Content-Type: application/json' \
-d "{\"msgtype\": \"text\", \"text\": {\"content\": \"$message\"}}" \
> /dev/null 2>&1
fi
}
case "$ACTION" in
pre)
log "Pre-hook triggered for: ${DOMAINS}"
# 停止占用 80 端口的服务
if systemctl is-active --quiet nginx; then
systemctl stop nginx
log "Nginx stopped"
fi
;;
post)
log "Post-hook triggered for: ${DOMAINS}"
# 启动之前停止的服务
if ! systemctl is-active --quiet nginx; then
systemctl start nginx
log "Nginx started"
fi
;;
deploy)
log "Deploy-hook triggered for: ${DOMAINS}"
log "Certificate path: ${LINEAGE}"
# 重载 Web 服务器
if systemctl is-active --quiet nginx; then
systemctl reload nginx
log "Nginx reloaded"
fi
# 发送通知
notify "SSL 证书续期成功: ${DOMAINS}"
;;
*)
echo "Usage: $0 [pre|post|deploy]"
exit 1
;;
esac
exit 0
# 设置权限
sudo chmod +x /usr/local/bin/certbot-hook-manager.sh
# 在续期配置中使用
sudo certbot renew \
--pre-hook "/usr/local/bin/certbot-hook-manager.sh pre" \
--post-hook "/usr/local/bin/certbot-hook-manager.sh post" \
--deploy-hook "/usr/local/bin/certbot-hook-manager.sh deploy"
9.9 钩子最佳实践
- 幂等性: 钩子脚本应支持多次执行而不产生副作用
- 错误处理: 使用
set -e或检查命令返回值 - 日志记录: 所有操作都应记录到日志文件
- 权限最小化: 钩子脚本仅需 root 权限执行,避免设置不必要的权限
- 测试验证: 在 staging 环境中完整测试钩子脚本
- 超时控制: 避免钩子脚本长时间执行,设置合理的超时
- Deploy Hook 优先: 能用 deploy hook 就不用 post hook(仅续期成功才执行)