Skip to content

Commit

Permalink
Merge pull request #1137 from nginx-proxy/dns-challenge
Browse files Browse the repository at this point in the history
feat: DNS-01 challenge support
  • Loading branch information
buchdag authored Jul 29, 2024
2 parents c2764aa + b048f4e commit 126f1ee
Show file tree
Hide file tree
Showing 6 changed files with 208 additions and 41 deletions.
1 change: 1 addition & 0 deletions .shellcheckrc
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
external-sources=true
12 changes: 8 additions & 4 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -10,20 +10,24 @@ It handles the automated creation, renewal and use of SSL certificates for proxi

### Features:
* Automated creation/renewal of Let's Encrypt (or other ACME CAs) certificates using [**acme.sh**](https://github.com/acmesh-official/acme.sh).
* Let's Encrypt / ACME domain validation through `http-01` challenge only.
* Let's Encrypt / ACME domain validation through `HTTP-01` (by default) or [`DNS-01`](https://github.com/nginx-proxy/acme-companion/blob/main/docs/Let's-Encrypt-and-ACME.md#dns-01-acme-challenge) challenge.
* Automated update and reload of nginx config on certificate creation/renewal.
* Support creation of [Multi-Domain (SAN) Certificates](https://github.com/nginx-proxy/acme-companion/blob/main/docs/Let's-Encrypt-and-ACME.md#multi-domains-certificates).
* Support creation of [Wildcard Certificates](https://community.letsencrypt.org/t/acme-v2-production-environment-wildcards/55578) (with `DNS-01` challenge only).
* Creation of a strong [RFC7919 Diffie-Hellman Group](https://datatracker.ietf.org/doc/html/rfc7919#appendix-A) at startup.
* Work with all versions of docker.

### Requirements:
### HTTP-01 challenge requirements:
* Your host **must** be publicly reachable on **both** port [`80`](https://letsencrypt.org/docs/allow-port-80/) and [`443`](https://github.com/nginx-proxy/acme-companion/discussions/873#discussioncomment-1410225).
* Check your firewall rules and [**do not attempt to block port `80`**](https://letsencrypt.org/docs/allow-port-80/) as that will prevent `http-01` challenges from completing.
* Check your firewall rules and [**do not attempt to block port `80`**](https://letsencrypt.org/docs/allow-port-80/) as that will prevent `HTTP-01` challenges from completing.
* For the same reason, you can't use nginx-proxy's [`HTTPS_METHOD=nohttp`](https://github.com/nginx-proxy/nginx-proxy#how-ssl-support-works).
* The (sub)domains you want to issue certificates for must correctly resolve to the host.
* Your DNS provider must [answer correctly to CAA record requests](https://letsencrypt.org/docs/caa/).
* If your (sub)domains have AAAA records set, the host must be publicly reachable over IPv6 on port `80` and `443`.

If you can't meet these requirements, you can use the `DNS-01` challenge instead. Please refer to the [documentation](https://github.com/nginx-proxy/acme-companion/blob/main/docs/Let's-Encrypt-and-ACME.md#dns-01-acme-challenge) for more information.

In addition to the above, please ensure that your DNS provider answers correctly to CAA record requests. [If your DNS provider answer with an error, Let's Encrypt won't issue a certificate for your domain](https://letsencrypt.org/docs/caa/). Let's Encrypt do not require that you set a CAA record on your domain, just that your DNS provider answers correctly.

![schema](https://github.com/nginx-proxy/acme-companion/blob/main/schema.png)

## Basic usage (with the nginx-proxy container)
Expand Down
142 changes: 112 additions & 30 deletions app/letsencrypt_service
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,17 @@ RENEW_PRIVATE_KEYS="$(lc "${RENEW_PRIVATE_KEYS:-true}")"
# Backward compatibility environment variable
REUSE_PRIVATE_KEYS="$(lc "${REUSE_PRIVATE_KEYS:-false}")"

function strip_wildcard {
# Remove wildcard prefix if present
# https://github.com/nginx-proxy/nginx-proxy/tree/main/docs#wildcard-certificates
local -r domain="${1?missing domain argument}"
if [[ "${domain:0:2}" == "*." ]]; then
echo "${domain:2}"
else
echo "$domain"
fi
}

function create_link {
local -r source=${1?missing source argument}
local -r target=${2?missing target argument}
Expand All @@ -27,7 +38,8 @@ function create_link {

function create_links {
local -r base_domain=${1?missing base_domain argument}
local -r domain=${2?missing base_domain argument}
local domain=${2?missing base_domain argument}
domain="$(strip_wildcard "$domain")"

if [[ ! -f "/etc/nginx/certs/$base_domain/fullchain.pem" || \
! -f "/etc/nginx/certs/$base_domain/key.pem" ]]; then
Expand Down Expand Up @@ -75,6 +87,7 @@ function cleanup_links {
for cid in "${LETSENCRYPT_CONTAINERS[@]}"; do
local -n hosts_array="LETSENCRYPT_${cid}_HOST"
for domain in "${hosts_array[@]}"; do
domain="$(strip_wildcard "$domain")"
# Add domain to the array storing currently enabled domains.
ENABLED_DOMAINS+=("$domain")
done
Expand Down Expand Up @@ -128,6 +141,11 @@ function update_cert {
# First domain will be our base domain
local base_domain="${hosts_array[0]}"

local wildcard_certificate='false'
if [[ "${base_domain:0:2}" == "*." ]]; then
wildcard_certificate='true'
fi

local should_restart_container='false'

# Base CLI parameters array, used for both --register-account and --issue
Expand All @@ -151,11 +169,69 @@ function update_cert {

# CLI parameters array used for --issue
local -a params_issue_arr
params_issue_arr+=(--webroot /usr/share/nginx/html)

# ACME challenge type
local -n acme_challenge="ACME_${cid}_CHALLENGE"
if [[ -z "${acme_challenge}" ]]; then
acme_challenge="${ACME_CHALLENGE:-HTTP-01}"
fi

if [[ "$acme_challenge" == "HTTP-01" ]]; then
# HTTP-01 challenge
if [[ "$wildcard_certificate" == 'true' ]]; then
echo "Error: wildcard certificates (${base_domain}) can't be obtained with HTTP-01 challenge"
return 1
fi
params_issue_arr+=(--webroot /usr/share/nginx/html)
elif [[ "$acme_challenge" == "DNS-01" ]]; then
# DNS-01 challenge
local acmesh_dns_config_used='none'

local default_acmesh_dns_api="${DEFAULT_ACMESH_DNS_API_CONFIG[DNS_API]}"
[[ -n "$default_acmesh_dns_api" ]] && acmesh_dns_config_used='default'

local -n acmesh_dns_config="ACMESH_${cid}_DNS_API_CONFIG"
local acmesh_dns_api="${acmesh_dns_config[DNS_API]}"
[[ -n "$acmesh_dns_api" ]] && acmesh_dns_config_used='container'

local -a dns_api_keys

case "$acmesh_dns_config_used" in
'default')
params_issue_arr+=(--dns "$default_acmesh_dns_api")
# Loop over defined variable for default acme.sh DNS api config
for key in "${!DEFAULT_ACMESH_DNS_API_CONFIG[@]}"; do
[[ "$key" == "DNS_API" ]] && continue
dns_api_keys+=("$key")
local value="${DEFAULT_ACMESH_DNS_API_CONFIG[$key]}"
local -x "$key"="$value"
done
;;
'container')
params_issue_arr+=(--dns "$acmesh_dns_api")
# Loop over defined variable for per container acme.sh DNS api config
for key in "${!acmesh_dns_config[@]}"; do
[[ "$key" == "DNS_API" ]] && continue
dns_api_keys+=("$key")
local value="${acmesh_dns_config[$key]}"
local -x "$key"="$value"
done
;;
*)
echo "Error: missing acme.sh DNS API for DNS challenge"
return 1
;;
esac

echo "Info: DNS challenge using $acmesh_dns_api DNS API with the following keys: ${dns_api_keys[*]} (${acmesh_dns_config_used} config)"
else
echo "Error: unknown ACME challenge method: $acme_challenge"
return 1
fi

local -n cert_keysize="LETSENCRYPT_${cid}_KEYSIZE"
if [[ -z "$cert_keysize" ]] || \
[[ ! "$cert_keysize" =~ ^(2048|3072|4096|ec-256|ec-384)$ ]]; then
[[ ! "$cert_keysize" =~ ^('2048'|'3072'|'4096'|'ec-256'|'ec-384')$ ]]; then
cert_keysize=$DEFAULT_KEY_SIZE
fi
params_issue_arr+=(--keylength "$cert_keysize")
Expand Down Expand Up @@ -206,23 +282,28 @@ function update_cert {
local ca_path_dir
ca_path_dir="$(echo "$acme_ca_uri" | cut -d : -f 2- | tr -s / | cut -d / -f 3-)"

local certificate_dir
local relative_certificate_dir
if [[ "$wildcard_certificate" == 'true' ]]; then
relative_certificate_dir="wildcard_${base_domain:2}"
else
relative_certificate_dir="$base_domain"
fi
# If we're going to use one of LE stating endpoints ...
if [[ "$acme_ca_uri" =~ ^https://acme-staging.* ]]; then
# Unset accountemail
# force config dir to 'staging'
unset accountemail
config_home="/etc/acme.sh/staging"
# Prefix test certificate directory with _test_
certificate_dir="/etc/nginx/certs/_test_$base_domain"
else
certificate_dir="/etc/nginx/certs/$base_domain"
relative_certificate_dir="_test_${relative_certificate_dir}"
fi

local absolute_certificate_dir="/etc/nginx/certs/$relative_certificate_dir"
params_issue_arr+=( \
--cert-file "${certificate_dir}/cert.pem" \
--key-file "${certificate_dir}/key.pem" \
--ca-file "${certificate_dir}/chain.pem" \
--fullchain-file "${certificate_dir}/fullchain.pem" \
--cert-file "${absolute_certificate_dir}/cert.pem" \
--key-file "${absolute_certificate_dir}/key.pem" \
--ca-file "${absolute_certificate_dir}/chain.pem" \
--fullchain-file "${absolute_certificate_dir}/fullchain.pem" \
)

[[ ! -d "$config_home" ]] && mkdir -p "$config_home"
Expand Down Expand Up @@ -342,14 +423,14 @@ function update_cert {
[[ "${2:-}" == "--force-renew" ]] && params_issue_arr+=(--force)

# Create directory for the first domain
mkdir -p "$certificate_dir"
set_ownership_and_permissions "$certificate_dir"
mkdir -p "$absolute_certificate_dir"
set_ownership_and_permissions "$absolute_certificate_dir"

for domain in "${hosts_array[@]}"; do
# Add all the domains to certificate
params_issue_arr+=(--domain "$domain")
# If enabled, add location configuration for the domain
if parse_true "${ACME_HTTP_CHALLENGE_LOCATION:=false}"; then
if [[ "$acme_challenge" == "HTTP-01" ]] && parse_true "${ACME_HTTP_CHALLENGE_LOCATION:=false}"; then
add_location_configuration "$domain" || reload_nginx
fi
done
Expand All @@ -364,24 +445,19 @@ function update_cert {
# 0 = success, 2 = RENEW_SKIP
if [[ $acmesh_return == 0 || $acmesh_return == 2 ]]; then
for domain in "${hosts_array[@]}"; do
if [[ $acme_ca_uri =~ ^https://acme-staging.* ]]; then
create_links "_test_$base_domain" "$domain" \
&& should_reload_nginx='true' \
&& should_restart_container='true'
else
create_links "$base_domain" "$domain" \
&& should_reload_nginx='true' \
&& should_restart_container='true'
fi
create_links "$relative_certificate_dir" "$domain" \
&& should_reload_nginx='true' \
&& should_restart_container='true'
done
echo "${COMPANION_VERSION:-}" > "${certificate_dir}/.companion"
set_ownership_and_permissions "${certificate_dir}/.companion"
echo "${COMPANION_VERSION:-}" > "${absolute_certificate_dir}/.companion"
set_ownership_and_permissions "${absolute_certificate_dir}/.companion"
# Make private key root readable only
for file in cert.pem key.pem chain.pem fullchain.pem; do
local file_path="${certificate_dir}/${file}"
local file_path="${absolute_certificate_dir}/${file}"
[[ -e "$file_path" ]] && set_ownership_and_permissions "$file_path"
done
local acme_private_key="$(find /etc/acme.sh -name "*.key" -path "*${hosts_array[0]}*")"
local acme_private_key
acme_private_key="$(find /etc/acme.sh -name "*.key" -path "*${hosts_array[0]}*")"
[[ -e "$acme_private_key" ]] && set_ownership_and_permissions "$acme_private_key"
# Queue nginx reload if a certificate was issued or renewed
[[ $acmesh_return -eq 0 ]] \
Expand Down Expand Up @@ -424,9 +500,15 @@ function update_certs {
if source /app/letsencrypt_user_data; then
for cid in "${LETSENCRYPT_STANDALONE_CERTS[@]}"; do
local -n hosts_array="LETSENCRYPT_${cid}_HOST"
for domain in "${hosts_array[@]}"; do
add_standalone_configuration "$domain"
done

local -n acme_challenge="ACME_${cid}_CHALLENGE"
acme_challenge="${acme_challenge:-HTTP-01}"

if [[ "$acme_challenge" == "HTTP-01" ]]; then
for domain in "${hosts_array[@]}"; do
add_standalone_configuration "$domain"
done
fi
done
reload_nginx
LETSENCRYPT_CONTAINERS+=( "${LETSENCRYPT_STANDALONE_CERTS[@]}" )
Expand Down
40 changes: 34 additions & 6 deletions app/letsencrypt_service_data.tmpl
Original file line number Diff line number Diff line change
@@ -1,5 +1,15 @@
#!/bin/bash
# shellcheck disable=SC2034
{{- $DEFAULT_ACMESH_DNS_API_CONFIG := fromYaml (coalesce $.Env.ACMESH_DNS_API_CONFIG "") }}
{{- if $DEFAULT_ACMESH_DNS_API_CONFIG }}
{{- "\n" }}declare -A DEFAULT_ACMESH_DNS_API_CONFIG=(
{{- range $key, $value := $DEFAULT_ACMESH_DNS_API_CONFIG }}
{{- "\n\t" }}['{{ $key }}']='{{ $value }}'
{{- end }}
{{- "\n" }})
{{- end }}


LETSENCRYPT_CONTAINERS=(
{{ $orderedContainers := sortObjectsByKeysDesc $ "Created" }}
{{ range $_, $container := whereExist $orderedContainers "Env.LETSENCRYPT_HOST" }}
Expand All @@ -8,11 +18,11 @@ LETSENCRYPT_CONTAINERS=(
{{/* Explicit per-domain splitting of the certificate */}}
{{ range $host := split $container.Env.LETSENCRYPT_HOST "," }}
{{ $host := trim $host }}
{{- "\n " }}'{{ printf "%.12s" $container.ID }}_{{ sha1 $host }}' # {{ $container.Name }}, created at {{ $container.Created }}
{{- "\n\t" }}'{{ printf "%.12s" $container.ID }}_{{ sha1 $host }}' # {{ $container.Name }}, created at {{ $container.Created }}
{{ end }}
{{ else }}
{{/* Default: multi-domain (SAN) certificate */}}
{{- "\n " }}'{{ printf "%.12s" $container.ID }}' # {{ $container.Name }}, created at {{ $container.Created }}
{{- "\n\t" }}'{{ printf "%.12s" $container.ID }}' # {{ $container.Name }}, created at {{ $container.Created }}
{{ end }}
{{ end }}
{{ end }}
Expand All @@ -26,6 +36,8 @@ LETSENCRYPT_CONTAINERS=(
{{ $STAGING := trim (coalesce $container.Env.LETSENCRYPT_TEST "") }}
{{ $EMAIL := trim (coalesce $container.Env.LETSENCRYPT_EMAIL "") }}
{{ $CA_URI := trim (coalesce $container.Env.ACME_CA_URI "") }}
{{ $ACME_CHALLENGE := trim (coalesce $container.Env.ACME_CHALLENGE "") }}
{{ $ACMESH_DNS_API_CONFIG := fromYaml (coalesce $container.Env.ACMESH_DNS_API_CONFIG "") }}
{{ $PREFERRED_CHAIN := trim (coalesce $container.Env.ACME_PREFERRED_CHAIN "") }}
{{ $OCSP := trim (coalesce $container.Env.ACME_OCSP "") }}
{{ $EAB_KID := trim (coalesce $container.Env.ACME_EAB_KID "") }}
Expand All @@ -47,6 +59,14 @@ LETSENCRYPT_CONTAINERS=(
{{- "\n" }}LETSENCRYPT_{{ $cid }}_{{ $hostHash }}_TEST="{{ $STAGING }}"
{{- "\n" }}LETSENCRYPT_{{ $cid }}_{{ $hostHash }}_EMAIL="{{ $EMAIL }}"
{{- "\n" }}ACME_{{ $cid }}_{{ $hostHash }}_CA_URI="{{ $CA_URI }}"
{{- "\n" }}ACME_{{ $cid }}_{{ $hostHash }}_CHALLENGE="{{ $ACME_CHALLENGE }}"
{{- if $ACMESH_DNS_API_CONFIG }}
{{- "\n" }}declare -A ACMESH_{{ $cid }}_{{ $hostHash }}_DNS_API_CONFIG=(
{{- range $key, $value := $ACMESH_DNS_API_CONFIG }}
{{- "\n\t" }}['{{ $key }}']='{{ $value }}'
{{- end }}
{{- "\n" }})
{{- end }}
{{- "\n" }}ACME_{{ $cid }}_{{ $hostHash }}_PREFERRED_CHAIN="{{ $PREFERRED_CHAIN }}"
{{- "\n" }}ACME_{{ $cid }}_{{ $hostHash }}_OCSP="{{ $OCSP }}"
{{- "\n" }}ACME_{{ $cid }}_{{ $hostHash }}_EAB_KID="{{ $EAB_KID }}"
Expand All @@ -61,14 +81,22 @@ LETSENCRYPT_CONTAINERS=(
{{- "\n" }}LETSENCRYPT_{{ $cid }}_HOST=(
{{- range $host := split $hosts "," }}
{{- $host := trim $host }}
{{- $host := trimSuffix "." $host -}}
'{{ $host }}'{{ " " }}
{{- end -}}
)
{{- $host := trimSuffix "." $host }}
{{- "\n\t" }}'{{ $host }}'
{{- end }}
{{- "\n" }})
{{- "\n" }}LETSENCRYPT_{{ $cid }}_KEYSIZE="{{ $KEYSIZE }}"
{{- "\n" }}LETSENCRYPT_{{ $cid }}_TEST="{{ $STAGING }}"
{{- "\n" }}LETSENCRYPT_{{ $cid }}_EMAIL="{{ $EMAIL }}"
{{- "\n" }}ACME_{{ $cid }}_CA_URI="{{ $CA_URI }}"
{{- "\n" }}ACME_{{ $cid }}_CHALLENGE="{{ $ACME_CHALLENGE }}"
{{- if $ACMESH_DNS_API_CONFIG }}
{{- "\n" }}declare -A ACMESH_{{ $cid }}_DNS_API_CONFIG=(
{{- range $key, $value := $ACMESH_DNS_API_CONFIG }}
{{- "\n\t" }}['{{ $key }}']='{{ $value }}'
{{- end }}
{{- "\n" }})
{{- end }}
{{- "\n" }}ACME_{{ $cid }}_PREFERRED_CHAIN="{{ $PREFERRED_CHAIN }}"
{{- "\n" }}ACME_{{ $cid }}_OCSP="{{ $OCSP }}"
{{- "\n" }}ACME_{{ $cid }}_EAB_KID="{{ $EAB_KID }}"
Expand Down
2 changes: 1 addition & 1 deletion docs/Basic-usage.md
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@
Two writable volumes must be declared on the **nginx-proxy** container so that they can be shared with the **acme-companion** container:

* `/etc/nginx/certs` to store certificates and private keys (readonly for the **nginx-proxy** container).
* `/usr/share/nginx/html` to write `http-01` challenge files.
* `/usr/share/nginx/html` to write `HTTP-01` challenge files.

Additionally, a fourth volume must be declared on the **acme-companion** container to store `acme.sh` configuration and state: `/etc/acme.sh`.

Expand Down
Loading

0 comments on commit 126f1ee

Please sign in to comment.