强曰为道
与天地相似,故不违。知周乎万物,而道济天下,故不过。旁行而不流,乐天知命,故不忧.
文档目录

Certbot 证书自动化教程 / 第 9 章:续期钩子

第 9 章:续期钩子

9.1 钩子概述

续期钩子(Renewal Hooks)是 Certbot 在证书续期流程的各个阶段执行的自定义脚本。通过钩子,可以实现服务重启、日志记录、通知发送、配置分发等自动化操作。

钩子执行流程

certbot renew
     │
     ▼
┌────────────────┐
│   pre-hook     │  续期尝试前执行(每次运行都执行)
│  停止服务等     │
└───────┬────────┘
        │
        ▼
┌────────────────┐
│  尝试续期       │  检查证书是否需要续期
│                │
└───────┬────────┘
        │
   ┌────┴────┐
   │ 续期成功?│
   └────┬────┘
    是  │   否
   ┌────┴──┐  │
   ▼       │  │
┌────────┐  │  │
│deploy  │  │  │  仅续期成功后执行
│ hook   │  │  │  重载服务/通知等
└───┬────┘  │  │
    │       │  │
    ▼       ▼  ▼
┌────────────────┐
│   post-hook    │  续期尝试后执行(每次运行都执行)
│  启动服务等     │
└────────────────┘

钩子类型汇总

钩子CLI 参数配置文件键执行条件典型用途
Pre-hook--pre-hookpre_hook每次续期尝试前停止占用 80 端口的服务
Post-hook--post-hookpost_hook每次续期尝试后启动之前停止的服务
Deploy-hook--deploy-hookdeploy仅续期成功后重载 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 钩子最佳实践

  1. 幂等性: 钩子脚本应支持多次执行而不产生副作用
  2. 错误处理: 使用 set -e 或检查命令返回值
  3. 日志记录: 所有操作都应记录到日志文件
  4. 权限最小化: 钩子脚本仅需 root 权限执行,避免设置不必要的权限
  5. 测试验证: 在 staging 环境中完整测试钩子脚本
  6. 超时控制: 避免钩子脚本长时间执行,设置合理的超时
  7. Deploy Hook 优先: 能用 deploy hook 就不用 post hook(仅续期成功才执行)

扩展阅读