网站开发acme.sh自动注册SSL并同步到阿里云CDN|Windows PowerShell脚本

生活日记 工作心得
16
发表时间: 编辑:顾永胜来源:0513.city标签:原创

1. 下载软件:

https://mirrors.huaweicloud.com/git-for-windows/v2.55.0.windows.4/Git-2.55.0.4-64-bit.exe

双击安装Git,选择安装盘符,我安装在D:/git文件夹中,一路Next。

2. 安装acme.sh

安装完成在系统菜单中,选择Git Bash

右键粘入git clone https://gitee.com/neilpang/acme.sh.git

默认安装在C:\Users\Administrator\acme.sh,位置就不要移动了(我移动到D:\acme.sh过,但一执行powershell又跑到C:\Users\Administrator\acme.sh目录里了)。

Git Bash中输入:

cd /d/acme.sh

./acme.sh --install -m 邮箱地址

acme.sh默认会安装一个定时任务acme.sh.cron,用于更新证书,我这边要用自己的,所以原来定时任务删除。

按 Win + R,输入 taskschd.msc,回车,找到任务名称acme.sh.cron的任务,右键 → 删除。

安装完成之后,文件路径为C:\Users\Administrator\.acme.sh(比源文件夹名称多了一个.)

最好还是将acme.sh更新到最新版(使最新版可以避免参数不准确的问题):

powershell进入到C:\Users\Administrator\.acme.sh目录,执行:

./acme.sh --upgrade

最新版的默认证书是ZeroSSL,不是Let's Encrypt。

3. 阿里云工具:

https://aliyuncli.alicdn.com/aliyun-cli-windows-latest-amd64.zip

打开压缩包,是一个aliyun.exe文件。解压到D:\aliyun-cli\aliyun.exe。

4.PowerShell配置华为云和阿里云登录环境变量:

[System.Environment]::SetEnvironmentVariable('HUAWEICLOUD_Username','IAM用户名','User')
[System.Environment]::SetEnvironmentVariable('HUAWEICLOUD_Password','IAM密码','User')
[System.Environment]::SetEnvironmentVariable('HUAWEICLOUD_DomainName','华为云帐号名','User')
[System.Environment]::SetEnvironmentVariable('ALIBABA_CLOUD_ACCESS_KEY_ID','阿里云AK','User')
[System.Environment]::SetEnvironmentVariable('ALIBABA_CLOUD_ACCESS_KEY_SECRET','阿里云SK','User')

华为云还有一个参数$HuaweiCloudRegion,要根据自己的区域选择,我的参数是:cn-east-3,表示华东-上海一。

华为云账号设置:https://0513.city/thought/692.html

阿里云账号设置:https://0513.city/thought/693.html

5.写一个自动注册PowerShell,代码如下(AI生成代码,根据自己情况替换参数),保存为autossl.ps1。

6.使用定时任务,每天03:00,执行autossl.ps1就可以了。

#Requires -Version 5.1
#Requires -RunAsAdministrator
<#
.SYNOPSIS
    Let's Encrypt 通配符证书自动续期 + 阿里云 CDN 同步脚本(安全替换版)
.DESCRIPTION
    1. 基于华为云 DNS-01 验证(官方 dns_huaweicloud 插件,用户名密码方式)
    2. 部署到本地 Nginx
    3. 同步新证书到阿里云 CDN(支持多域名批量更新)
    4. 【安全改进】新证书确认生成后才删除旧证书,避免证书断档
.NOTES
    版本: 3.7
    用法:
        1. 设置环境变量(见下方【密钥配置】)
        2. 修改【用户配置】区域
        3. 添加到 Windows 任务计划程序,建议每天 03:00 执行
#>
# ==========================【密钥配置】==========================
# 安全要求:所有密钥严禁硬编码!请通过环境变量配置:
#
# 华为云(用于 acme.sh DNS-01 验证,官方插件要求用户名密码方式):
#   [System.Environment]::SetEnvironmentVariable('HUAWEICLOUD_Username',   'IAM用户名', 'User')
#   [System.Environment]::SetEnvironmentVariable('HUAWEICLOUD_Password',   'IAM密码',   'User')
#   [System.Environment]::SetEnvironmentVariable('HUAWEICLOUD_DomainName', '华为云帐号名', 'User')
#
# 阿里云(用于 CDN 证书同步):
#   [System.Environment]::SetEnvironmentVariable('ALIBABA_CLOUD_ACCESS_KEY_ID',     '阿里云AK', 'User')
#   [System.Environment]::SetEnvironmentVariable('ALIBABA_CLOUD_ACCESS_KEY_SECRET', '阿里云SK', 'User')
#
# 设置后需要重启 PowerShell 或重新登录生效。
# ================================================================
# ==========================【用户配置】==========================
# --- 域名与证书 ---
$Domain          = '0513.city'           # 主域名
$WildcardDomain  = '*.0513.city'         # 通配符域名
$CertOutDir      = 'D:\sslcert'         # 本地证书输出目录
# --- Nginx ---
$NginxExe        = 'D:\nginx\nginx.exe' # Nginx 可执行文件
$NginxConfigTest = $true                 # 续期前是否先测试 nginx -t
# --- acme.sh ---
$AcmeShRoot      = 'C:\Users\Administrator\.acme.sh'  # acme.sh 实际工作目录(从日志确认)
$GitBashExe      = 'D:\git\bin\bash.exe'  # Git Bash
$AcmeEmail       = '261352352@qq.com'        # Let's Encrypt 注册邮箱
# --- 华为云区域配置(关键!官方插件默认是新加坡 ap-southeast-1,国内账号必须改)---
# 可选值(根据你的华为云 DNS 实际区域填写):
#   cn-north-4    华北-北京四(最常见)
#   cn-north-1    华北-北京一
#   cn-east-3     华东-上海一
#   cn-south-1    华南-广州
#   ap-southeast-1  新加坡(默认,国内账号不要用)
$HuaweiCloudRegion = 'cn-east-3'
# --- 阿里云 CDN 同步配置 ---
$EnableAliyunCdnSync = $true             # 是否启用阿里云 CDN 同步
$AliyunCliExe        = 'D:\aliyun-cli\aliyun.exe'  # aliyun-cli 路径
$AliyunRegion        = 'cn-hangzhou'     # CDN API 区域(固定 cn-hangzhou)
# 需要同步证书的 CDN 加速域名列表(可填多个)
$AliyunCdnDomains = @(
    'www.0513.city'
    # 'cdn.0513.city'    # 如有更多域名,继续添加
)
# --- 日志 ---
$LogDir          = 'D:\acme.sh\logs'
$LogFile         = Join-Path $LogDir 'ssl_renew.log'
$LogKeepDays     = 30
# --- 触发阈值(天)---
$ExpireThreshold = 15
# --- 加锁 ---
$LockFile        = Join-Path $env:TEMP 'ssl_renew_0513city.lock'
$MaxLockAgeSec   = 600    # 锁文件最大存活秒数,防止死锁
# --- 通知(可选)---
$EnableNotify    = $false
$NotifyOnFail    = $true
$NotifyOnSuccess = $false
$NotifyType      = 'webhook'   # 可选: webhook / email / none
# Webhook 配置
$WebhookUrl      = 'https://qyapi.weixin.qq.com/cgi-bin/webhook/send?key=YOUR_KEY'
# 邮件配置
$SmtpServer      = 'smtp.example.com'
$SmtpPort        = 587
$SmtpFrom        = 'alert@example.com'
$SmtpTo          = 'admin@example.com'
$SmtpUser        = 'alert@example.com'
$SmtpPass        = $env:SMTP_PASSWORD
# --- 测试模式(临时使用)---
# 设为 $true 时:跳过证书有效期检查和 acme.sh 申请,直接用 D:\sslcert 现有证书测试 Nginx 重载 + CDN 同步
$TestCdnOnly = $false
# ================================================================
# -------------------------- 函数定义 --------------------------
function Write-Log {
    param(
        [Parameter(Mandatory)]
        [string]$Message,
        [ValidateSet('INFO','WARN','ERROR','SUCCESS')]
        [string]$Level = 'INFO'
    )
    $timeStr = Get-Date -Format 'yyyy-MM-dd HH:mm:ss'
    $line = "[$timeStr] [$Level$Message"
    try {
        Add-Content -Path $LogFile -Value $line -Encoding UTF8 -ErrorAction Stop
    } catch {
        Write-Warning "写入日志失败: $_"
    }
    switch ($Level) {
        'ERROR'   { Write-Host $line -ForegroundColor Red }
        'WARN'    { Write-Host $line -ForegroundColor Yellow }
        'SUCCESS' { Write-Host $line -ForegroundColor Green }
        default   { Write-Host $line }
    }
}
function Clear-OldLog {
    if (-not (Test-Path $LogFile)) { return }
    try {
        $cutoff = (Get-Date).AddDays(-$LogKeepDays)
        $newContent = [System.Collections.Generic.List[string]]::new()
        foreach ($row in Get-Content $LogFile -Encoding UTF8) {
            if ($row -match '^\[(\d{4}-\d{2}-\d{2} \d{2}:\d{2}:\d{2})\]') {
                try {
                    $logTime = [DateTime]::ParseExact($matches[1], 'yyyy-MM-dd HH:mm:ss'$null)
                    if ($logTime -ge $cutoff) {
                        $newContent.Add($row)
                    }
                } catch {
                    $newContent.Add($row)
                }
            } else {
                $newContent.Add($row)
            }
        }
        $newContent | Set-Content $LogFile -Encoding UTF8
        Write-Log "日志清理完成,保留最近 $LogKeepDays 天" -Level INFO
    } catch {
        Write-Log "日志清理异常: $($_.Exception.Message)" -Level WARN
    }
}
function Send-Notification {
    param(
        [string]$Title,
        [string]$Body,
        [ValidateSet('INFO','ERROR')]
        [string]$Severity = 'INFO'
    )
    if (-not $EnableNotify) { return }
    if ($Severity -eq 'INFO' -and -not $NotifyOnSuccess) { return }
    if ($Severity -eq 'ERROR' -and -not $NotifyOnFail) { return }
    try {
        switch ($NotifyType) {
            'webhook' {
                $payload = @{
                    msgtype = 'text'
                    text    = @content = "$Title`n$Body" }
                } | ConvertTo-Json -Compress
                Invoke-RestMethod -Uri $WebhookUrl -Method Post -ContentType 'application/json' -Body $payload -TimeoutSec 15 | Out-Null
                Write-Log 'Webhook 通知已发送' -Level INFO
            }
            'email' {
                $securePass = ConvertTo-SecureString $SmtpPass -AsPlainText -Force
                $cred = New-Object System.Management.Automation.PSCredential ($SmtpUser$securePass)
                Send-MailMessage -SmtpServer $SmtpServer -Port $SmtpPort -UseSsl `
                    -From $SmtpFrom -To $SmtpTo `
                    -Subject $Title -Body $Body `
                    -Credential $cred -Encoding UTF8
                Write-Log '邮件通知已发送' -Level INFO
            }
        }
    } catch {
        Write-Log "通知发送失败: $($_.Exception.Message)" -Level WARN
    }
}
function Acquire-Lock {
    if (Test-Path $LockFile) {
        $lockItem = Get-Item $LockFile
        $age = ([DateTime]::Now - $lockItem.LastWriteTime).TotalSeconds
        if ($age -gt $MaxLockAgeSec) {
            Write-Log "发现过期锁文件(${age}秒),强制移除" -Level WARN
            Remove-Item $LockFile -Force
        } else {
            Write-Log "脚本正在运行中(锁文件存在,已运行 ${age}秒),本次退出" -Level WARN
            exit 0
        }
    }
    New-Item -ItemType File -Path $LockFile -Force | Out-Null
    Write-Log '已获取执行锁' -Level INFO
}
function Release-Lock {
    if (Test-Path $LockFile) {
        Remove-Item $LockFile -Force
        Write-Log '已释放执行锁' -Level INFO
    }
}
function Convert-ToBashPath {
    param([string]$WinPath)
    return ($WinPath -replace '\\''/' -replace '^([A-Za-z]):''/$1').ToLower()
}
function Sync-AliyunCdnCertificate {
    param(
        [Parameter(Mandatory)]
        [string]$CertPubPath,
        [Parameter(Mandatory)]
        [string]$CertPriPath
    )
    if (-not $EnableAliyunCdnSync) {
        Write-Log '阿里云 CDN 同步已禁用,跳过' -Level INFO
        return $true
    }
    if (-not (Test-Path $AliyunCliExe)) {
        Write-Log "aliyun-cli 未找到: $AliyunCliExe,跳过 CDN 同步" -Level WARN
        return $false
    }
    try {
        $sslPub = Get-Content -Path $CertPubPath -Raw -Encoding UTF8
        $sslPri = Get-Content -Path $CertPriPath -Raw -Encoding UTF8
    } catch {
        Write-Log "读取证书文件失败: $($_.Exception.Message)" -Level ERROR
        return $false
    }
    $certName = "$($Domain -replace '\.''-')-$(Get-Date -Format 'yyyyMMdd-HHmmss')"
    $allSuccess = $true
    foreach ($cdnDomain in $AliyunCdnDomains) {
        Write-Log "正在同步证书到阿里云 CDN 域名: $cdnDomain ..." -Level INFO
        $argList = @(
            'cdn''SetCdnDomainSSLCertificate'
            '--region'$AliyunRegion
            '--DomainName'$cdnDomain
            '--CertName'$certName
            '--CertType''upload'
            '--SSLProtocol''on'
            '--SSLPub'$sslPub
            '--SSLPri'$sslPri
        )
        try {
            $output = & $AliyunCliExe @argList 2>&1
            $cliExitCode = $LASTEXITCODE
            if ($cliExitCode -eq 0) {
                Write-Log "CDN 域名 $cdnDomain 证书同步成功" -Level SUCCESS
            } else {
                Write-Log "CDN 域名 $cdnDomain 证书同步失败,退出码: $cliExitCode" -Level ERROR
                Write-Log "输出: $output" -Level ERROR
                $allSuccess = $false
            }
        } catch {
            Write-Log "CDN 域名 $cdnDomain 同步异常: $($_.Exception.Message)" -LEVEL ERROR
            $allSuccess = $false
        }
    }
    return $allSuccess
}
# -------------------------- 主流程 --------------------------
if (-not (Test-Path $LogDir)) {
    New-Item -ItemType Directory -Path $LogDir -Force | Out-Null
}
Clear-OldLog
Write-Log '========== SSL 自动续期 + CDN 同步脚本启动 ==========' -Level INFO
# 1. 检查华为云用户名密码
$HuaweiUsername   = ($env:HUAWEICLOUD_Username).Trim()
$HuaweiPassword   = ($env:HUAWEICLOUD_Password).Trim()
$HuaweiDomainName = ($env:HUAWEICLOUD_DomainName).Trim()
if ([string]::IsNullOrWhiteSpace($HuaweiUsername) -or [string]::IsNullOrWhiteSpace($HuaweiPassword) -or [string]::IsNullOrWhiteSpace($HuaweiDomainName)) {
    $err = '错误:未从环境变量读取到 HUAWEICLOUD_Username / HUAWEICLOUD_Password / HUAWEICLOUD_DomainName。请按脚本顶部【密钥配置】说明设置后再执行。'
    Write-Log $err -Level ERROR
    Send-Notification -Title 'SSL 续期失败' -Body $err -Severity ERROR
    exit 1
}
Write-Log '已从环境变量读取华为云 IAM 认证信息' -Level INFO
# 1.5 检查阿里云 AK/SK
if ($EnableAliyunCdnSync) {
    $AliyunAK = $env:ALIBABA_CLOUD_ACCESS_KEY_ID
    $AliyunSK = $env:ALIBABA_CLOUD_ACCESS_KEY_SECRET
    if ([string]::IsNullOrWhiteSpace($AliyunAK) -or [string]::IsNullOrWhiteSpace($AliyunSK)) {
        $err = '错误:已启用阿里云 CDN 同步,但未从环境变量读取到 ALIBABA_CLOUD_ACCESS_KEY_ID 或 ALIBABA_CLOUD_ACCESS_KEY_SECRET。'
        Write-Log $err -Level ERROR
        Send-Notification -Title 'SSL 续期失败' -Body $err -Severity ERROR
        exit 1
    }
    Write-Log '已从环境变量读取阿里云 AK/SK' -Level INFO
}
# 2. 加锁
Acquire-Lock
try {
    # 3. 前置检查
    if (-not (Test-Path $GitBashExe)) {
        throw "Git Bash 未找到: $GitBashExe"
    }
    $acmeShScript = Join-Path $AcmeShRoot 'acme.sh'
    if (-not (Test-Path $acmeShScript)) {
        throw "acme.sh 脚本未找到: $acmeShScript"
    }
    if (-not (Test-Path $NginxExe)) {
        throw "Nginx 可执行文件未找到: $NginxExe"
    }
    # 4. 检查现有证书
    $crtFile = Join-Path $CertOutDir 'fullchain.pem'
    $keyFile = Join-Path $CertOutDir 'privkey.pem'
    $needRenew = $false
    if (-not (Test-Path $CertOutDir)) {
        New-Item -ItemType Directory -Path $CertOutDir -Force | Out-Null
        Write-Log "证书目录不存在,已创建 $CertOutDir" -Level INFO
    }
    if (Test-Path $crtFile) {
        try {
            $certRaw = Get-Content $crtFile -Raw -Encoding ASCII
            $certBytes = [System.Text.Encoding]::ASCII.GetBytes($certRaw)
            $certObj = New-Object System.Security.Cryptography.X509Certificates.X509Certificate2
            $certObj.Import($certBytes)
            $expireDate = $certObj.NotAfter
            $remainDays = ($expireDate - (Get-Date)).TotalDays
            Write-Log "现有证书到期: $expireDate,剩余 $([Math]::Round($remainDays,2)) 天" -Level INFO
            if ($remainDays -le $ExpireThreshold) {
                Write-Log "证书剩余有效期 <= $ExpireThreshold 天,准备续期" -Level WARN
                $needRenew = $true
            } elseif (-not $TestCdnOnly) {
                Write-Log '证书有效期充足,无需更新,脚本退出' -Level SUCCESS
                exit 0
            } else {
                Write-Log '测试模式:证书有效期充足,但强制继续执行后续步骤' -Level WARN
                $needRenew = $false
            }
        } catch {
            Write-Log "读取证书异常,可能已损坏,将重新申请。错误: $($_.Exception.Message)" -Level WARN
            $needRenew = $true
        }
    } else {
        Write-Log '证书文件不存在,需要申请新证书' -Level WARN
        $needRenew = $true
    }
    if (-not $needRenew -and -not $TestCdnOnly) {
        exit 0
    }
    # 5. Nginx 配置预检(带工作目录切换)
    if ($NginxConfigTest) {
        Write-Log '执行 nginx -t 配置预检...' -Level INFO
        $nginxDir = Split-Path $NginxExe -Parent
        $originalPath = Get-Location
        Set-Location $nginxDir
        & $NginxExe -t
        $nginxTestExitCode = $LASTEXITCODE
        Set-Location $originalPath
        if ($nginxTestExitCode -ne 0) {
            throw "Nginx 配置测试失败(exit code $nginxTestExitCode),请先修复配置再继续"
        }
        Write-Log 'Nginx 配置测试通过' -Level SUCCESS
    }
    # 6. 证书申请(测试模式跳过)
    if (-not $TestCdnOnly) {
        # 调用 acme.sh 申请证书(官方 dns_huaweicloud 插件,用户名密码方式,新版支持 HUAWEICLOUD_Region)
    # 【安全改进】先申请到 acme.sh 自己的工作目录,确认成功后再替换旧证书
    $bashDir      = Convert-ToBashPath (Split-Path $AcmeShRoot -Parent)
    $bashAcmeSh   = Convert-ToBashPath $acmeShScript
    $cmdParts = @(
        "cd '$bashDir'"
        "&& export HUAWEICLOUD_Username='$($HuaweiUsername)'"
        "&& export HUAWEICLOUD_Password='$($HuaweiPassword)'"
        "&& export HUAWEICLOUD_DomainName='$($HuaweiDomainName)'"
        "&& export HUAWEICLOUD_Region='$($HuaweiCloudRegion)'"
        "&& '$bashAcmeSh' --issue --dns dns_huaweicloud --force"
        "-d '$Domain'"
        "-d '$WildcardDomain'"
    )
    if ($AcmeEmail -and $AcmeEmail -notmatch 'your-email') {
        $cmdParts += "--accountemail '$AcmeEmail'"
    }
    $bashCmd = $cmdParts -join ' '
    Write-Log "开始执行 acme.sh 申请证书..." -Level INFO
    & $GitBashExe -c $bashCmd
    $acmeExitCode = $LASTEXITCODE
    if ($acmeExitCode -ne 0) {
        throw "acme.sh 执行失败,退出码: $acmeExitCode。旧证书保留未动,请检查日志确认失败原因(DNS 解析、IAM 权限、API 限流等)。"
    }
    Write-Log 'acme.sh 执行成功' -Level SUCCESS
    # 7. 【安全改进】确认新证书已生成,然后再删除旧证书
    $acmeCrt = Join-Path $AcmeShRoot "$Domain`_ecc\fullchain.cer"
    $acmeKey = Join-Path $AcmeShRoot "$Domain`_ecc\$Domain.key"
    if (-not (Test-Path $acmeCrt)) {
        throw "新证书文件未生成: $acmeCrt。旧证书保留未动,请检查 acme.sh 日志。"
    }
    if (-not (Test-Path $acmeKey)) {
        throw "新私钥文件未生成: $acmeKey。旧证书保留未动,请检查 acme.sh 日志。"
    }
    Write-Log "新证书已确认存在: $acmeCrt$acmeKey" -Level SUCCESS
    # 确认新证书有效后再删除旧的
    if (Test-Path $crtFile) { Remove-Item $crtFile -Force }
    if (Test-Path $keyFile) { Remove-Item $keyFile -Force }
    Write-Log '旧证书已安全删除' -Level INFO
    # 8. 复制新证书到目标目录
    Copy-Item -Path $acmeCrt -Destination $crtFile -Force
    Copy-Item -Path $acmeKey -Destination $keyFile -Force
    Write-Log "新证书已复制到 $CertOutDir" -Level SUCCESS
    } else {
        Write-Log '测试模式:跳过证书申请和复制,直接使用 D:\sslcert 现有证书' -Level INFO
        if (-not (Test-Path $crtFile) -or -not (Test-Path $keyFile)) {
            throw "测试模式:未找到现有证书文件($crtFile / $keyFile),无法继续"
        }
    }
    # 9. 重载 Nginx(带工作目录切换)
    Write-Log '执行 nginx -s reload...' -Level INFO
    $nginxDir = Split-Path $NginxExe -Parent
    $originalPath = Get-Location
    Set-Location $nginxDir
    & $NginxExe -s reload
    $nginxReloadExitCode = $LASTEXITCODE
    Set-Location $originalPath
    if ($nginxReloadExitCode -ne 0) {
        throw "Nginx reload 失败(exit code $nginxReloadExitCode)"
    }
    Write-Log 'Nginx reload 成功' -Level SUCCESS
    # 10. 同步证书到阿里云 CDN
    if ($EnableAliyunCdnSync) {
        Write-Log '开始同步证书到阿里云 CDN...' -Level INFO
        $cdnSyncResult = Sync-AliyunCdnCertificate -CertPubPath $crtFile -CertPriPath $keyFile
        if (-not $cdnSyncResult) {
            Write-Log '阿里云 CDN 证书同步部分失败,请检查日志' -Level WARN
            Send-Notification -Title 'SSL 续期成功,但 CDN 同步失败' -Body "域名: $Domain`nNginx 已重载,但阿里云 CDN 证书同步出现问题,请检查日志。" -Severity ERROR
        } else {
            Write-Log '阿里云 CDN 证书同步全部完成' -Level SUCCESS
        }
    }
    Write-Log '本次续期任务全部完成' -Level SUCCESS
    Send-Notification -Title 'SSL 续期成功' -Body "域名: $Domain / $WildcardDomain`n时间: $(Get-Date -Format 'yyyy-MM-dd HH:mm:ss')`nCDN 同步: $(if($EnableAliyunCdnSync){'已执行'}else{'已跳过'})" -Severity INFO
catch {
    $errMsg = $_.Exception.Message
    Write-Log "脚本异常终止: $errMsg" -Level ERROR
    Send-Notification -Title 'SSL 续期失败' -Body "域名: $Domain`n错误: $errMsg`n时间: $(Get-Date -Format 'yyyy-MM-dd HH:mm:ss')" -Severity ERROR
    exit 1
finally {
    Release-Lock
    Write-Log '========== SSL 自动续期 + CDN 同步脚本结束 ==========' -Level INFO
}