letsencrypt-2019-012

Delivered as text/html

[ hide source ] | [ make this the default ]

File Contents

#
# letsencrypt.tcl --
#
#   A small Let's Encrypt client for NaviServer implemented in Tcl,
#   supporting the ACME v2 interface of letsenvrypt.
#
#   To use it, set enabled to 1 and drop it somewhere under
#   NaviServer pageroot which is usually /usr/local/ns/pages and point
#   browser to it.
#
# If this page needs to be access restricted, configure the following
# three variables:
#
set user ""
set password ""
set enabled 1

namespace eval ::letsencrypt {
    #
    # The certificate will be placed finally into the following
    # directory:
    #
    set sslpath "[ns_info home]/modules/nsssl"

    #
    # Let's encrypt has several rate limits to avoid DOS
    # situations: https://letsencrypt.org/docs/rate-limits/
    #
    # When developing the interface (e.g. improving this script), you
    # should consider using the "staging" API of letsencrypt instead
    # of the "production" API to void these constraints.
    #
    set API "production"
    #set API "staging"
}

##########################################################################
#
#  ---- no configuration below this point --------------------------------
#
##########################################################################
if {[info commands ::json::json2dict] eq ""} {
    package require json
}
package require nx

namespace eval ::letsencrypt {

    nx::Class create ::letsencrypt::Client {

        # state and configuration variables
        :variable domains
        :variable domain
        :variable sans
        :variable startUrl

        # crypto state
        :variable modulus
        :variable exponent
        :variable jwk
        :variable thumbprint64

        # results from last HTTP request
        :variable nonce
        :variable replyHeaders
        :variable replyText

        # data for final certificate
        :variable certPrivKey
        :variable certPemFile


        # ####################### #
        # ----- domain form ----- #
        # ####################### #

        :method domainForm {} {
            ns_return 200 text/html [subst {
                <head>
                <title>Let's Encrypt Client</title>
                </head>
                <body>
                <form method='post' action='[ns_conn url]'>
                Please enter the domain names for the SSL certificate:<br>
                <input name="domains" size="80">
                <input type='submit' value='Submit'>
                </form>
                </body>
            }]
        }

        :method log {msg} {
            ::ns_write $msg
            ns_log notice "letsencrypt: $msg"
        }


        # ####################### #
        # ----- printHeaders ---- #
        # ####################### #

        :method printHeaders {headers} {
            set result "<pre>"
            foreach {k v} [ns_set array $headers] {
                append result "   $k: [ns_quotehtml $v]\n"
            }
            append result "</pre>\n"
        }

        # ####################### #
        # ------- readFile ------ #
        # ####################### #

        :method readFile {{-binary:switch f} fileName} {
            set F [open $fileName r]
            if {$binary} { fconfigure $F -encoding binary -translation binary }
            set content [read $F]
            close $F
            return $content
        }

        # ####################### #
        # ------- writeFile ----- #
        # ####################### #

        :method writeFile {{-binary:switch f} {-append:switch f} fileName content} {
            set mode [expr {$append ? "a" : "w"}]
            set F [open $fileName $mode]
            if {$binary} { fconfigure $F -encoding binary -translation binary }
            puts -nonewline $F $content
            close $F
        }

        # ################################# #
        # ----- produce backup files -----  #
        # ################################# #

        :method backup {{-mode rename} fileName} {
            set backupFileName ""
            if {[file exists $fileName]} {
                #
                # If the base file exists, make a backup based on the
                # content (using a sha256 checksum). Using checksums is
                # independent of timestamps and makes sure to prevent loss
                # of data (e.g. config files). If we have already a backup
                # file, there is nothing to do.
                #
                set backupFileName $fileName.[ns_md file -digest sha256 $fileName]
                if {![file exists $backupFileName]} {
                    file $mode -force $fileName $backupFileName
                    :log "Make backup of $fileName<br>"
                }
            } else {
                #
                # No need to make a backup, file does not exist yet
                #
            }
            return $backupFileName
        }


        # ###############ääää########################## #
        # ----- post JWS request of given payload ----- #
        # ############################################# #

        :method send_signed_request {{-method POST} url payload} {
            set payload64 [ns_base64urlencode -binary $payload]
            #
            # "kid" and "jwk" are mutually exclusive
            # (https://tools.ietf.org/html/draft-ietf-acme-acme-10#section-6.2)
            #
            if {[info exists :kid]} {
                set protected [subst {{"url":"$url","alg":"RS256","nonce":"${:nonce}","kid":"${:kid}"}}]
            } else {
                #
                # "jwk" only for newAccount and revokeCert requests
                set protected [subst {{"url":"$url","alg":"RS256","nonce":"${:nonce}","jwk":${:jwk}}}]
            }
            set protected64 [ns_base64urlencode $protected]

            set siginput [subst {$protected64.$payload64}]
            set signature64 [::ns_crypto::md string \
                                 -digest sha256 \
                                 -sign ${:accoutKeyFile} \
                                 -encoding base64url \
                                 $siginput]
            set data [subst {{
                "protected": "$protected64",
                "payload":   "$payload64",
                "signature": "$signature64"
            }}]
            #:log "<pre>POST $url\n$data</pre>"

            set queryHeaders [ns_set create]
            ns_set update $queryHeaders "Content-type" "application/jose+json"
            set d [ns_http run -method POST -headers $queryHeaders -body $data $url]

            #
            # Get headers, body and nonce into instance variables,
            # since these are used later to understand what the server
            # replied.
            #
            set :replyHeaders [dict get $d headers]
            set :replyText [dict get $d body]
            set :nonce [ns_set iget ${:replyHeaders} "replay-nonce"]

            :log "<pre>reply from letsencrypt:\n${:replyText}</pre>"
            return [dict get $d status]
        }

        :method abortMsg {status msg} {
            :log "$msg ended with HTTP status $status<br>"
            :log "[:printHeaders ${:replyHeaders}]<br>${:replyText}<br>"
        }

        :method startOfReport {} {
            ns_headers 200 text/html
            :log {<!DOCTYPE html><html lang="en"><head><title>NaviServer Let's Encrypt client</title></head><body>}
            :log "<h3>Obtaining a certificate from Let's Encrypt using \
                  the [string totitle $::letsencrypt::API] API:</h3>"
        }

        :method URL {kind} {
            dict get ${:apiURLs} $kind
        }

        # ###################################äää#### #
        # ----- get API URLs from Let's encrypt ---- #
        # ########################################## #

        :method getAPIurls {config} {

            set url [dict get $config $::letsencrypt::API url]
            set d [ns_http run $url]
            set :replyHeaders [dict get $d headers]

            #:log [:printHeaders ${:replyHeaders}]
            set :nonce [ns_set iget ${:replyHeaders} "replay-nonce"]

            set :apiURLs [json::json2dict [dict get $d body]]
            #:log ":apiURLs ${:apiURLs}"

            #
            # key-change keyChange
            # new-authz
            # new-cert    newOrder?
            # new-reg     newAccount?
            # revoke-cert revokeCert
            #             newNonce

            :log [subst {<br>
                Let's Encrypt URLs ($::letsencrypt::API API):<br>
                <pre>   [:URL keyChange]\n   [:URL newNonce]\n   [:URL newOrder]\n   [:URL newAccount]\n   [:URL revokeCert]</pre>
            }]
        }

        :method getNonce {} {
            set d [ns_http run -method HEAD [:URL newNonce]]
            set :replyHeaders [dict get $d headers]
            set :nonce [ns_set iget ${:replyHeaders} "replay-nonce"]
            #:log "<pre>getNonce: ${:nonce}\n</pre>"
        }

        :method decnum_to_bytes {num} {
            set result ""

            while {$num} {
                set char [expr {$num & 0xff}]
                set result "[format %c $char]$result"
                set num [expr {$num >> 8}]
            }
            return $result
        }


        :method parseAccountKey {} {
            :log "parseAccountKey ${:accoutKeyFile}<br>"

            #
            # Get :modulus and :exponent from the PEM file of the account
            #
            set keyInfo [exec openssl rsa -in ${:accoutKeyFile} -noout -text]
            regexp {\nmodulus:\n([\sa-f0-9:]+)\npublicExponent:\s(\d+)\s} $keyInfo . pub_hex exp
            regsub -all {[\s:]} $pub_hex "" mod
            regsub {^00} $mod "" mod
            #:log "<pre>pub_hex: ${pub_hex}</pre>"
            #:log "modulus: ${mod}<br>"

            #
            # Put key info into JSON Web Key (:jwk)
            #
            set :modulus [ns_base64urlencode -binary [binary decode hex $mod]]
            set :exponent [ns_base64urlencode -binary [:decnum_to_bytes $exp]]
            set :jwk [subst {{"e":"${:exponent}","kty":"RSA","n":"${:modulus}"}}]

            #
            # Generate thumbprint from the JSON Web Key (:jwk)
            #
            set :thumbprint64 [ns_md string -digest sha256 -encoding base64url ${:jwk}]
            :log "<br><pre>jwk: ${:jwk}\n"
            :log "thumbprint64: ${:thumbprint64}\n"

            #:log "<pre>jwk ${:jwk}\nthumbprint64: ${:thumbprint64}</pre>"
        }

        # ########################################## #
        # - register new acccount at Let's Encrypt - #
        # ########################################## #

        :method registerNewAccount {} {

            :log "Register new account at Let's Encrypt... "
            :log "generating RSA key pair...<br>"

            #
            # Repeat max 10 times until registration was successful
            #
            for {set count 0} {$count < 3} {incr count} {
                #
                # Create a fresh account key and get its components
                #
                exec -ignorestderr -- openssl genrsa 2048 > ${:accoutKeyFile}
                :parseAccountKey

                # ########################### #
                # ----- get first nonce ----- #
                # ########################### #
                :getNonce

                # ########################### #
                # ------ registration ------- #
                # ########################### #
                :log "Creating new registration...<br>"

                set payload [subst {{"termsOfServiceAgreed": true, "onlyReturnExisting": false, "contact": \["mailto:webmaster@${:domain}"\]}}]
                set status [:send_signed_request [:URL newAccount] $payload]
                if {$status eq "400"} {
                    :log "New Registration failed. Retry and generate new RSA key pair...<br>"
                } else {
                    set :kid [ns_set iget ${:replyHeaders} "location"]
                    :log "<pre>registration headers contained kid ${:kid}\n</pre>"
                    break
                }
            }
            :log "Registration ended with status $status.<br>"

            return $status
        }

        # ########################## #
        # ----- sign agreement ----- #
        # ########################## #

        :method signAgreement {} {

            :log "<br>Signing agreement... "
            set location [ns_set iget ${:replyHeaders} "location"]
            #set :kid $location

            #
            # parse link header for terms of service
            #
            set url ""
            foreach {key value} [ns_set array ${:replyHeaders}] {
                if {$key eq "link"
                    && [regexp {^<(.*)>;rel="terms-of-service"} $value . url]
                } {
                    break
                }
            }

            set payload [subst {{"resource": "reg", "agreement": "$url"}}]
            set httpStatus [:send_signed_request $location $payload]

            :log "returned HTTP status $httpStatus<br>"
            return $httpStatus
        }


        # ########################## #
        # ----- authorize domain --- #
        # ########################## #

        :method authorizeDomain {auth_url domain} {
            :log "<br>Authorizing account for domain <strong>$domain</strong>... "

            set httpStatus [:send_signed_request $auth_url ""]
            :log "$auth_url returned HTTP status $httpStatus<br>"

            if {$httpStatus in {400 403}} {
                :log "error message: ${:replyText}<br>"
                return invalid
            }

            :log "... getting HTTP challenge... "
            set :authorization [ns_set iget ${:replyHeaders} "location"]
            set challenges [dict get [json::json2dict ${:replyText}] challenges]
            ns_log notice "... challenges:\n[join $challenges \n]"

            #
            # Parse HTTP challenge
            #
            foreach entry $challenges {
                if {[dict filter $entry value "http-01"] ne ""} {
                    set challengeURL [dict get $entry url]
                    set token [dict get $entry token]
                }
            }

            #
            # Provide HTTP resource to fulfill HTTP challenge
            #
            file mkdir [ns_server pagedir]/.well-known/acme-challenge
            :writeFile [ns_server pagedir]/.well-known/acme-challenge/$token $token.${:thumbprint64}

            :log "<pre>keyauthorization: $token.${:thumbprint64}</pre>\n"

            #set payload [subst {{"resource": "challenge", "keyAuthorization": "$token.${:thumbprint64}"}}]
            :log "challenge is done [ns_server pagedir]/.well-known/acme-challenge/$token<br>"

            #
            # Try to obtain challenge URL locally. If this does not
            # work for us, it will not work for letsencrypt either.
            #
            set wellknown_url "http://$domain/.well-known/acme-challenge/$token"
            set d [ns_http run -timeout 5.0 $wellknown_url]
            :log "wellknown_url $wellknown_url returned <pre>$d</pre>"
            if {[dict get $d status] eq "200"} {
                :log "challenge is available on server $wellknown_url\n"
            } else {
                :log "challenge can not retrieved from server: $wellknown_url\n"
                return "invalid"
            }

            set httpStatus [:send_signed_request $challengeURL "{}"]
            :log "challengeURL $challengeURL returned HTTP status $httpStatus<br>"

            #
            # ----- validate
            #
            :log "... validating the challenge... "
            #:log "Reply Headers: [:printHeaders ${:replyHeaders}]<br>"

            #
            # Not sure, we have to get the "up" link, the result is
            # identical to the $auth_url
            #
            #set link ""
            #foreach {k v} [ns_set array ${:replyHeaders}] {
            #    if {$k eq "link" && [regexp {^<(.*)>;rel="up"} $v . link]} {
            #        break
            #    }
            #}
            #if {$link ne ""} {
            #    :log "obtained up link: $link, "
            #} else {
            #    :log "could not obtain up link from header, "
            #}
            #:log "uplink equal to auth_url: [string equal $link $auth_url]<br>"

            set status [dict get [json::json2dict ${:replyText}] status]
            :log "status: $status<br>"
            #:log "<pre>$result</pre>[:printHeaders ${:replyHeaders}]<br>"

            # check until validation is finished (max 20 times)
            set count 0
            #set link $challengeURL
            while {$status eq "pending"} {
                :log "... retry after one second... "
                ns_sleep 1

                set httpStatus [:send_signed_request $auth_url ""]
                :log "$auth_url returned HTTP status $httpStatus<br>"

                set status [dict get [json::json2dict ${:replyText}] status]
                :log "status: $status<br>"
                if {$status ni {"valid" "pending"}} {
                    :log "<pre>${:replyText}</pre>[:printHeaders ${:replyHeaders}]<br>"
                    break
                }
                # safety belt to avoid in the worst case endless loops.
                if {[incr count] > 2} break
            }
            return $status
        }


        # ########################### #
        # ----- get certificate ----- #
        # ########################### #

        :method certificateRequest {finalizeURL} {

            :log "<br>Generating RSA key pair for SSL certificate... "

            #
            # Repeat max 10 times until certificate was successfully obtained
            #
            for {set count 0} {$count < 10} {incr count} {

                set csrConfFile $::letsencrypt::sslpath/${:domain}.csr.conf
                set csrFile     $::letsencrypt::sslpath/${:domain}.csr
                set keyFile     $::letsencrypt::sslpath/${:domain}.key

                exec -ignorestderr -- openssl genrsa -out $keyFile 2048
                set :certPrivKey [:readFile $keyFile]

                lassign [exec openssl version -d] _ openssldir
                file copy -force [file join $openssldir openssl.cnf] $csrConfFile
                if {[llength ${:sans}] > 0} {
                    set altNames {}; foreach alt ${:sans} {lappend altNames DNS:$alt}
                    :writeFile -append $csrConfFile "\n\[SAN\]\nsubjectAltName=[join $altNames ,]\n"
                    set extensions [list -reqexts SAN -extensions SAN]
                } else {
                    set extensions {}
                }
                exec openssl req -new -sha256 -outform DER {*}$extensions \
                    -subj "/CN=${:domain}" -key $keyFile -config $csrConfFile -out $csrFile
                set csr [:readFile -binary $::letsencrypt::sslpath/${:domain}.csr]

                :log "DONE<br>"
                :log "Getting the certificate for domain ${:domain}, SANs ${:sans}... "

                set csr64 [ns_base64urlencode -binary $csr]
                set payload [subst {{"csr": "$csr64"}}]
                set httpStatus [:send_signed_request $finalizeURL $payload]

                :log "returned HTTP status $httpStatus<br>"

                if {$httpStatus eq "400"} {
                    :log "Certificate request failed. Generating new RSA key pair... "
                    #ns_log notice "CSR-Request returned 400\n"
                    :log "[:printHeaders ${:replyHeaders}]<br>${:replyText}<br>"
                    break
                } else {
                    break
                }
            }
            if {$httpStatus == 200} {
                set finalizeDict [json::json2dict ${:replyText}]
                set certificateURL [dict get $finalizeDict certificate]
                set httpStatus [:send_signed_request $certificateURL ""]
            }
            return $httpStatus
        }


        # ############################### #
        # ----- install certificate ----- #
        # ############################### #

        :method certificateInstall {} {

            :log "<br>Generate the certificate under $::letsencrypt::sslpath...<br>"

            set cert ${:replyText}

            #ns_log notice  "Storing certificate under $::letsencrypt::sslpath/${:domain}.cer"
            #:writeFile $::letsencrypt::sslpath/${:domain}.pem ${:replyText}

            #puts "Converting the certificate to PEM format to $::letsencrypt::sslpath/${:domain}.crt"
            #exec openssl x509 -inform der \
            #    -in $::letsencrypt::sslpath/${:domain}.cer \
            #    -out $::letsencrypt::sslpath/${:domain}.crt
            #set cert [:readFile $::letsencrypt::sslpath/${:domain}.crt]

            #
            # Build certificate in the file system. Backup old file if necessary.
            #
            set :certPemFile $::letsencrypt::sslpath/${:domain}.pem

            # Save certificate and private key in single file in directory
            # of nsssl module.
            :backup ${:certPemFile}

            ns_log notice  "Combining certificate and private key to ${:certPemFile}"
            :writeFile ${:certPemFile} "${:certPrivKey}$cert"

            #ns_log notice  "Deleting ${:domain}.cer and ${:domain}.crt under $::letsencrypt::sslpath/"
            #file delete $::letsencrypt::sslpath/${:domain}.cer
            #file delete $::letsencrypt::sslpath/${:domain}.crt

            #
            # Get certificate chain; the Let's Encrypt certificates
            # are available from https://letsencrypt.org/certificates/
            # the used certificate is the "Let’s Encrypt Authority X3
            # (IdenTrust cross-signed)"
            #
            # One might as well add the following certificate to
            # complete the chain, but this does not seem necessary by
            # www.ssllabs.com
            #
            # https://www.identrust.com/certificates/trustid/root-download-x3.html
            #
            :log "Obtaining certificate chain ... "
            set d [ns_http run https://letsencrypt.org/certs/lets-encrypt-x3-cross-signed.pem.txt]
            :log "returned HTTP status [dict get $d status]<br>"

            :writeFile -append ${:certPemFile} [dict get $d body]

            #
            # Add DH parameters
            #
            :log "Adding DH parameters to ${:certPemFile} (might take a while - wait for DONE message) ... "
            exec -ignorestderr -- openssl dhparam 2048 >> ${:certPemFile} 2> /dev/null
            :log " DONE<br><br>"

            :log "New certificate successfully installed in: <strong>${:certPemFile}</strong><br><br>"
        }


        # ############################### #
        # ----- Update configuration ---- #
        # ############################### #

        :method updateConfiguration {} {

            #
            # Update the NaviServer config file by reading its content
            # and update it in memory before writing it back to disk
            # (if changed).
            #

            :log "Checking the NaviServer config file: "
            set C [:readFile [ns_info config]]
            set origConfig $C

            #
            # Check, if nsssl module is already loaded
            #
            set nssslLoaded 0
            foreach d [ns_driver info] {
                if {[dict get $d protocol] eq "https"} {
                    set nssslLoaded 1
                    break
                }
            }
            if {$nssslLoaded} {
                :log "The nsssl driver module is apparently already loaded.<br>"
            } else {
                :log "The nsssl driver module is apparently already not loaded, try to fix this.<br>"

                if {[regexp {\#\s+ns_param\s+nsssl.*nsssl} $C]} {
                    #
                    # The nsssl driver is apparently commented out, activate it
                    #
                    regsub {\#(\s+ns_param\s+nsssl.*nsssl)} $C \1 C
                    :log {...removing comment from driver module nsssl.so line in config file.<br>}

                } else {
                    #
                    # There is no nsssl driver in the config file, add it
                    # to the end.
                    #
                    append C {
                        #
                        # In order to install nsssl globally to your
                        # server, uncomment the following lines
                        #
                        ns_section "ns/modules"
                        ns_param    nssock              nssock

                        ns_section    ns/server/${server}/modules
                        ns_param      nsssl            nsssl.so
                    }
                    :log {
                        ... add the driver module "nsssl.so" in your config file either
                        to the global or per-server "modules" section .<br>}
                }
            }

            if {![regexp {ns_param\s+certificate\s+} $C]} {
                :log [subst {Your config file [ns_info config] does
                    not seem to contain a nsssl definition section.<br>
                    Adding a default section to the end. Please check,
                    if you want to modify the section according to your needs.
                }]
                append C [subst {
                    ns_section    ns/server/\${server}/module/nsssl
                    ns_param   certificate   ${:certPemFile}
                    ns_param   address       0.0.0.0
                    ns_param   port          443
                    ns_param   ciphers      "ECDH+AESGCM:DH+AESGCM:ECDH+AES256:DH+AES256:ECDH+AES128:DH+AES:ECDH+3DES:DH+3DES:RSA+AESGCM:RSA+AES:RSA+3DES:!aNULL:!MD5:!RC4"
                    ns_param   protocols    "!SSLv2:!SSLv3"
                    ns_param   verify         0

                    ns_param   extraheaders {
                        Strict-Transport-Security "max-age=31536000; includeSubDomains"
                        X-Frame-Options SAMEORIGIN
                        X-Content-Type-Options nosniff
                    }
                }]
            } elseif {![regexp "ns_param\\s+certificate\\s+${:certPemFile}" $C]} {
                :log {... updating the certificate entry<br>}
                regsub -all {ns_param\s+certificate\s+[^\n]+} $C "ns_param   certificate   ${:certPemFile}" C
            }

            #
            # Rewrite config file only, when the content has changed
            #
            if {$origConfig ne $C} {
                #
                # Make first a backup of old config file ...
                #
                :backup -mode copy [ns_info config]

                #
                # Rewrite config file
                #
                :writeFile [ns_info config] $C
                :log [subst {
                    Updating NaviServer config file<br>
                    Please check updated config file: <strong>[ns_info config]</strong>
                    <br>and update it (if necessary)<p>
                }]
            } else {
                #
                # Nothing has changed.
                #
                :log {No need to update the NaviServer config file.<br>}
            }
        }


        # ########################## #
        # ----- MAIN METHOD ----- #
        # ########################## #
        :public method getCertificate {} {

            set :domains [ns_queryget domains]
            #
            # If the domain names were already submitted in the form
            # (or via query parameters), we have all data we
            # need. Otherwise give the user a form to fill in the data
            # and to continue from there.

            if {${:domains} eq ""} {
                :domainForm
                return
            }

            set :domain    [lindex ${:domains} 0]
            set :sans      [lrange ${:domains} 1 end]
            set :startUrl "[ns_conn proto]://${:domain}[ns_conn url]"

            set config {
                staging    {url https://acme-staging-v02.api.letsencrypt.org/directory}
                production {url https://acme-v02.api.letsencrypt.org/directory}
            }

            #
            # Make sure, the sslpath exists
            #
            file mkdir $::letsencrypt::sslpath
            set :accoutKeyFile   $::letsencrypt::sslpath/letsencrypt-$::letsencrypt::API-account.key

            #
            # Start output
            #
            :startOfReport

            #
            # Always get first the API URLs
            #
            :getAPIurls $config

            #
            # Create or reuse an account
            #
            if {[file exists ${:accoutKeyFile}]} {
                #
                # We have already registered in the past successfully at
                # Let's Encrypt and signed the agreement.
                #
                :log "Reuse existing account registration at Let's Encrypt (${:accoutKeyFile})<br>"

                :parseAccountKey
                :getNonce

                set payload [subst {{"termsOfServiceAgreed": true, "onlyReturnExisting": true, "contact": \["mailto:webmaster@${:domain}"\]}}]
                set status [:send_signed_request [:URL newAccount] $payload]

                if {$status eq "400"} {
                    :abortMsg $status "authorization for existing account failed"
                    return
                } else {
                    set :kid [ns_set iget ${:replyHeaders} "location"]
                    :log "<pre>registration headers contained kid ${:kid}\n</pre>"
                }

            } else {

                set status [:registerNewAccount]
                if {$status >= 400} {
                    :abortMsg $status "Registration"
                    return
                }

                set status [:signAgreement]
                if {$status >= 400} {
                    :abortMsg $status "Agreement"
                    return
                }
            }

            #
            # Create a new order for the domains
            #
            file delete -force [ns_server pagedir]/.well-known
            file mkdir [ns_server pagedir]/.well-known

            :log "Creating new order...<br>"
            set ids {}
            foreach domain ${:domains} {
                lappend ids [subst {{"type": "dns", "value": "$domain"}}]
            }
            set payload [subst {{"identifiers": \[[join $ids ,]\]}}]
            :log "... payload: <pre>$payload</pre>"

            set httpStatus [:send_signed_request [:URL newOrder] $payload]
            if {$httpStatus >= 400} {
                :abortMsg $httpStatus "Order failed"
                return
            }
            set orderDict [json::json2dict ${:replyText}]
            set authorizations [dict get $orderDict authorizations]
            set identifiers [dict get $orderDict identifiers]
            set orderFinalizeURL [dict get $orderDict finalize]

            #:log "<pre>authorizations:\n$authorizations\norderFinalizeURL:$orderFinalizeURL</pre>"

            if {[llength $authorizations] != [llength ${:domains}]} {
                :abortMsg $httpStatus "number of domains ([llength ${:domains}]) differs from number of authorizations ([llength $authorizations])"
                return
            }

            foreach domain ${:domains} auth_url $authorizations id $identifiers {
                set status [:authorizeDomain $auth_url [dict get $id value]]
                if {$status in {invalid}} {
                    :log [subst {
                        Validation of domain $domain failed (final status $status).
                        <p>Please restart the procedure at <a href="${:startUrl}">${:startUrl}</a>
                    }]
                    return
                }
            }

            file delete -force [ns_server pagedir]/.well-known

            #
            # Get certificate
            #
            set status [:certificateRequest $orderFinalizeURL]
            if {$status >= 400} {
                :abortMsg $status "Certificate request"
                return
            }

            #
            # Install certificate and update configuration
            #
            :certificateInstall
            :updateConfiguration

            :log [subst {<br>
                To use the new certificate, restart your NaviServer instance
                and check results on <a href="https://${:domain}">https://${:domain}</a>.
                <p>
            }]
        }
    }
}

# Check user access if configured
if { ($enabled == 0 && [ns_conn peeraddr] ni {"127.0.0.1" "::1"}) ||
     ($user ne "" && ([ns_conn authuser] ne $user || [ns_conn authpassword] ne $password)) } {
    ns_returnunauthorized
    return
}

# Produce page
ns_set update [ns_conn outputheaders] "Expires" "now"

set c [::letsencrypt::Client new]
$c getCertificate
$c destroy

#
# Local variables:
#    mode: tcl
#    tcl-indent-level: 4
#    indent-tabs-mode: nil
# End: