diff --git a/.shellcheckrc b/.shellcheckrc new file mode 100644 index 00000000..8226afb6 --- /dev/null +++ b/.shellcheckrc @@ -0,0 +1 @@ +external-sources=true diff --git a/README.md b/README.md index 934dea05..a28859bc 100644 --- a/README.md +++ b/README.md @@ -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) diff --git a/app/letsencrypt_service b/app/letsencrypt_service index 451f3121..4f589255 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 @@ -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") @@ -206,7 +282,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 @@ -214,15 +295,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" @@ -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 @@ -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 ]] \ @@ -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[@]}" ) diff --git a/app/letsencrypt_service_data.tmpl b/app/letsencrypt_service_data.tmpl index f026d99f..c5faf406 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" }} @@ -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 }} @@ -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 "") }} @@ -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 }}" @@ -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 }}" 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..d005aec3 100644 --- a/docs/Let's-Encrypt-and-ACME.md +++ b/docs/Let's-Encrypt-and-ACME.md @@ -10,6 +10,58 @@ 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 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). + +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 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_cf', 'CF_Key': 'yourCloudflareApiKey', 'CF_Email': 'yourCloudflareAccountEmail'}" \ + nginxproxy/acme-companion +``` + +Same example on a Docker compose file: +```yaml +services: + # nginx proxy container omitted + + 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: + DEFAULT_EMAIL: mail@yourdomain.tld + ACME_CHALLENGE: DNS-01 + ACMESH_DNS_API_CONFIG: |- + 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). + #### 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).