From 4137f4daabceddc44e087221fcc9b7c20c8e24ac Mon Sep 17 00:00:00 2001 From: Nicolas Duchon Date: Mon, 15 Jul 2024 22:45:50 +0200 Subject: [PATCH 01/13] style: add .shellcheckrc file --- .shellcheckrc | 1 + 1 file changed, 1 insertion(+) create mode 100644 .shellcheckrc diff --git a/.shellcheckrc b/.shellcheckrc new file mode 100644 index 00000000..8226afb6 --- /dev/null +++ b/.shellcheckrc @@ -0,0 +1 @@ +external-sources=true From 55cd21a50cf9a8fa8b03300082575c9f0b3a1e65 Mon Sep 17 00:00:00 2001 From: Nicolas Duchon Date: Mon, 15 Jul 2024 22:47:01 +0200 Subject: [PATCH 02/13] style: improve rendered template presentation --- app/letsencrypt_service_data.tmpl | 14 +++++++------- 1 file changed, 7 insertions(+), 7 deletions(-) diff --git a/app/letsencrypt_service_data.tmpl b/app/letsencrypt_service_data.tmpl index f026d99f..4a66960f 100644 --- a/app/letsencrypt_service_data.tmpl +++ b/app/letsencrypt_service_data.tmpl @@ -8,11 +8,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 }} @@ -59,12 +59,12 @@ LETSENCRYPT_CONTAINERS=( {{ else }} {{/* Default: multi-domain (SAN) certificate */}} {{- "\n" }}LETSENCRYPT_{{ $cid }}_HOST=( - {{- range $host := split $hosts "," }} + {{- range $i, $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 }}" From 7178f0790b703c59385777b7b44ba243ed239721 Mon Sep 17 00:00:00 2001 From: Nicolas Duchon Date: Mon, 15 Jul 2024 22:47:29 +0200 Subject: [PATCH 03/13] style: linting --- app/letsencrypt_service | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/app/letsencrypt_service b/app/letsencrypt_service index 451f3121..ccca6a23 100755 --- a/app/letsencrypt_service +++ b/app/letsencrypt_service @@ -155,7 +155,7 @@ function update_cert { 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") @@ -381,7 +381,8 @@ function update_cert { local file_path="${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 ]] \ From 48b40d401f7fd1420eb57e139fbf6daa9075a540 Mon Sep 17 00:00:00 2001 From: Nicolas Duchon Date: Mon, 15 Jul 2024 22:48:12 +0200 Subject: [PATCH 04/13] feat: support for DNS-01 challenge w/ acme.sh DNS API Co-authored-by: Nicolas Duchon Co-authored-by: David Michaluk --- app/letsencrypt_service | 48 +++++++++++++++++++++++++++++-- app/letsencrypt_service_data.tmpl | 18 ++++++++++++ app/start.sh | 4 ++- 3 files changed, 67 insertions(+), 3 deletions(-) diff --git a/app/letsencrypt_service b/app/letsencrypt_service index ccca6a23..eb691964 100755 --- a/app/letsencrypt_service +++ b/app/letsencrypt_service @@ -151,7 +151,41 @@ 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 + params_issue_arr+=(--webroot /usr/share/nginx/html) + elif [[ "$acme_challenge" == "DNS-01" ]]; then + # DNS-01 challenge + local -n acmesh_dns_config="ACMESH_${cid}_DNS_API_CONFIG" + + local acmesh_dns_api="${acmesh_dns_config[DNS_API]}" + if [[ -z "$acmesh_dns_api" ]]; then + echo "Error: missing acme.sh DNS API for DNS challenge" + return 1 + fi + params_issue_arr+=(--dns "$acmesh_dns_api") + + # Loop over defined variable for acme.sh DNS api config + local -a dns_api_keys + for key in "${!acmesh_dns_config[@]}"; do + [[ "$key" == "DNS_API" ]] && continue + dns_api_keys+=("$key") + local value="${acmesh_dns_config[$key]}" + declare -x "$key"="$value" + done + + echo "Info: DNS challenge using $acmesh_dns_api DNS API with the following keys: ${dns_api_keys[*]}" + else + echo "Error: unknown ACME challenge method: $acme_challenge" + return 1 + fi local -n cert_keysize="LETSENCRYPT_${cid}_KEYSIZE" if [[ -z "$cert_keysize" ]] || \ @@ -349,7 +383,7 @@ function update_cert { # 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 @@ -361,6 +395,16 @@ function update_cert { local acmesh_return=$? + # DNS challenge: clean environment variables + if [[ "$acme_challenge" == "DNS-01" ]]; then + local -n acmesh_dns_config="ACMESH_${cid}_DNS_API_CONFIG" + # Loop over defined variable for acme.sh DNS api config + for key in "${!acmesh_dns_config[@]}"; do + [[ "$key" == "DNS_API" ]] && continue + unset "$key" + done + fi + # 0 = success, 2 = RENEW_SKIP if [[ $acmesh_return == 0 || $acmesh_return == 2 ]]; then for domain in "${hosts_array[@]}"; do diff --git a/app/letsencrypt_service_data.tmpl b/app/letsencrypt_service_data.tmpl index 4a66960f..17c04071 100644 --- a/app/letsencrypt_service_data.tmpl +++ b/app/letsencrypt_service_data.tmpl @@ -26,6 +26,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 "") }} @@ -47,6 +49,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 }}" @@ -69,6 +79,14 @@ LETSENCRYPT_CONTAINERS=( {{- "\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 }}" diff --git a/app/start.sh b/app/start.sh index 94db50f4..3ce8737e 100755 --- a/app/start.sh +++ b/app/start.sh @@ -7,7 +7,9 @@ term_handler() { # shellcheck source=functions.sh source /app/functions.sh - remove_all_location_configurations + if parse_true "${ACME_HTTP_CHALLENGE_LOCATION:=false}"; then + remove_all_location_configurations + fi remove_all_standalone_configurations exit 0 From 712dd944605fdc3e43f020c9d211b6b0d52f0163 Mon Sep 17 00:00:00 2001 From: Nicolas Duchon Date: Tue, 16 Jul 2024 00:22:11 +0200 Subject: [PATCH 05/13] docs: DNS-01 challenge support --- README.md | 11 +++++++---- docs/Basic-usage.md | 2 +- docs/Let's-Encrypt-and-ACME.md | 36 ++++++++++++++++++++++++++++++++++ 3 files changed, 44 insertions(+), 5 deletions(-) diff --git a/README.md b/README.md index 934dea05..8ee5d756 100644 --- a/README.md +++ b/README.md @@ -10,20 +10,23 @@ 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). * 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](./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) diff --git a/docs/Basic-usage.md b/docs/Basic-usage.md index ade1cc91..630565b9 100644 --- a/docs/Basic-usage.md +++ b/docs/Basic-usage.md @@ -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`. diff --git a/docs/Let's-Encrypt-and-ACME.md b/docs/Let's-Encrypt-and-ACME.md index 8633a69b..dd7bcc50 100644 --- a/docs/Let's-Encrypt-and-ACME.md +++ b/docs/Let's-Encrypt-and-ACME.md @@ -10,6 +10,42 @@ The following environment variables are optional and parametrize the way the Let ### per proxyed container +#### DNS-01 ACME challenge + +In order to switch to the DNS-01 ACME challenge, set the `ACME_CHALLENGE` environment variable to `DNS-01` on your proxied container. This will also require you to set the `ACMESH_DNS_API_CONFIG` environment variable to a JSON or YAML string containing the configuration for the DNS provider you are using. Inside the JSON or YAML string, the `DNS_API` property is always required and should be set to the name of the [acme.sh DNS API](https://github.com/acmesh-official/acme.sh/tree/3.0.7/dnsapi) you want to use. + +The other properties required will depend on the DNS provider you are using. For more information on the required properties for each DNS provider, please refer to the [acme.sh documentation](https://github.com/acmesh-official/acme.sh/wiki/dnsapi) (please keep in mind that nginxproxy/acme-companion is using a fixed version of acme.sh, so the documentation might include DNS providers that are not yet available in the version used by this image). + +Example using the [Gandi Live DNS API](https://github.com/acmesh-official/acme.sh/blob/3.0.7/dnsapi/dns_gandi_livedns.sh): +```console +docker run --detach \ + --name your-proxyed-app \ + --env "VIRTUAL_HOST=yourdomain.tld" \ + --env "LETSENCRYPT_HOST=yourdomain.tld" \ + --env "ACME_CHALLENGE=DNS-01" \ + --env "ACMESH_DNS_API_CONFIG={'DNS_API': 'dns_gandi_livedns', 'GANDI_LIVEDNS_KEY': 'yourApiKey'}" \ + nginx +``` + +Same example on a Docker compose file: +```yaml +services: + # [...] + + app: + image: nginx + container_name: your-proxyed-app + environment: + VIRTUAL_HOST: yourdomain.tld + LETSENCRYPT_HOST: yourdomain.tld + ACME_CHALLENGE: DNS-01 + ACMESH_DNS_API_CONFIG: |- + DNS_API: dns_gandi_livedns + GANDI_LIVEDNS_KEY: yourApiKey +``` + +If you experience issues with the DNS-01 ACME challenge, please try to get it working outside of the container before opening an issue. If you can't get it working outside of the container, please seek support on the [acme.sh repository](https://github.com/acmesh-official). + #### Multi-domains certificates Specify multiple hosts with a comma delimiter to create multi-domains ([SAN](https://www.digicert.com/subject-alternative-name.htm)) certificates (the first domain in the list will be the base domain). From 9772acc57d7f43787a2c3f5e1faa817b61d55acd Mon Sep 17 00:00:00 2001 From: Nicolas Duchon Date: Tue, 16 Jul 2024 09:53:50 +0200 Subject: [PATCH 06/13] feat: wildcard certificates support Co-authored-by: Nicolas Duchon Co-authored-by: Gilles Filippini --- README.md | 1 + app/letsencrypt_service | 67 +++++++++++++++++++++++++++-------------- 2 files changed, 45 insertions(+), 23 deletions(-) diff --git a/README.md b/README.md index 8ee5d756..f5206a66 100644 --- a/README.md +++ b/README.md @@ -13,6 +13,7 @@ It handles the automated creation, renewal and use of SSL certificates for proxi * 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. diff --git a/app/letsencrypt_service b/app/letsencrypt_service index eb691964..a013dcc9 100755 --- a/app/letsencrypt_service +++ b/app/letsencrypt_service @@ -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} @@ -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 @@ -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 @@ -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 @@ -160,6 +178,10 @@ function update_cert { 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 @@ -240,7 +262,12 @@ 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 @@ -248,15 +275,15 @@ function update_cert { 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" @@ -376,8 +403,8 @@ 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 @@ -408,21 +435,15 @@ 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 From 124b6c034c9b52461829905f61131e83359ec411 Mon Sep 17 00:00:00 2001 From: Nicolas Duchon Date: Tue, 16 Jul 2024 13:47:16 +0200 Subject: [PATCH 07/13] refactor: remove support for global ACME_CHALLENGE --- app/letsencrypt_service | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/app/letsencrypt_service b/app/letsencrypt_service index a013dcc9..8eb0a29a 100755 --- a/app/letsencrypt_service +++ b/app/letsencrypt_service @@ -172,9 +172,7 @@ function update_cert { # ACME challenge type local -n acme_challenge="ACME_${cid}_CHALLENGE" - if [[ -z "${acme_challenge}" ]]; then - acme_challenge="${ACME_CHALLENGE:-HTTP-01}" - fi + acme_challenge="${acme_challenge:-HTTP-01}" if [[ "$acme_challenge" == "HTTP-01" ]]; then # HTTP-01 challenge From b356f51ebc731c70941e1560606383a8707a69fb Mon Sep 17 00:00:00 2001 From: Nicolas Duchon Date: Tue, 16 Jul 2024 13:47:43 +0200 Subject: [PATCH 08/13] fix: add standlone config for HTTP-01 challenge only --- app/letsencrypt_service | 12 +++++++++--- 1 file changed, 9 insertions(+), 3 deletions(-) diff --git a/app/letsencrypt_service b/app/letsencrypt_service index 8eb0a29a..62a4a8d0 100755 --- a/app/letsencrypt_service +++ b/app/letsencrypt_service @@ -488,9 +488,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[@]}" ) From 1c9c0db73093f723d80b500cfe1bdfa55135cc58 Mon Sep 17 00:00:00 2001 From: Nicolas Duchon Date: Tue, 16 Jul 2024 14:17:50 +0200 Subject: [PATCH 09/13] refactor: DNS-01 variables are scoped to the function --- app/letsencrypt_service | 12 +----------- 1 file changed, 1 insertion(+), 11 deletions(-) diff --git a/app/letsencrypt_service b/app/letsencrypt_service index 62a4a8d0..e5a70583 100755 --- a/app/letsencrypt_service +++ b/app/letsencrypt_service @@ -198,7 +198,7 @@ function update_cert { [[ "$key" == "DNS_API" ]] && continue dns_api_keys+=("$key") local value="${acmesh_dns_config[$key]}" - declare -x "$key"="$value" + local -x "$key"="$value" done echo "Info: DNS challenge using $acmesh_dns_api DNS API with the following keys: ${dns_api_keys[*]}" @@ -420,16 +420,6 @@ function update_cert { local acmesh_return=$? - # DNS challenge: clean environment variables - if [[ "$acme_challenge" == "DNS-01" ]]; then - local -n acmesh_dns_config="ACMESH_${cid}_DNS_API_CONFIG" - # Loop over defined variable for acme.sh DNS api config - for key in "${!acmesh_dns_config[@]}"; do - [[ "$key" == "DNS_API" ]] && continue - unset "$key" - done - fi - # 0 = success, 2 = RENEW_SKIP if [[ $acmesh_return == 0 || $acmesh_return == 2 ]]; then for domain in "${hosts_array[@]}"; do From c0de80f03188315f1233ab8e9b81bdc6ef3d77f8 Mon Sep 17 00:00:00 2001 From: Nicolas Duchon Date: Tue, 16 Jul 2024 23:33:20 +0200 Subject: [PATCH 10/13] feat: global & per container acme.sh DNS API config --- app/letsencrypt_service | 52 ++++++++++++++++++++++--------- app/letsencrypt_service_data.tmpl | 10 ++++++ 2 files changed, 47 insertions(+), 15 deletions(-) diff --git a/app/letsencrypt_service b/app/letsencrypt_service index e5a70583..4f589255 100755 --- a/app/letsencrypt_service +++ b/app/letsencrypt_service @@ -172,7 +172,9 @@ function update_cert { # ACME challenge type local -n acme_challenge="ACME_${cid}_CHALLENGE" - acme_challenge="${acme_challenge:-HTTP-01}" + if [[ -z "${acme_challenge}" ]]; then + acme_challenge="${ACME_CHALLENGE:-HTTP-01}" + fi if [[ "$acme_challenge" == "HTTP-01" ]]; then # HTTP-01 challenge @@ -183,25 +185,45 @@ function update_cert { params_issue_arr+=(--webroot /usr/share/nginx/html) elif [[ "$acme_challenge" == "DNS-01" ]]; then # DNS-01 challenge - local -n acmesh_dns_config="ACMESH_${cid}_DNS_API_CONFIG" + 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]}" - if [[ -z "$acmesh_dns_api" ]]; then - echo "Error: missing acme.sh DNS API for DNS challenge" - return 1 - fi - params_issue_arr+=(--dns "$acmesh_dns_api") + [[ -n "$acmesh_dns_api" ]] && acmesh_dns_config_used='container' - # Loop over defined variable for acme.sh DNS api config local -a dns_api_keys - 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 "Info: DNS challenge using $acmesh_dns_api DNS API with the following keys: ${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 diff --git a/app/letsencrypt_service_data.tmpl b/app/letsencrypt_service_data.tmpl index 17c04071..014d55ac 100644 --- a/app/letsencrypt_service_data.tmpl +++ b/app/letsencrypt_service_data.tmpl @@ -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" }} From 24d76fb42ca8c6d1a91a9511a775dc1ba451be99 Mon Sep 17 00:00:00 2001 From: Nicolas Duchon Date: Wed, 17 Jul 2024 07:33:31 +0200 Subject: [PATCH 11/13] refactor: remove unused range index --- app/letsencrypt_service_data.tmpl | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/app/letsencrypt_service_data.tmpl b/app/letsencrypt_service_data.tmpl index 014d55ac..c5faf406 100644 --- a/app/letsencrypt_service_data.tmpl +++ b/app/letsencrypt_service_data.tmpl @@ -79,7 +79,7 @@ LETSENCRYPT_CONTAINERS=( {{ else }} {{/* Default: multi-domain (SAN) certificate */}} {{- "\n" }}LETSENCRYPT_{{ $cid }}_HOST=( - {{- range $i, $host := split $hosts "," }} + {{- range $host := split $hosts "," }} {{- $host := trim $host }} {{- $host := trimSuffix "." $host }} {{- "\n\t" }}'{{ $host }}' From e6f31f0d102e4ab758a0d7d21ba07ef09b7c8f54 Mon Sep 17 00:00:00 2001 From: Nicolas Duchon Date: Wed, 17 Jul 2024 07:57:09 +0200 Subject: [PATCH 12/13] docs: update DNS-01 doc --- docs/Let's-Encrypt-and-ACME.md | 46 +++++++++++++++++++++++----------- 1 file changed, 31 insertions(+), 15 deletions(-) diff --git a/docs/Let's-Encrypt-and-ACME.md b/docs/Let's-Encrypt-and-ACME.md index dd7bcc50..d005aec3 100644 --- a/docs/Let's-Encrypt-and-ACME.md +++ b/docs/Let's-Encrypt-and-ACME.md @@ -12,36 +12,52 @@ The following environment variables are optional and parametrize the way the Let #### DNS-01 ACME challenge -In order to switch to the DNS-01 ACME challenge, set the `ACME_CHALLENGE` environment variable to `DNS-01` on your proxied container. This will also require you to set the `ACMESH_DNS_API_CONFIG` environment variable to a JSON or YAML string containing the configuration for the DNS provider you are using. Inside the JSON or YAML string, the `DNS_API` property is always required and should be set to the name of the [acme.sh DNS API](https://github.com/acmesh-official/acme.sh/tree/3.0.7/dnsapi) you want to use. +In order to switch to the DNS-01 ACME challenge, set the `ACME_CHALLENGE` environment variable to `DNS-01` on your acme-companion container. This will also require you to set the `ACMESH_DNS_API_CONFIG` environment variable to a JSON or YAML string containing the configuration for the DNS provider you are using. Inside the JSON or YAML string, the `DNS_API` property is always required and should be set to the name of the [acme.sh DNS API](https://github.com/acmesh-official/acme.sh/tree/3.0.7/dnsapi) you want to use. The other properties required will depend on the DNS provider you are using. For more information on the required properties for each DNS provider, please refer to the [acme.sh documentation](https://github.com/acmesh-official/acme.sh/wiki/dnsapi) (please keep in mind that nginxproxy/acme-companion is using a fixed version of acme.sh, so the documentation might include DNS providers that are not yet available in the version used by this image). -Example using the [Gandi Live DNS API](https://github.com/acmesh-official/acme.sh/blob/3.0.7/dnsapi/dns_gandi_livedns.sh): +Both `ACME_CHALLENGE` and `ACMESH_DNS_API_CONFIG` environment variables can also be set on the proxied application container, in which case they will override the values set on the acme-companion container, if any. + +Not: if you do not plan on using the `HTTP-01` challenge at all, you won't need to share `/usr/share/nginx/html` between the **nginx-proxy** and **acme-companion** containers, and can remove this volume from both. + +Example using [Cloudflare DNS](https://github.com/acmesh-official/acme.sh/blob/master/dnsapi/dns_cf.sh): ```console docker run --detach \ - --name your-proxyed-app \ - --env "VIRTUAL_HOST=yourdomain.tld" \ - --env "LETSENCRYPT_HOST=yourdomain.tld" \ + --name nginx-proxy-acme \ + --volume certs:/etc/nginx/certs \ + --volume acme:/etc/acme.sh \ + --volume /var/run/docker.sock:/var/run/docker.sock:ro \ + --env "DEFAULT_EMAIL=mail@yourdomain.tld" \ --env "ACME_CHALLENGE=DNS-01" \ - --env "ACMESH_DNS_API_CONFIG={'DNS_API': 'dns_gandi_livedns', 'GANDI_LIVEDNS_KEY': 'yourApiKey'}" \ - nginx + --env "ACMESH_DNS_API_CONFIG={'DNS_API': 'dns_cf', 'CF_Key': 'yourCloudflareApiKey', 'CF_Email': 'yourCloudflareAccountEmail'}" \ + nginxproxy/acme-companion ``` Same example on a Docker compose file: ```yaml services: - # [...] + # nginx proxy container omitted - app: - image: nginx - container_name: your-proxyed-app + acme: + image: nginxproxy/acme-companion + container_name: nginx-proxy-acme + volumes: + - certs:/etc/nginx/certs + - acme:/etc/acme.sh + - /var/run/docker.sock:/var/run/docker.sock:ro environment: - VIRTUAL_HOST: yourdomain.tld - LETSENCRYPT_HOST: yourdomain.tld + DEFAULT_EMAIL: mail@yourdomain.tld ACME_CHALLENGE: DNS-01 ACMESH_DNS_API_CONFIG: |- - DNS_API: dns_gandi_livedns - GANDI_LIVEDNS_KEY: yourApiKey + DNS_API: dns_cf + CF_Key: yourCloudflareApiKey + CF_Email: yourCloudflareAccountEmail + + # app container omitted + +volumes: + certs: + acme: ``` If you experience issues with the DNS-01 ACME challenge, please try to get it working outside of the container before opening an issue. If you can't get it working outside of the container, please seek support on the [acme.sh repository](https://github.com/acmesh-official). From b048f4eeca35ba967baf0d1cdcc39d830fa782a9 Mon Sep 17 00:00:00 2001 From: Nicolas Duchon Date: Fri, 19 Jul 2024 08:46:27 +0200 Subject: [PATCH 13/13] refactor: apply suggestions from code review --- README.md | 2 +- app/start.sh | 4 +--- 2 files changed, 2 insertions(+), 4 deletions(-) diff --git a/README.md b/README.md index f5206a66..a28859bc 100644 --- a/README.md +++ b/README.md @@ -24,7 +24,7 @@ It handles the automated creation, renewal and use of SSL certificates for proxi * The (sub)domains you want to issue certificates for must correctly resolve to the host. * 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](./docs/Let's-Encrypt-and-ACME.md#dns-01-acme-challenge) for more information. +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. diff --git a/app/start.sh b/app/start.sh index 3ce8737e..94db50f4 100755 --- a/app/start.sh +++ b/app/start.sh @@ -7,9 +7,7 @@ term_handler() { # shellcheck source=functions.sh source /app/functions.sh - if parse_true "${ACME_HTTP_CHALLENGE_LOCATION:=false}"; then - remove_all_location_configurations - fi + remove_all_location_configurations remove_all_standalone_configurations exit 0