1. 下载软件:
2. 安装acme.sh
默认安装在C:\Users\Administrator\acme.sh,位置就不要移动了(我移动到D:\acme.sh过,但一执行powershell又跑到C:\Users\Administrator\acme.sh目录里了)。
3. 阿里云工具:
#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
}