I developed couple of websites, some of them are hosted on public cloud and served at port 80/443 in IIS. In this case, it is easy to use ACME.NET to update and renew certificate with no brain.
But I also got some home websites which are hosted in my home's PC. A public IP with unnormal port like 9999. Because in in my country, I cannot use 80/443 without government approve. I can apply, but the workflow is not easy and time consuming. And for the unnormal public ports, we cannot use http-01 challenge.
Before when I use ACME.NET, I never know the term http-01 challenge, it is just some magic tool for me and I just simply run and select renew all when necessary. 😅
After some study, I found I can use DNS-01 challenge without a live website and also need to be hosted on port 80/443, which will need me to create some TXT record on my DNS provider website (ali cloud). And this is quite simple for me, even in a manual way.
Untill now, I know what is ACME: Automatic Certificate Management Environment, a protocol that a CA and an applicant can use to automate the process of verification and certificate issuance.
Here is the script for how to automatically complish below steps with some ACME packages/APIs:
- Give options to set sub-domain, domain, certficate private password
- Create LetsEncrypt account
- Create LetsEncrypt order
- Create DNS challenge
- Put the DNS challenge value to my DNS provider (ali cloud)
- Check the DNS record is ready
- Download/export the certificate in pfx, pem formats
Add some package and open needed namespaces
#r "nuget: Certes" // Provide the nice ACME API by: https://github.com/fszlin/certes
#r "nuget: DnsClient"
#r "nuget: Fun.Build"
open System
open System.IO
open System.Text.Json
open Certes
open Certes.Acme
open Certes.Acme.Resource
open DnsClient
open Spectre.Console
open Fun.Result
open Fun.Build
The pipeline
pipeline "generate" {
description "Specify sub/domain to generate certificate accordingly."
whenCmdArg "--sub"
whenCmdArg "--pwd"
whenCmdArg "--domain"
whenCmdArg "--aliyun" "" "" true
stage "start" {
run (fun ctx -> asyncResult {
let sub = ctx.GetCmdArg("--sub")
let pwd = ctx.GetCmdArg("--pwd")
let domain = ctx.GetCmdArg("--domain")
let subDomain = $"{sub}.{domain}"
let! acme, _ = prepareAcmeAccount () |> AsyncResult.ofTask
let! order = acme.NewOrder([| subDomain |]) |> AsyncResult.ofTask
let! authz = order.Authorizations() |> Task.map Seq.head |> AsyncResult.ofTask
let! dnsChallenge = authz.Dns() |> AsyncResult.ofTask
let dnsValue = acme.AccountKey.DnsTxt(dnsChallenge.Token)
let dnsHost = "_acme-challenge." + (if sub = "*" then domain else subDomain)
printfn "Update DNS challenge TXT record to Aliyun ..."
AnsiConsole.MarkupLine $"Host: [green]{dnsHost}[/]"
AnsiConsole.MarkupLine $"VALUE: [green]{dnsValue}[/]"
AnsiConsole.MarkupLine ""
match ctx.TryGetCmdArg("--aliyun") with
| ValueSome _ -> do! updateToAliyun ctx sub domain dnsValue
| ValueNone ->
AnsiConsole.MarkupLine "[yellow]After you finished your update on your DNS provider(aliyun eg.), please press Enter to continue the validation[/]"
Console.ReadKey() |> ignore
AnsiConsole.MarkupLine ""
do! dnsTxtLookup dnsHost dnsValue |> AsyncResult.ofTask
let mutable retry = 0
let mutable shouldContinue = true
while retry < 5 && shouldContinue do
let! challengeResult = dnsChallenge.Validate() |> AsyncResult.ofTask
if challengeResult.Status.HasValue && challengeResult.Status.Value = ChallengeStatus.Valid then
shouldContinue <- false
printfn "DNS challenge validated"
else
printfn "DNS challenge failed: %A. %A. Waiting for retry..." challengeResult.Error challengeResult.Status
do! Async.Sleep 3000 |> Async.map Ok
let! orderState = order.Resource() |> AsyncResult.ofTask
AnsiConsole.MarkupLine ""
AnsiConsole.MarkupLine $"Order status: [green]{orderState.Status}[/]"
AnsiConsole.MarkupLine ""
AnsiConsole.MarkupLine "Export Certerficate ..."
let certKey = KeyFactory.NewKey(KeyAlgorithm.RS256)
let! certChain = order.Generate(CsrInfo(), certKey) |> AsyncResult.ofTask
let pfxBuilder = certChain.ToPfx(certKey)
let pfxBytes = pfxBuilder.Build($"https://{subDomain}", pwd)
let pfxFile = Path.GetFullPath $"{dnsHost}.pfx"
File.WriteAllBytes(pfxFile, pfxBytes)
AnsiConsole.MarkupLine $"CERT was saved to: [green]{pfxFile}[/]"
})
}
runIfOnlySpecified
}
tryPrintPipelineCommandHelp ()
Helper functions
let acmeAccount = "your email"
let acmeAccountFile = "acme-account.pem"
// Create the account if it is not cached locally before
let prepareAcmeAccount () = task {
if File.Exists acmeAccountFile then
let accountKey = KeyFactory.FromPem(File.ReadAllText acmeAccountFile)
let acme = new AcmeContext(WellKnownServers.LetsEncryptV2, accountKey)
let! account = acme.Account()
return acme, account
else
let acme = new AcmeContext(WellKnownServers.LetsEncryptV2)
let! account = acme.NewAccount(acmeAccount, true)
let pemKey = acme.AccountKey.ToPem()
File.WriteAllText(acmeAccountFile, pemKey)
return acme, account
}
// Verify the DNS txt record's value is matcing the expectation
let dnsTxtLookup (dnsHost) (dnsTxt) = task {
printfn "Verify DNS record ..."
let mutable retry = 0
let mutable shouldContinue = true
let dnsClient = LookupClient()
while retry < 10 && shouldContinue do
let! result = dnsClient.QueryAsync(dnsHost, QueryType.TXT)
match result.AllRecords |> Seq.tryHead with
| Some(:? Protocol.TxtRecord as record) ->
match record.Text |> Seq.tryHead with
| Some txt when txt = dnsTxt ->
printfn "Got updated dns record value: %A" record
shouldContinue <- false
| Some txt -> printfn "DNS record is not updated to %s. And is still %s." dnsTxt txt
| _ -> printfn "DNS record is not updated to %s" dnsTxt
| _ -> printfn "DNS record is not updated to %s" dnsTxt
if shouldContinue then
printfn "Waiting for check again..."
retry <- retry + 1
do! Async.Sleep 3000
if shouldContinue then failwith "DNS verify failed"
}
// Create/update aliyun DNS record
let updateToAliyun (ctx: Internal.StageContext) sub domain dnsValue = asyncResult {
let! result =
ctx.RunCommandCaptureOutput($"aliyun alidns DescribeDomainRecords --DomainName {domain} --Type TXT")
|> AsyncResult.map (fun x ->
JsonSerializer.Deserialize<
{|
DomainRecords:
{|
Record: {| RecordId: string; RR: string; Value: string |}[]
|}
|}
>(x)
)
let dnsSub = if sub = "*" then "_acme-challenge" else $"_acme-challenge.{sub}"
let dnsRecord = result.DomainRecords.Record |> Seq.tryFind (fun x -> x.RR = dnsSub)
match dnsRecord with
| Some r when r.Value = dnsValue -> AnsiConsole.MarkupLine "[green]DNS record is already up to date[/]"
| Some r -> do! ctx.RunCommand $"aliyun alidns UpdateDomainRecord --RecordId {r.RecordId} --RR {dnsSub} --Type TXT --Value {dnsValue} --Line Default"
| None -> do! ctx.RunCommand $"aliyun alidns AddDomainRecord --RR {dnsSub} --Type TXT --Value {dnsValue} --Line Default --DomainName {domain}"
}