Ubuntu24.04自动更新SSL证书并重启Apache

Title Ubuntu24.04自动更新SSL证书并重启Apache
Framework Ubuntu
User wy8817399@vip.qq.com
Id 92
Created 8/21/26, 9:36 PM
Modified 8/21/26, 9:36 PM
Published Yes
Content

1、获取阿里云的AccessKey

2、当前的ssl证书存放在/etc/ssl/apache路径下

3、将下面的脚本放在某个目录下,命名为ssl_auto_renew.sh,新的服务器用./ssl_auto_renew.sh setup,如果已经有配置过了,则只需要将新加的域名添加到DOMAINS里面,新增一行即可。

#!/bin/bash
#=============================================================================
# SSL证书自动检测与续期脚本 (Apache2版, 支持任意多个域名)
# 环境: Ubuntu 24.04 + Apache 2.4.58 + 阿里云DNS + acme.sh + Let's Encrypt
#
# 功能:
#   1. 检测所有SSL证书的到期时间
#   2. 临近到期(默认30天)自动通过acme.sh + DNS-01申请免费证书
#   3. 自动部署到对应目录并重载Apache2
#   4. 完整日志记录 + 可选钉钉/邮件通知
#
# 用法:
#   ./ssl_auto_renew.sh setup     # 首次安装配置(安装acme.sh+签发证书+配置cron)
#   ./ssl_auto_renew.sh check     # 检查并续期(cron定时调用)
#   ./ssl_auto_renew.sh status    # 查看所有证书状态
#   ./ssl_auto_renew.sh force     # 强制续期所有证书
#
# 新增域名: 在 DOMAINS 数组中加一行即可, 证书可放在任意目录
#=============================================================================

set -o pipefail

# ========================== 配置区域(请修改) ==========================

# 阿里云AccessKey (需要AliyunDNSFullAccess权限)
# 注意: 要求 wangyan.asia 和 timeless.wang 两个域名的DNS解析都托管在阿里云
#       (timeless.wang 在Nginx服务器上已用阿里云DNS验证成功, 一般无需改动)
# 获取: 阿里云控制台 -> 右上角头像 -> AccessKey管理 -> 创建AccessKey
ALI_KEY=""
ALI_SECRET=""

# 续期阈值(距到期多少天开始自动续期)
RENEW_DAYS=3

# 日志文件
LOG_FILE="/var/log/ssl_renew.log"

# ACME CA: letsencrypt 或 zerossl
ACME_CA="letsencrypt"

# 通知配置(可选, 留空则不通知)
# 钉钉/企业微信机器人webhook
WEBHOOK_URL=""
# 通知邮箱
NOTIFY_EMAIL="wy8817399@vip.qq.com"

# 域名配置(支持任意多个域名, 新增域名加一行即可)
# 格式: "主域名|附加SAN域名(逗号分隔,可空)|证书目录(绝对路径)|pem文件名|key文件名"
# 注意: 文件名/路径与Apache配置中 SSLCertificateFile / SSLCertificateKeyFile 保持一致
DOMAINS=(
  "timelesscake.wangyan.asia|wangyan.asia|/etc/ssl/apache/timelesscake|timelesscake.wangyan.asia.pem|timelesscake.wangyan.asia.key"
)
# 示例: 新增一个完全不同根域名、不同目录的证书, 直接加一行:
#   "blog.example.com||/etc/ssl/apache/blog|blog.example.com.pem|blog.example.com.key"

# ========================== 配置结束(以下无需修改) ==========================

# ACME账户邮箱: 优先使用用户填写的 NOTIFY_EMAIL; 若为空, 使用域名下的默认邮箱
ACME_EMAIL="${NOTIFY_EMAIL:-ssl@wangyan.asia}"

ACME_SH="$HOME/.acme.sh/acme.sh"
RED='\033[0;31m'
GREEN='\033[0;32m'
YELLOW='\033[1;33m'
BLUE='\033[0;34m'
NC='\033[0m'

# --- 日志函数 ---
log()     { echo "[$(date '+%Y-%m-%d %H:%M:%S')] $1" | tee -a "$LOG_FILE"; }
info()    { echo -e "${BLUE}[INFO]${NC} $1" | tee -a "$LOG_FILE"; }
success() { echo -e "${GREEN}[ OK ]${NC} $1" | tee -a "$LOG_FILE"; }
warn()    { echo -e "${YELLOW}[WARN]${NC} $1" | tee -a "$LOG_FILE"; }
error()   { echo -e "${RED}[FAIL]${NC} $1" | tee -a "$LOG_FILE"; }

# --- 通知函数 ---
notify() {
    local title="$1" content="$2"
    if [[ -n "$WEBHOOK_URL" ]]; then
        curl -s -X POST "$WEBHOOK_URL" \
            -H "Content-Type: application/json" \
            -d "{\"msgtype\":\"markdown\",\"markdown\":{\"title\":\"$title\",\"text\":\"**$title**\n\n$content\n\n$(date '+%Y-%m-%d %H:%M:%S')\"}}" \
            > /dev/null 2>&1 || true
    fi
    if [[ -n "$NOTIFY_EMAIL" ]]; then
        echo -e "$content" | mail -s "$title" "$NOTIFY_EMAIL" 2>/dev/null || true
    fi
}

# --- Apache配置检测与重载 ---
apache_test() {
    apache2ctl configtest 2>&1
    return ${PIPESTATUS[0]}
}

apache_reload() {
    if apache_test | tee -a "$LOG_FILE"; then
        systemctl reload apache2
        success "Apache2 已重载"
    else
        error "Apache2 配置测试失败, 请检查配置"
        return 1
    fi
}

# --- 前置检查 ---
check_root() {
    if [[ $EUID -ne 0 ]]; then
        error "请以root用户运行此脚本"
        exit 1
    fi
}

check_config() {
    if [[ -z "$ALI_KEY" || -z "$ALI_SECRET" ]]; then
        error "请先编辑本脚本, 填入 ALI_KEY 和 ALI_SECRET"
        echo ""
        echo "  获取方式:"
        echo "    1. 登录阿里云控制台 -> 右上角头像 -> AccessKey管理"
        echo "    2. 创建AccessKey (建议仅授予 AliyunDNSFullAccess 权限)"
        echo "    3. 将 AccessKey ID  填入 ALI_KEY"
        echo "       将 AccessKey Secret 填入 ALI_SECRET"
        exit 1
    fi
    # 检查apache2ctl是否存在
    if ! command -v apache2ctl >/dev/null 2>&1; then
        error "未找到 apache2ctl 命令, 请确认已安装 Apache2"
        exit 1
    fi
    # 如果用户填写了通知邮箱, 校验基本格式
    if [[ -n "$NOTIFY_EMAIL" && ! "$NOTIFY_EMAIL" =~ ^[^@]+@[^@]+\.[^@]+$ ]]; then
        error "通知邮箱 NOTIFY_EMAIL 格式无效: $NOTIFY_EMAIL"
        error "留空则使用默认 ACME 邮箱, 或填写形如 admin@example.com 的有效地址"
        exit 1
    fi
}

# --- 安装acme.sh ---
install_acme() {
    if [[ -f "$ACME_SH" ]]; then
        info "acme.sh 已安装"
    else
        info "正在安装 acme.sh (优先官方源, 失败则切换Gitee国内镜像) ..."
        # 方式1: 官方安装脚本(境外服务器可用)
        # 注意: get.acme.sh 只是引导脚本, 内部会 git clone github, 大陆服务器会无限挂起,
        #       必须用 timeout 强制限时, 超时后自动降级到Gitee镜像
        timeout 90 bash -c "curl -m 30 https://get.acme.sh | sh -s email='${ACME_EMAIL}'" >/dev/null 2>&1
        if [[ ! -f "$ACME_SH" ]]; then
            warn "官方源安装失败/超时(大陆服务器常见), 切换Gitee镜像 ..."
            # 清理官方源安装可能残留的半成品目录
            [[ -d "$HOME/.acme.sh" ]] && rm -rf "$HOME/.acme.sh"
            # 方式2: Gitee国内镜像安装
            local tmp_dir="/tmp/acme_sh_install_$$"
            if timeout 180 git clone --depth 1 https://gitee.com/neilpang/acme.sh.git "$tmp_dir" >/dev/null 2>&1; then
                (cd "$tmp_dir" && sh ./acme.sh --install -m "${ACME_EMAIL}") >>"$LOG_FILE" 2>&1
                rm -rf "$tmp_dir"
            fi
        fi
        if [[ ! -f "$ACME_SH" ]]; then
            error "acme.sh 安装失败(官方源和Gitee镜像均不可用)"
            error "请检查服务器网络后重试, 或参考: https://github.com/acmesh-official/acme.sh/wiki/Install-in-China"
            exit 1
        fi
        success "acme.sh 安装完成"
    fi
    # 设置默认CA
    "$ACME_SH" --set-default-ca --server "$ACME_CA" 2>/dev/null || true
    # 关闭自动升级(避免大陆服务器反复访问GitHub导致续期报错)
    # --auto-upgrade 是 --upgrade 的子参数, 不能单独使用
    "$ACME_SH" --upgrade --auto-upgrade 0 >/dev/null 2>&1 || true
}

# --- 确保ACME账户邮箱有效 ---
ensure_acme_account() {
    local conf="$HOME/.acme.sh/account.conf"
    if [[ -f "$conf" ]]; then
        # 修正 account.conf 中可能残留的无效邮箱
        sed -i "s/^SAVED_EMAIL=.*/SAVED_EMAIL='${ACME_EMAIL}'/" "$conf" 2>/dev/null || true
        sed -i "s/^ACCOUNT_EMAIL=.*/ACCOUNT_EMAIL='${ACME_EMAIL}'/" "$conf" 2>/dev/null || true
    fi
    info "确保 ACME 账户邮箱正确: ${ACME_EMAIL}"
    if ! "$ACME_SH" --register-account -m "$ACME_EMAIL" --server "$ACME_CA" 2>&1 | tee -a "$LOG_FILE"; then
        warn "ACME 账户注册/更新未成功, 将在签发时重试"
    fi
}

# --- 获取证书剩余天数(返回-1表示文件不存在) ---
get_days() {
    local cert_file="$1"
    if [[ ! -f "$cert_file" ]]; then
        echo -1
        return
    fi
    local expiry_date
    expiry_date=$(openssl x509 -enddate -noout -in "$cert_file" 2>/dev/null | cut -d= -f2)
    if [[ -z "$expiry_date" ]]; then
        echo -1
        return
    fi
    local expiry_epoch now_epoch
    expiry_epoch=$(date -d "$expiry_date" +%s 2>/dev/null)
    now_epoch=$(date +%s)
    echo $(( (expiry_epoch - now_epoch) / 86400 ))
}

# --- 签发/续期并部署证书 ---
# 参数: 主域名 附加SAN域名 证书目录(绝对路径) pem文件名 key文件名
deploy_cert() {
    local domain="$1" san="$2" cert_dir="$3" pem_name="$4" key_name="$5"
    local cert_file="$cert_dir/$pem_name"
    local key_file="$cert_dir/$key_name"

    # 构建域名参数
    local domain_args="-d $domain"
    if [[ -n "$san" ]]; then
        IFS=',' read -ra san_array <<< "$san"
        for s in "${san_array[@]}"; do
            domain_args="$domain_args -d $s"
        done
    fi

    # 设置阿里云DNS API密钥
    export Ali_Key="$ALI_KEY"
    export Ali_Secret="$ALI_SECRET"

    # 签发证书(--force确保每次都重新签发)
    info "正在签发证书: $domain ..."
    if "$ACME_SH" --issue --dns dns_ali $domain_args --force 2>&1 | tee -a "$LOG_FILE"; then
        success "证书签发成功: $domain"
    else
        error "证书签发失败: $domain"
        # 清理可能残留的半成品目录, 避免下次误判为已有证书
        rm -rf "$HOME/.acme.sh/${domain}" "$HOME/.acme.sh/${domain}_ecc"
        return 1
    fi

    # 确保证书目录存在
    mkdir -p "$cert_dir"

    # 备份现有证书(如有)
    if [[ -f "$cert_file" ]]; then
        cp -f "$cert_file" "${cert_file}.bak.$(date +%Y%m%d%H%M%S)"
        cp -f "$key_file" "${key_file}.bak.$(date +%Y%m%d%H%M%S)" 2>/dev/null || true
    fi

    # 安装证书到目标目录(acme.sh会记住安装位置, 后续renew时自动部署)
    # 注意: --reloadcmd 在批量部署时可能因其他域名尚未部署而configtest失败,
    #       但证书文件会先于reloadcmd写入, 因此以文件是否生成为准, reloadcmd失败仅警告
    info "部署证书: $domain -> $cert_dir/"
    local install_rc=0
    "$ACME_SH" --install-cert -d "$domain" \
        --key-file "$key_file" \
        --fullchain-file "$cert_file" \
        --reloadcmd "apache2ctl configtest && systemctl reload apache2" \
        2>&1 | tee -a "$LOG_FILE" || install_rc=1

    if [[ -f "$cert_file" && -f "$key_file" ]]; then
        chmod 644 "$cert_file"
        chmod 600 "$key_file"
        if [[ "$install_rc" -ne 0 ]]; then
            warn "证书文件已写入, 但 reloadcmd 失败(批量部署时正常, 将在最后统一重载): $domain"
        fi
        success "证书部署完成: $domain"
        return 0
    else
        error "证书部署失败, 文件未生成: $domain"
        return 1
    fi
}

# ========================== 主逻辑 ==========================

case "${1:-help}" in

# ---------- 首次安装配置 ----------
setup)
    check_root
    check_config
    mkdir -p "$(dirname "$LOG_FILE")"
    touch "$LOG_FILE"

    info "=========================================="
    info "  SSL证书自动续期(Apache2版) - 初始配置"
    info "=========================================="

    # 安装acme.sh
    install_acme

    # 确保ACME账户邮箱正确有效
    ensure_acme_account

    # 逐个签发并部署证书
    for line in "${DOMAINS[@]}"; do
        IFS='|' read -r d_main d_san d_dir d_pem d_key <<< "$line"
        deploy_cert "$d_main" "$d_san" "$d_dir" "$d_pem" "$d_key"
    done

    # 重载Apache2
    info "重载 Apache2 ..."
    apache_reload

    # 配置cron定时任务(每天凌晨3点检查)
    script_path=$(readlink -f "$0")
    cron_entry="0 3 * * * ${script_path} check >> ${LOG_FILE} 2>&1"
    if ! crontab -l 2>/dev/null | grep -qF "${script_path} check"; then
        (crontab -l 2>/dev/null; echo "$cron_entry") | crontab -
        success "已添加cron定时任务: 每天 03:00 自动检查证书"
    else
        info "cron定时任务已存在, 跳过"
    fi

    # 配置日志轮转
    cat > /etc/logrotate.d/ssl_renew <<LOGROTATE_EOF
${LOG_FILE} {
    weekly
    rotate 8
    compress
    missingok
    notifempty
    create 0644 root root
}
LOGROTATE_EOF
    success "已配置日志轮转: /etc/logrotate.d/ssl_renew"

    success "=========================================="
    success "  配置完成!"
    success "=========================================="
    echo ""
    echo "  常用命令:"
    echo "    $0 status   # 查看所有证书状态"
    echo "    $0 check    # 手动检查并续期"
    echo "    $0 force    # 强制续期所有证书"
    echo ""
    echo "  日志文件: $LOG_FILE"
    echo "  cron任务: 每天 03:00 自动检查"
    echo "  新增域名: 编辑 DOMAINS 数组加一行, 再执行 $0 check 即可"
    echo ""
    ;;

# ---------- 检查并续期(cron调用) ----------
check)
    check_root

    # 确保acme.sh已安装且ACME账户邮箱正确
    install_acme
    ensure_acme_account

    info "=========================================="
    info "  开始检查SSL证书 (共 ${#DOMAINS[@]} 个)"
    info "=========================================="

    failed_count=0
    renewed_count=0

    for line in "${DOMAINS[@]}"; do
        IFS='|' read -r d_main d_san d_dir d_pem d_key <<< "$line"
        cert_file="$d_dir/$d_pem"
        days_left=$(get_days "$cert_file")

        if [[ "$days_left" -eq -1 ]]; then
            warn "证书文件不存在, 开始签发: $d_main"
            if deploy_cert "$d_main" "$d_san" "$d_dir" "$d_pem" "$d_key"; then
                ((renewed_count++))
            else
                ((failed_count++))
            fi
        elif [[ "$days_left" -le 0 ]]; then
            warn "证书已过期(${days_left}天), 紧急续期: $d_main"
            if deploy_cert "$d_main" "$d_san" "$d_dir" "$d_pem" "$d_key"; then
                ((renewed_count++))
            else
                ((failed_count++))
            fi
        elif [[ "$days_left" -le "$RENEW_DAYS" ]]; then
            warn "证书即将到期(剩余${days_left}天), 开始续期: $d_main"
            if deploy_cert "$d_main" "$d_san" "$d_dir" "$d_pem" "$d_key"; then
                ((renewed_count++))
            else
                ((failed_count++))
            fi
        else
            success "证书正常(剩余${days_left}天): $d_main"
        fi
    done

    # 如有证书被更新, 统一重载Apache2一次(确保所有域名使用最新证书)
    if [[ "$renewed_count" -gt 0 ]]; then
        info "本次有证书更新, 统一重载 Apache2 ..."
        apache_reload
    fi

    # 运行acme.sh内置续期检查(作为双保险)
    if [[ -f "$ACME_SH" ]]; then
        info "运行acme.sh内置续期检查 ..."
        "$ACME_SH" --cron 2>&1 | tail -5 | tee -a "$LOG_FILE" || true
    fi

    # 汇总
    info "------------------------------------------"
    info "  检查完毕: 续期 ${renewed_count} 个, 失败 ${failed_count} 个"
    info "=========================================="

    if [[ "$failed_count" -gt 0 ]]; then
        error "有 ${failed_count} 个证书处理失败!"
        notify "SSL证书续期告警" "有 ${failed_count} 个证书续期失败, 请检查日志: ${LOG_FILE}"
    elif [[ "$renewed_count" -gt 0 ]]; then
        success "成功续期 ${renewed_count} 个证书"
        notify "SSL证书续期成功" "成功续期 ${renewed_count} 个证书"
    fi
    ;;

# ---------- 查看证书状态 ----------
status)
    echo "=============================================================================="
    echo "  SSL证书状态报告    $(date '+%Y-%m-%d %H:%M:%S')    共 ${#DOMAINS[@]} 个域名"
    echo "=============================================================================="
    printf "  %-40s %-8s %-28s %s\n" "域名" "剩余" "到期时间" "状态"
    echo "------------------------------------------------------------------------------"

    for line in "${DOMAINS[@]}"; do
        IFS='|' read -r d_main d_san d_dir d_pem d_key <<< "$line"
        cert_file="$d_dir/$d_pem"
        days_left=$(get_days "$cert_file")

        if [[ "$days_left" -eq -1 ]]; then
            printf "  %-40s %-8s %-28s %b\n" "$d_main" "N/A" "N/A" "${RED}不存在${NC}"
        else
            expiry_str=$(openssl x509 -enddate -noout -in "$cert_file" 2>/dev/null | cut -d= -f2)
            if [[ "$days_left" -le 0 ]]; then
                status_str="${RED}已过期${NC}"
            elif [[ "$days_left" -le "$RENEW_DAYS" ]]; then
                status_str="${YELLOW}需续期${NC}"
            else
                status_str="${GREEN}正常${NC}"
            fi
            printf "  %-40s %-8s %-28s %b\n" "$d_main" "${days_left}天" "$expiry_str" "$status_str"
        fi
    done

    echo "=============================================================================="
    echo "  续期阈值: ${RENEW_DAYS}天 | 日志: ${LOG_FILE}"
    echo "=============================================================================="
    ;;

# ---------- 强制续期所有证书 ----------
force)
    check_root
    info "=========================================="
    info "  强制续期所有证书 (共 ${#DOMAINS[@]} 个)"
    info "=========================================="

    for line in "${DOMAINS[@]}"; do
        IFS='|' read -r d_main d_san d_dir d_pem d_key <<< "$line"
        deploy_cert "$d_main" "$d_san" "$d_dir" "$d_pem" "$d_key"
    done

    info "重载 Apache2 ..."
    apache_reload

    success "=========================================="
    success "  强制续期完成!"
    success "=========================================="
    ;;

# ---------- 帮助 ----------
*)
    echo ""
    echo "  SSL证书自动检测与续期脚本 (Apache2版)"
    echo "  环境: Ubuntu 24.04 + Apache 2.4.58 + 阿里云DNS + acme.sh + Let's Encrypt"
    echo ""
    echo "  用法: $0 {setup|check|status|force}"
    echo ""
    echo "  setup    首次安装: 安装acme.sh + 签发所有证书 + 配置cron定时任务"
    echo "  check    检查证书有效期, 到期自动续期 (cron定时调用)"
    echo "  status   查看所有证书的当前状态(只读, 不做任何修改)"
    echo "  force    强制续期所有证书(忽略到期时间)"
    echo ""
    echo "  首次使用:"
    echo "    1. 编辑本脚本, 确认 ALI_KEY / ALI_SECRET, 且域名DNS托管在阿里云"
    echo "    2. 运行: $0 setup"
    echo "    3. 查看状态: $0 status"
    echo ""
    echo "  新增域名:"
    echo "    在 DOMAINS 数组中按格式加一行, 再执行 $0 check 即自动签发部署"
    echo "    格式: \"主域名|附加SAN域名|证书目录(绝对路径)|pem文件名|key文件名\""
    echo ""
    exit 1
    ;;
esac