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: