(运维) IIS 下 Let’s Encrypt 免费泛域名 SSL 证书的自动部署与续期的实践
背景
主机是一台 Windows Server 2019 Datacenter (可以视为 Win10 1809),IIS 10.0
IIS 上运行了数个站点,包括一个 Gitea 的反向代理,一个微服务网关,数个管理平台,一个静态资源托管
本文即将使用的工具:WinACME:一款非常强大而且简单易用的工具。
因为在 Windows 的 IIS 中进行 HTTPS 托管,在笔者的多方比较下,WinACME 是最为简单的,它提供了多种自动部署方式,并且能安装计划任务自动延期。但是想要更加愉快的使用,必须进行一番调教。
有哪些问题
- 使用 WinACME 申请泛域名(Wildcard, 俗称野卡) 证书时,必须要用到 DNS 验证方式,WinACME 没有自动修改域名解析记录的任何适合国内使用的集成解决方案。
- 最近几版本支持 HTTP/2,SNI 的 IIS 可以在一个服务器上使用多个证书。
- 使用泛域名的野卡证书会出现多个网站共用一个证书的情况
- WinACME 的自动续期只负责续签证书以及自动注册到 IIS 当中,但不会更新网站中对旧证书的绑定。
WinACME 支持自写脚本扩展,所以我们可以通过写域名DNS自动验证脚本,以及正确的域名证书更新脚本来规避上述问题。
解决方案
由于笔者的域名使用的是阿里云(万网),因此可以使用阿里云的 API 来达到自动添加 DNS 验证 所需要的 TXT 记录。以下是 DNS 验证 自动脚本(PowerShell,可以直接拿去使用,参考自 Posh-ACME 的插件):
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 176 177 178 179 180 181 182 183 184 185 186 187 188 189 190 191 192 193 194 195 196 197 198 199 |
# Modified By iEdon (https://iedon.com) param( [string]$Task, [string]$DomainName, [string]$RecordName, [string]$TxtValue ) $AliKeyId = '阿里云API Key' $AliSecretInsecure = '阿里云 Secert' $script:WellKnownDirs = @{ LE_PROD = 'https://acme-v02.api.letsencrypt.org/directory'; LE_STAGE = 'https://acme-staging-v02.api.letsencrypt.org/directory'; } $script:HEADER_NONCE = 'Replay-Nonce' $script:USER_AGENT = "iEdon-Modified; PowerShell/$($PSVersionTable.PSVersion)" $script:COMMON_HEADERS = @{'Accept-Language'='en-us,en;q=0.5'} $script:UseBasic = @{} if ('UseBasicParsing' -in (Get-Command Invoke-WebRequest).Parameters.Keys) { $script:UseBasic.UseBasicParsing = $true } function Get-DateTimeOffsetNow { [CmdletBinding()] param() [System.DateTimeOffset]::Now } function Add-DnsTxtAliyun { [CmdletBinding(DefaultParameterSetName='Insecure')] param( [Parameter(Mandatory,Position=0)] [string]$RecordName, [Parameter(Mandatory,Position=1)] [string]$TxtValue, [Parameter(Mandatory,Position=2)] [string]$AliKeyId, [Parameter(ParameterSetName='Secure',Mandatory,Position=3)] [securestring]$AliSecret, [Parameter(ParameterSetName='Insecure',Mandatory,Position=3)] [string]$AliSecretInsecure, [Parameter(ValueFromRemainingArguments)] $ExtraParams ) if ('Insecure' -eq $PSCmdlet.ParameterSetName) { $AliSecret = ConvertTo-SecureString $AliSecretInsecure -AsPlainText -Force } try { $zoneName = Find-AliZone $RecordName $AliKeyId $AliSecret } catch { throw } Write-Debug "Found zone $zoneName" $recShort = $RecordName -ireplace [regex]::Escape(".$zoneName"), [string]::Empty if ($recShort -eq $RecordName) { $recShort = '@' } try { $queryParams = "DomainName=$zoneName","RRKeyWord=$recShort","ValueKeyWord=$TxtValue",'TypeKeyWord=TXT' $response = Invoke-AliRest DescribeDomainRecords $queryParams $AliKeyId $AliSecret } catch { throw } if ($response.TotalCount -gt 0) { Write-Debug "Record $RecordName already contains $TxtValue. Nothing to do." } else { Write-Verbose "Adding a TXT record for $RecordName with value $TxtValue" $queryParams = "DomainName=$zoneName","RR=$recShort","Value=$TxtValue",'Type=TXT' Invoke-AliRest AddDomainRecord $queryParams $AliKeyId $AliSecret | Out-Null } } function Remove-DnsTxtAliyun { [CmdletBinding(DefaultParameterSetName='Insecure')] param( [Parameter(Mandatory,Position=0)] [string]$RecordName, [Parameter(Mandatory,Position=1)] [string]$TxtValue, [Parameter(Mandatory,Position=2)] [string]$AliKeyId, [Parameter(ParameterSetName='Secure',Mandatory,Position=3)] [securestring]$AliSecret, [Parameter(ParameterSetName='Insecure',Mandatory,Position=3)] [string]$AliSecretInsecure, [Parameter(ValueFromRemainingArguments)] $ExtraParams ) if ('Insecure' -eq $PSCmdlet.ParameterSetName) { $AliSecret = ConvertTo-SecureString $AliSecretInsecure -AsPlainText -Force } try { $zoneName = Find-AliZone $RecordName $AliKeyId $AliSecret } catch { throw } Write-Debug "Found zone $zoneName" $recShort = $RecordName -ireplace [regex]::Escape(".$zoneName"), [string]::Empty if ($recShort -eq $RecordName) { $recShort = '@' } try { $queryParams = "DomainName=$zoneName","RRKeyWord=$recShort","ValueKeyWord=$TxtValue",'TypeKeyWord=TXT' $response = Invoke-AliRest DescribeDomainRecords $queryParams $AliKeyId $AliSecret } catch { throw } if ($response.TotalCount -gt 0) { Write-Verbose "Removing TXT record for $RecordName with value $TxtValue" $id = $response.DomainRecords.Record[0].RecordId Invoke-AliRest DeleteDomainRecord @("RecordId=$id") $AliKeyId $AliSecret | Out-Null } else { Write-Debug "Record $RecordName with value $TxtValue doesn't exist. Nothing to do." } } function Save-DnsTxtAliyun { [CmdletBinding()] param( [Parameter(ValueFromRemainingArguments)] $ExtraParams ) } function Invoke-AliRest { [CmdletBinding()] param( [Parameter(Mandatory,Position=0)] [string]$Action, [Parameter(Position=1)] [string[]]$ActionParams, [Parameter(Mandatory,Position=2)] [string]$AccessKeyId, [Parameter(Mandatory,Position=3)] [securestring]$AccessSecret ) $apiBase = 'https://alidns.aliyuncs.com' $allParams = $ActionParams + @( "AccessKeyId=$AccessKeyId", "Action=$Action", 'Format=json', 'SignatureMethod=HMAC-SHA1', "SignatureNonce=$((New-Guid).ToString())", 'SignatureVersion=1.0', "Timestamp=$((Get-DateTimeOffsetNow).UtcDateTime.ToString('yyyy-MM-ddTHH\%3Amm\%3AssZ'))", "Version=2015-01-09" ) | Sort-Object $strToSign = [uri]::EscapeDataString($allParams -join '&') $strToSign = "GET&%2F&$strToSign" Write-Debug $strToSign $stsBytes = [Text.Encoding]::UTF8.GetBytes($strToSign) $secPlain = (New-Object PSCredential "user",$AccessSecret).GetNetworkCredential().Password $secBytes = [Text.Encoding]::UTF8.GetBytes("$secPlain&") $hmac = New-Object Security.Cryptography.HMACSHA1($secBytes,$true) $sig = [Convert]::ToBase64String($hmac.ComputeHash($stsBytes)) $sigUrl = [uri]::EscapeDataString($sig) Write-Debug $sig $uri = "$apiBase/?$($allParams -join '&')&Signature=$sigUrl" Invoke-RestMethod $uri @script:UseBasic -EA Stop } function Find-AliZone { [CmdletBinding()] param( [Parameter(Mandatory,Position=0)] [string]$RecordName, [Parameter(Mandatory,Position=1)] [string]$AliKeyId, [Parameter(Mandatory,Position=2)] [securestring]$AliSecret ) if (!$script:AliRecordZones) { $script:AliRecordZones = @{} } if ($script:AliRecordZones.ContainsKey($RecordName)) { return $script:AliRecordZones.$RecordName } $pieces = $RecordName.Split('.') for ($i=1; $i -lt ($pieces.Count-1); $i++) { $zoneTest = "$( $pieces[$i..($pieces.Count-1)] -join '.' )" Write-Debug "Checking $zoneTest" try { $response = Invoke-AliRest DescribeDomains @("KeyWord=$zoneTest") $AliKeyId $AliSecret if ($response.TotalCount -gt 0) { $script:AliRecordZones.$RecordName = $response.Domains.Domain[0].DomainName # or PunyCode? return $script:AliRecordZones.$RecordName } } catch { throw } } throw "No zone found for $RecordName" } if ($Task -eq 'create'){ Add-DnsTxtAliyun $RecordName $TxtValue $AliKeyId $AliSecretInsecure } if ($Task -eq 'delete'){ Remove-DnsTxtAliyun $RecordName $TxtValue $AliKeyId $AliSecretInsecure } |
然后还有一个问题便是野卡证书的自动续签问题,可以利用这个 installation 脚本(PowerShell):
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 |
# Script By iEdon (https://iedon.com) Import-Module WebAdministration $bindings = Get-Item IIS:\SslBindings\* foreach ($binding in $bindings) { if ( ($binding.Store -eq "WebHosting") -and ( ($binding.Host.indexof("域名xxx.com") -ne -1) -or ($binding.Sites -eq "网站名XXXX") )) { $binding | Remove-Item } } $CertShop=$args[0] $newCert = Get-Item -Path "Cert:\LocalMachine\WebHosting\$CertShop" (Get-WebBinding -Name "网站A" -Protocol "https").AddSslCertificate($newCert.GetCertHashString(), "WebHosting") (Get-WebBinding -Name "网站B" -Protocol "https").AddSslCertificate($newCert.GetCertHashString(), "WebHosting") (Get-WebBinding -Name "网站C" -Protocol "https").AddSslCertificate($newCert.GetCertHashString(), "WebHosting") (Get-WebBinding -Name "网站D" -Protocol "https").AddSslCertificate($newCert.GetCertHashString(), "WebHosting") (Get-WebBinding -Name "网站E" -Protocol "https").AddSslCertificate($newCert.GetCertHashString(), "WebHosting") (Get-WebBinding -Name "网站F" -Protocol "https").AddSslCertificate($newCert.GetCertHashString(), "WebHosting") |
此脚本会自动更新 IPv4 任意主机上 443 端口的虚拟主机上的绑定信息。
此后,可以正常使用 Win-ACME,操作为:
- M (Create new certificate (full options)),
- 4 (Manual input),
- Enter comma-separated list of host names, starting with the common name: *.iedon.com(你的野卡 Wildcard 域名),
- Suggested FriendlyName is ‘[Manual] 你刚输入的域名’, press enter to accept or type an alternative: <直接回车>,
- 选择 3 ([dns-01] Create verification records with your own script),
- Path to script that creates DNS records: C:\Server\WinACME\dns.ps1 (上文的阿里云 DNS 自动验证脚本),
- How to delete records after validation: 3 (Do not delete),
- Input parameters for create script, or enter for default “create {Identifier} {RecordName} {Token}”: <直接回车>,
- What kind of private key should be used for the certificate?: 2 (RSA Key),
- How would you like to store the certificate?: 3 (Windows Certificate Store),
- Would you like to store it in another way too?: 3 (No additional storage steps required),
- Which installation step should run first?: 3 (Start external script or program),
- 然后输入上文第二个自动续期用的安装脚本直接回车即可。