From: martin f. krafft Date: Mon, 30 Mar 2020 20:31:16 +0000 (+0200) Subject: initial checkin X-Git-Url: https://git.madduck.net/puppet/acmessl.git/commitdiff_plain/HEAD initial checkin --- 69f03c1de5f82a47356509a03b5a91c6f1c16153 diff --git a/manifests/init.pp b/manifests/init.pp new file mode 100644 index 0000000..0a95a6f --- /dev/null +++ b/manifests/init.pp @@ -0,0 +1,237 @@ +class acmessl ( + String[1] $username = 'acmecert', + String[1] $homedir = '/var/lib/acmecert', + String[1] $nsupdate_key, + String[1] $dnszone, + String[1] $dnsserver, + Optional[String[1]] $emailaddress = undef, +) { + $certsdir = "$homedir/certs" + + include acmessl::tools + include acmessl::rehash + + class { "acmessl::user": + user => $username, + homedir => $homedir, + }-> + class { "acmessl::pullconfig": + user => $username, + homedir => $homedir, + dnsserver => $dnsserver, + dnszone => $dnszone, + nsupdate_key => $nsupdate_key, + certsdir => $certsdir, + emailaddress => $emailaddress, + }-> + class { "acmessl::sslfiles": + certsdir => $certsdir, + } +} + +class acmessl::sslfiles ( + Stdlib::Absolutepath $certsdir, +) { + + $netfacts = $facts[networking] ? { undef => $facts, default => $facts[networking] } + $fqdn = $netfacts[fqdn] + file { "/etc/ssl/certs/${fqdn}.pem": + ensure => present, + owner => root, + group => root, + mode => "0444", + source => "${certsdir}/cert.pem", + notify => Exec["update-ca-certificates"], + } + file { "/etc/ssl/certs/Lets_Encrypt_Authority_X3.pem": + ensure => present, + owner => root, + group => root, + mode => "0444", + source => "${certsdir}/chain.pem", + notify => Exec["update-ca-certificates"], + } + file { "/etc/ssl/private/${fqdn}.pem": + ensure => present, + owner => root, + group => "ssl-cert", + mode => "0440", + source => "${certsdir}/privkey.pem", + } + +} + +class acmessl::pullconfig ( + String[1] $user, + Stdlib::Absolutepath $homedir, + Stdlib::Absolutepath $certsdir, + String[1] $dnsserver, + String[1] $dnszone, + String[1] $nsupdate_key, + Optional[Array[String[1]]] $dns_alt_names = undef, + Optional[String[1]] $emailaddress = undef, +) { + $confdir = "$homedir/dehydrated" + $basedir = "$confdir/spool" + $logsdir = "$homedir/logs" + $_keyparts = $nsupdate_key.split(' ') + $key = "${_keyparts[0]}:$dnszone:${_keyparts[1]}" + $netfacts = $facts[networking] ? { undef => $facts, default => $facts[networking] } + $fqdn = $netfacts[fqdn] + $_dns_alt_names = $dns_alt_names ? { + undef => "", + default => $dns_alt_names.join(' '), + } + $_emailaddress = $emailaddress ? { + undef => undef, + /.+@.+/ => $emailaddress, + default => "${emailaddress}@${netfacts[fqdn]}", + } + + file { default: + ensure => present, + owner => $user, + group => $user, + ; + "$confdir": + ensure => directory, + mode => "2770", + ; + "$basedir": + ensure => directory, + mode => "2770", + ; + "$confdir/dehydrated.conf": + mode => "0440", + content => epp("acmessl/dehydrated.conf.epp", { + basedir => $basedir, + emailaddress => $_emailaddress, + }), + ; + "$confdir/domains.txt": + mode => "0440", + content => "$fqdn $_dns_alt_names\n", + ; + "$confdir/dehydrated-wrapper": + mode => "0550", + content => epp("acmessl/dehydrated-wrapper.epp", { + logsdir => $logsdir, + }), + ; + "$confdir/dehydrated-hook": + mode => "0550", + content => epp("acmessl/dehydrated-hook.epp", { + dnsserver => $dnsserver, + dnszone => $dnszone, + deploydir => $certsdir, + }), + ; + "$confdir/nsupdate-wrapper": + mode => "0550", + content => epp("acmessl/nsupdate-wrapper.epp", { + nsupdate_key => $key, + }) + ; + "$certsdir": + ensure => directory, + mode => "2770", + ; + } + + class { "acmessl::register": + user => $user, + confdir => $confdir, + basedir => $basedir, + } + + class { "acmessl::schedule": + user => $user, + confdir => $confdir, + } +} + +class acmessl::schedule ( + String[1] $user, + Stdlib::Absolutepath $confdir, +) { + schedule { "Try to renew ACME certificates once a day": + period => daily, + }-> + exec { "$confdir/dehydrated-wrapper --cron": + require => [ Class["acmessl::tools"] + , Class["acmessl::pullconfig"] + , Class["acmessl::register"] + ], + user => $user, + umask => "0007", + logoutput => true, + schedule => "Try to renew ACME certificates once a day", + } +} + +class acmessl::tools { + ensure_packages( [ 'dehydrated', 'dnsutils', 'ssl-cert', 'gnutls-bin' ], { + ensure => latest + }) + + file { [ "/etc/ssl/certs/ssl-cert-snakeoil.pem" + , "/etc/ssl/private/ssl-cert-snakeoil.key" ]: + ensure => absent, + } +} + +class acmessl::register ( + String[1] $user, + Stdlib::Absolutepath $confdir, + Stdlib::Absolutepath $basedir, +) { + exec { "Register with Letsencrypt": + require => [ Class["acmessl::tools"] + , Class["acmessl::pullconfig"] + ], + creates => "$basedir/accounts", + command => "$confdir/dehydrated-wrapper --register --accept-terms", + logoutput => true, + user => $user, + umask => "0007", + } +} + +class acmessl::user ( + String[1] $user, + Stdlib::Absolutepath $homedir, +) +{ + group { $user: + ensure => present, + system => true, + }-> + user { $user: + ensure => present, + comment => "ACME certificate manager,,,", + home => $homedir, + gid => $user, + system => true, + shell => "/usr/sbin/nologin", + purge_ssh_keys => true, + }-> + file { "$homedir": + ensure => directory, + owner => $user, + group => $user, + mode => "2770", + recurse => true, + purge => true, + force => true, + } +} + +class acmessl::rehash { + ensure_resource("exec", "update-ca-certificates", { + command => "update-ca-certificates --fresh", + path => "/usr/sbin:/usr/bin:/sbin:/bin", + cwd => "/etc/ssl/certs", + logoutput => true, + refreshonly => true, + }) +} diff --git a/templates/dehydrated-hook.epp b/templates/dehydrated-hook.epp new file mode 100644 index 0000000..492a773 --- /dev/null +++ b/templates/dehydrated-hook.epp @@ -0,0 +1,260 @@ +#!/bin/bash +set -eu + +NSUPDATE="${0%/*}/nsupdate-wrapper" +TTL=120 +ZONE="<%= $dnszone %>" +SERVER="<%= $dnsserver %>" +DEPLOYDIR="<%= $deploydir %>" + +deploy_challenge() { + local DOMAIN="${1}" TOKEN_FILENAME="${2}" TOKEN_VALUE="${3}" + + # This hook is called once for every domain that needs to be + # validated, including any alternative names you may have listed. + # + # Parameters: + # - DOMAIN + # The domain name (CN or subject alternative name) being + # validated. + # - TOKEN_FILENAME + # The name of the file containing the token to be served for HTTP + # validation. Should be served by your web server as + # /.well-known/acme-challenge/${TOKEN_FILENAME}. + # - TOKEN_VALUE + # The token value that needs to be served for validation. For DNS + # validation, this is what you want to put in the _acme-challenge + # TXT record. For HTTP validation it is the value that is expected + # be found in the $TOKEN_FILENAME file. + + # Simple example: Use nsupdate with local named + # printf 'server 127.0.0.1\nupdate add _acme-challenge.%s 300 IN TXT "%s"\nsend\n' "${DOMAIN}" "${TOKEN_VALUE}" | nsupdate -k /var/run/named/session.key + + _nsupdate add "$DOMAIN" "$TOKEN_FILENAME" "$TOKEN_VALUE" +} + +clean_challenge() { + local DOMAIN="${1}" TOKEN_FILENAME="${2}" TOKEN_VALUE="${3}" + + # This hook is called after attempting to validate each domain, + # whether or not validation was successful. Here you can delete + # files or DNS records that are no longer needed. + # + # The parameters are the same as for deploy_challenge. + + # Simple example: Use nsupdate with local named + # printf 'server 127.0.0.1\nupdate delete _acme-challenge.%s TXT "%s"\nsend\n' "${DOMAIN}" "${TOKEN_VALUE}" | nsupdate -k /var/run/named/session.key + + _nsupdate delete "$DOMAIN" "$TOKEN_FILENAME" "$TOKEN_VALUE" +} + +sync_cert() { + local KEYFILE="${1}" CERTFILE="${2}" FULLCHAINFILE="${3}" CHAINFILE="${4}" REQUESTFILE="${5}" + + # This hook is called after the certificates have been created but before + # they are symlinked. This allows you to sync the files to disk to prevent + # creating a symlink to empty files on unexpected system crashes. + # + # This hook is not intended to be used for further processing of certificate + # files, see deploy_cert for that. + # + # Parameters: + # - KEYFILE + # The path of the file containing the private key. + # - CERTFILE + # The path of the file containing the signed certificate. + # - FULLCHAINFILE + # The path of the file containing the full certificate chain. + # - CHAINFILE + # The path of the file containing the intermediate certificate(s). + # - REQUESTFILE + # The path of the file containing the certificate signing request. + + # Simple example: sync the files before symlinking them + # sync "${KEYFILE}" "${CERTFILE} "${FULLCHAINFILE}" "${CHAINFILE}" "${REQUESTFILE}" +} + +deploy_cert() { + local DOMAIN="${1}" KEYFILE="${2}" CERTFILE="${3}" FULLCHAINFILE="${4}" CHAINFILE="${5}" TIMESTAMP="${6}" + + # This hook is called once for each certificate that has been + # produced. Here you might, for instance, copy your new certificates + # to service-specific locations and reload the service. + # + # Parameters: + # - DOMAIN + # The primary domain name, i.e. the certificate common + # name (CN). + # - KEYFILE + # The path of the file containing the private key. + # - CERTFILE + # The path of the file containing the signed certificate. + # - FULLCHAINFILE + # The path of the file containing the full certificate chain. + # - CHAINFILE + # The path of the file containing the intermediate certificate(s). + # - TIMESTAMP + # Timestamp when the specified certificate was created. + + # Simple example: Copy file to nginx config + # cp "${KEYFILE}" "${FULLCHAINFILE}" /etc/nginx/ssl/; chown -R nginx: /etc/nginx/ssl + # systemctl reload nginx + cp --dereference --preserve --update --target-directory "$DEPLOYDIR" \ + "$KEYFILE" "$CERTFILE" "$FULLCHAINFILE" "$CHAINFILE" +} + +deploy_ocsp() { + local DOMAIN="${1}" OCSPFILE="${2}" TIMESTAMP="${3}" + + # This hook is called once for each updated ocsp stapling file that has + # been produced. Here you might, for instance, copy your new ocsp stapling + # files to service-specific locations and reload the service. + # + # Parameters: + # - DOMAIN + # The primary domain name, i.e. the certificate common + # name (CN). + # - OCSPFILE + # The path of the ocsp stapling file + # - TIMESTAMP + # Timestamp when the specified ocsp stapling file was created. + + # Simple example: Copy file to nginx config + # cp "${OCSPFILE}" /etc/nginx/ssl/; chown -R nginx: /etc/nginx/ssl + # systemctl reload nginx +} + + +unchanged_cert() { + local DOMAIN="${1}" KEYFILE="${2}" CERTFILE="${3}" FULLCHAINFILE="${4}" CHAINFILE="${5}" + + # This hook is called once for each certificate that is still + # valid and therefore wasn't reissued. + # + # Parameters: + # - DOMAIN + # The primary domain name, i.e. the certificate common + # name (CN). + # - KEYFILE + # The path of the file containing the private key. + # - CERTFILE + # The path of the file containing the signed certificate. + # - FULLCHAINFILE + # The path of the file containing the full certificate chain. + # - CHAINFILE + # The path of the file containing the intermediate certificate(s). +} + +invalid_challenge() { + local DOMAIN="${1}" RESPONSE="${2}" + + # This hook is called if the challenge response has failed, so domain + # owners can be aware and act accordingly. + # + # Parameters: + # - DOMAIN + # The primary domain name, i.e. the certificate common + # name (CN). + # - RESPONSE + # The response that the verification server returned + + # Simple example: Send mail to root + # printf "Subject: Validation of ${DOMAIN} failed!\n\nOh noez!" | sendmail root +} + +request_failure() { + local STATUSCODE="${1}" REASON="${2}" REQTYPE="${3}" HEADERS="${4}" + + # This hook is called when an HTTP request fails (e.g., when the ACME + # server is busy, returns an error, etc). It will be called upon any + # response code that does not start with '2'. Useful to alert admins + # about problems with requests. + # + # Parameters: + # - STATUSCODE + # The HTML status code that originated the error. + # - REASON + # The specified reason for the error. + # - REQTYPE + # The kind of request that was made (GET, POST...) + # - HEADERS + # HTTP headers returned by the CA + + # Simple example: Send mail to root + # printf "Subject: HTTP request failed failed!\n\nA http request failed with status ${STATUSCODE}!" | sendmail root +} + +generate_csr() { + local DOMAIN="${1}" CERTDIR="${2}" ALTNAMES="${3}" + + # This hook is called before any certificate signing operation takes place. + # It can be used to generate or fetch a certificate signing request with external + # tools. + # The output should be just the cerificate signing request formatted as PEM. + # + # Parameters: + # - DOMAIN + # The primary domain as specified in domains.txt. This does not need to + # match with the domains in the CSR, it's basically just the directory name. + # - CERTDIR + # Certificate output directory for this particular certificate. Can be used + # for storing additional files. + # - ALTNAMES + # All domain names for the current certificate as specified in domains.txt. + # Again, this doesn't need to match with the CSR, it's just there for convenience. + + # Simple example: Look for pre-generated CSRs + # if [ -e "${CERTDIR}/pre-generated.csr" ]; then + # cat "${CERTDIR}/pre-generated.csr" + # fi +} + +startup_hook() { + # This hook is called before the cron command to do some initial tasks + # (e.g. starting a webserver). + + : +} + +exit_hook() { + local ERROR="${1:-}" + + # This hook is called at the end of the cron command and can be used to + # do some final (cleanup or other) tasks. + # + # Parameters: + # - ERROR + # Contains error message if dehydrated exits with error +} + +_make_nsupdate_script() { + local op; op="$1"; shift + echo "server $SERVER" + echo "zone $ZONE" + + local rrname txt + while [ -n "${1:-}" ]; do + if [ $# -lt 3 ]; then + echo >&2 "Expecting batches of 3 arguments, got $# in the last: $@" + return 1 + fi + rrname="$1" + # $2 is ignored for dns-01 + txt="$3" + + echo "update $op ${rrname}.$ZONE ${TTL} IN TXT \"${txt}\"" + shift 3 2>/dev/null || break + done + echo send +} + +_nsupdate() { + local script + script="$(_make_nsupdate_script "$@")" || return 1 + echo "$script" | $NSUPDATE +} + +HANDLER="$1"; shift +if [[ "${HANDLER}" =~ ^(deploy_challenge|clean_challenge|sync_cert|deploy_cert|deploy_ocsp|unchanged_cert|invalid_challenge|request_failure|generate_csr|startup_hook|exit_hook)$ ]]; then + "$HANDLER" "$@" +fi diff --git a/templates/dehydrated-wrapper.epp b/templates/dehydrated-wrapper.epp new file mode 100644 index 0000000..7968c04 --- /dev/null +++ b/templates/dehydrated-wrapper.epp @@ -0,0 +1,24 @@ +#!/bin/sh +set -eu + +LOGDIR="<%= $logsdir %>" + +args_to_filename() { + echo "$@" | sed -e "s,--,,g;s, ,_,g" +} + +get_datestamp() { + date +%F-%H-%M-%S +} + +if [ "$1" = "--log" ]; then + shift + exec >$LOGDIR/$(get_datestamp)-$(args_to_filename "$@").log 2>&1 +fi + +export _DEHYDRATED_DIR="${0%/*}" +case "$_DEHYDRATED_DIR" in + (/*) :;; + (*) _DEHYDRATED_DIR="$(realpath $_DEHYDRATED_DIR)";; +esac +exec dehydrated --config "${_DEHYDRATED_DIR}/dehydrated.conf" "$@" diff --git a/templates/dehydrated.conf.epp b/templates/dehydrated.conf.epp new file mode 100644 index 0000000..b83f577 --- /dev/null +++ b/templates/dehydrated.conf.epp @@ -0,0 +1,11 @@ +CONFIG_D= +DOMAINS_TXT="${_DEHYDRATED_DIR}/domains.txt" +BASEDIR=<%= $basedir %> +CHALLENGETYPE=dns-01 +HOOK="${_DEHYDRATED_DIR}/dehydrated-hook" +HOOK_CHAIN=yes +#CA="https://acme-staging.api.letsencrypt.org/directory" +PRIVATE_KEY_RENEW=no +<% if $emailaddress { -%> +CONTACT_EMAIL=<%= $emailaddress %> +<% } -%> diff --git a/templates/nsupdate-wrapper.epp b/templates/nsupdate-wrapper.epp new file mode 100644 index 0000000..03c17c1 --- /dev/null +++ b/templates/nsupdate-wrapper.epp @@ -0,0 +1,3 @@ +#!/bin/sh +set -eu +exec nsupdate -vy "<%= $nsupdate_key %>"