slaveOftime

Build a simple script to generate certificates from Let's Encrypt org

2024-12-25

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}"
}

Do you like this post?