(运维) IIS 下 Let’s Encrypt 免费泛域名 SSL 证书的自动部署与续期的实践

Posted by

on

背景

主机是一台 Windows Server 2019 Datacenter (可以视为 Win10 1809),IIS 10.0

IIS 上运行了数个站点,包括一个 Gitea 的反向代理,一个微服务网关,数个管理平台,一个静态资源托管

本文即将使用的工具:WinACME:一款非常强大而且简单易用的工具。

因为在 Windows 的 IIS 中进行 HTTPS 托管,在笔者的多方比较下,WinACME 是最为简单的,它提供了多种自动部署方式,并且能安装计划任务自动延期。但是想要更加愉快的使用,必须进行一番调教

有哪些问题

  1.  使用 WinACME 申请泛域名(Wildcard, 俗称野卡) 证书时,必须要用到 DNS 验证方式,WinACME 没有自动修改域名解析记录的任何适合国内使用的集成解决方案。
  2.  最近几版本支持 HTTP/2,SNI 的 IIS 可以在一个服务器上使用多个证书。
  3.  使用泛域名的野卡证书会出现多个网站共用一个证书的情况
  4.  WinACME 的自动续期只负责续签证书以及自动注册到 IIS 当中,但不会更新网站中对旧证书的绑定

WinACME 支持自写脚本扩展,所以我们可以通过写域名DNS自动验证脚本,以及正确的域名证书更新脚本来规避上述问题

解决方案

由于笔者的域名使用的是阿里云(万网),因此可以使用阿里云的 API 来达到自动添加 DNS 验证 所需要的 TXT 记录。以下是 DNS 验证 自动脚本(PowerShell,可以直接拿去使用,参考自 Posh-ACME 的插件):

# 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):

# 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)
  • 然后输入上文第二个自动续期用的安装脚本直接回车即可。

实际效果


4 responses to “(运维) IIS 下 Let’s Encrypt 免费泛域名 SSL 证书的自动部署与续期的实践”

  1. 我换域名了。。。umrhe.com

Leave a Reply

Your email address will not be published. Required fields are marked *