letsencrypt_service 14 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335
  1. #!/bin/bash
  2. # shellcheck disable=SC2120
  3. source /app/functions.sh
  4. seconds_to_wait=3600
  5. ACME_CA_URI="${ACME_CA_URI:-https://acme-v02.api.letsencrypt.org/directory}"
  6. DEFAULT_KEY_SIZE=4096
  7. REUSE_ACCOUNT_KEYS="$(lc ${REUSE_ACCOUNT_KEYS:-true})"
  8. REUSE_PRIVATE_KEYS="$(lc ${REUSE_PRIVATE_KEYS:-false})"
  9. MIN_VALIDITY_CAP=7603200
  10. DEFAULT_MIN_VALIDITY=2592000
  11. function create_link {
  12. local -r source=${1?missing source argument}
  13. local -r target=${2?missing target argument}
  14. if [[ -f "$target" ]] && [[ "$(readlink "$target")" == "$source" ]]; then
  15. set_ownership_and_permissions "$target"
  16. [[ "$(lc $DEBUG)" == true ]] && echo "$target already linked to $source"
  17. return 1
  18. else
  19. ln -sf "$source" "$target" \
  20. && set_ownership_and_permissions "$target"
  21. fi
  22. }
  23. function create_links {
  24. local -r base_domain=${1?missing base_domain argument}
  25. local -r domain=${2?missing base_domain argument}
  26. if [[ ! -f "/etc/nginx/certs/$base_domain/fullchain.pem" || \
  27. ! -f "/etc/nginx/certs/$base_domain/key.pem" ]]; then
  28. return 1
  29. fi
  30. local return_code=1
  31. create_link "./$base_domain/fullchain.pem" "/etc/nginx/certs/$domain.crt"
  32. return_code=$(( $return_code & $? ))
  33. create_link "./$base_domain/key.pem" "/etc/nginx/certs/$domain.key"
  34. return_code=$(( $return_code & $? ))
  35. if [[ -f "/etc/nginx/certs/dhparam.pem" ]]; then
  36. create_link ./dhparam.pem "/etc/nginx/certs/$domain.dhparam.pem"
  37. return_code=$(( $return_code & $? ))
  38. fi
  39. if [[ -f "/etc/nginx/certs/$base_domain/chain.pem" ]]; then
  40. create_link "./$base_domain/chain.pem" "/etc/nginx/certs/$domain.chain.pem"
  41. return_code=$(( $return_code & $? ))
  42. fi
  43. return $return_code
  44. }
  45. function cleanup_links {
  46. local -a ENABLED_DOMAINS
  47. local -a SYMLINKED_DOMAINS
  48. local -a DISABLED_DOMAINS
  49. # Create an array containing domains for which a
  50. # symlinked private key exists in /etc/nginx/certs.
  51. for symlinked_domain in /etc/nginx/certs/*.crt; do
  52. [[ -L "$symlinked_domain" ]] || continue
  53. symlinked_domain="${symlinked_domain##*/}"
  54. symlinked_domain="${symlinked_domain%*.crt}"
  55. SYMLINKED_DOMAINS+=("$symlinked_domain")
  56. done
  57. [[ "$(lc $DEBUG)" == true ]] && echo "Symlinked domains: ${SYMLINKED_DOMAINS[*]}"
  58. # Create an array containing domains that are considered
  59. # enabled (ie present on /app/letsencrypt_service_data).
  60. # shellcheck source=/dev/null
  61. source /app/letsencrypt_service_data
  62. for cid in "${LETSENCRYPT_CONTAINERS[@]}"; do
  63. host_varname="LETSENCRYPT_${cid}_HOST"
  64. hosts_array="${host_varname}[@]"
  65. for domain in "${!hosts_array}"; do
  66. # Add domain to the array storing currently enabled domains.
  67. ENABLED_DOMAINS+=("$domain")
  68. done
  69. done
  70. [[ "$(lc $DEBUG)" == true ]] && echo "Enabled domains: ${ENABLED_DOMAINS[*]}"
  71. # Create an array containing only domains for which a symlinked private key exists
  72. # in /etc/nginx/certs but that no longer have a corresponding LETSENCRYPT_HOST set
  73. # on an active container.
  74. if [[ ${#SYMLINKED_DOMAINS[@]} -gt 0 ]]; then
  75. mapfile -t DISABLED_DOMAINS < <(echo "${SYMLINKED_DOMAINS[@]}" \
  76. "${ENABLED_DOMAINS[@]}" \
  77. "${ENABLED_DOMAINS[@]}" \
  78. | tr ' ' '\n' | sort | uniq -u)
  79. fi
  80. [[ "$(lc $DEBUG)" == true ]] && echo "Disabled domains: ${DISABLED_DOMAINS[*]}"
  81. # Remove disabled domains symlinks if present.
  82. # Return 1 if nothing was removed and 0 otherwise.
  83. if [[ ${#DISABLED_DOMAINS[@]} -gt 0 ]]; then
  84. [[ "$(lc $DEBUG)" == true ]] && echo "Some domains are disabled :"
  85. for disabled_domain in "${DISABLED_DOMAINS[@]}"; do
  86. [[ "$(lc $DEBUG)" == true ]] && echo "Checking domain ${disabled_domain}"
  87. cert_folder="$(readlink -f /etc/nginx/certs/${disabled_domain}.crt)"
  88. # If the dotfile is absent, skip domain.
  89. if [[ ! -e "${cert_folder%/*}/.companion" ]]; then
  90. [[ "$(lc $DEBUG)" == true ]] && echo "No .companion file found in ${cert_folder}. ${disabled_domain} is not managed by letsencrypt-nginx-proxy-companion. Skipping domain."
  91. continue
  92. else
  93. [[ "$(lc $DEBUG)" == true ]] && echo "${disabled_domain} is managed by letsencrypt-nginx-proxy-companion. Removing unused symlinks."
  94. fi
  95. for extension in .crt .key .dhparam.pem .chain.pem; do
  96. file="${disabled_domain}${extension}"
  97. if [[ -n "${file// }" ]] && [[ -L "/etc/nginx/certs/${file}" ]]; then
  98. [[ "$(lc $DEBUG)" == true ]] && echo "Removing /etc/nginx/certs/${file}"
  99. rm -f "/etc/nginx/certs/${file}"
  100. fi
  101. done
  102. done
  103. return 0
  104. else
  105. return 1
  106. fi
  107. }
  108. function update_certs {
  109. check_nginx_proxy_container_run || return
  110. [[ -f /app/letsencrypt_service_data ]] || return
  111. # Load relevant container settings
  112. unset LETSENCRYPT_CONTAINERS
  113. # shellcheck source=/dev/null
  114. source /app/letsencrypt_service_data
  115. should_reload_nginx='false'
  116. for cid in "${LETSENCRYPT_CONTAINERS[@]}"; do
  117. should_restart_container='false'
  118. # Derive host and email variable names
  119. host_varname="LETSENCRYPT_${cid}_HOST"
  120. # Array variable indirection hack: http://stackoverflow.com/a/25880676/350221
  121. hosts_array="${host_varname}[@]"
  122. hosts_array_expanded=("${!hosts_array}")
  123. # First domain will be our base domain
  124. base_domain="${hosts_array_expanded[0]}"
  125. params_d_str=""
  126. # Use container's LETSENCRYPT_EMAIL if set, fallback to DEFAULT_EMAIL
  127. email_varname="LETSENCRYPT_${cid}_EMAIL"
  128. email_address="${!email_varname}"
  129. if [[ "$email_address" != "<no value>" ]]; then
  130. params_d_str+=" --email $email_address"
  131. elif [[ -n "${DEFAULT_EMAIL:-}" ]]; then
  132. params_d_str+=" --email $DEFAULT_EMAIL"
  133. fi
  134. keysize_varname="LETSENCRYPT_${cid}_KEYSIZE"
  135. cert_keysize="${!keysize_varname}"
  136. if [[ "$cert_keysize" == "<no value>" ]]; then
  137. cert_keysize=$DEFAULT_KEY_SIZE
  138. fi
  139. test_certificate_varname="LETSENCRYPT_${cid}_TEST"
  140. le_staging_uri="https://acme-staging-v02.api.letsencrypt.org/directory"
  141. if [[ $(lc "${!test_certificate_varname:-}") == true ]] || \
  142. [[ "$ACME_CA_URI" == "$le_staging_uri" ]]; then
  143. # Use staging Let's Encrypt ACME end point
  144. acme_ca_uri="$le_staging_uri"
  145. # Prefix test certificate directory with _test_
  146. certificate_dir="/etc/nginx/certs/_test_$base_domain"
  147. else
  148. # Use default or user provided ACME end point
  149. acme_ca_uri="$ACME_CA_URI"
  150. certificate_dir="/etc/nginx/certs/$base_domain"
  151. fi
  152. account_varname="LETSENCRYPT_${cid}_ACCOUNT_ALIAS"
  153. account_alias="${!account_varname}"
  154. if [[ "$account_alias" == "<no value>" ]]; then
  155. account_alias=default
  156. fi
  157. [[ "$(lc $DEBUG)" == true ]] && params_d_str+=" -v"
  158. [[ $REUSE_PRIVATE_KEYS == true ]] && params_d_str+=" --reuse_key"
  159. min_validity="LETSENCRYPT_${cid}_MIN_VALIDITY"
  160. min_validity="${!min_validity}"
  161. if [[ "$min_validity" == "<no value>" ]]; then
  162. min_validity=$DEFAULT_MIN_VALIDITY
  163. fi
  164. # Sanity Check
  165. # Upper Bound
  166. if [[ $min_validity -gt $MIN_VALIDITY_CAP ]]; then
  167. min_validity=$MIN_VALIDITY_CAP
  168. fi
  169. # Lower Bound
  170. if [[ $min_validity -lt $(($seconds_to_wait * 2)) ]]; then
  171. min_validity=$(($seconds_to_wait * 2))
  172. fi
  173. if [[ "${1}" == "--force-renew" ]]; then
  174. # Manually set to highest certificate lifetime given by LE CA
  175. params_d_str+=" --valid_min 7776000"
  176. else
  177. params_d_str+=" --valid_min $min_validity"
  178. fi
  179. # Create directory for the first domain,
  180. # make it root readable only and make it the cwd
  181. mkdir -p "$certificate_dir"
  182. set_ownership_and_permissions "$certificate_dir"
  183. pushd "$certificate_dir" || return
  184. for domain in "${!hosts_array}"; do
  185. # Add all the domains to certificate
  186. params_d_str+=" -d $domain"
  187. # Add location configuration for the domain
  188. add_location_configuration "$domain" || reload_nginx
  189. done
  190. if [[ -e "./account_key.json" ]] && [[ ! -e "./account_reg.json" ]]; then
  191. # If there is an account key present without account registration, this is
  192. # a leftover from the ACME v1 version of simp_le. Remove this account key.
  193. rm -f ./account_key.json
  194. [[ "$(lc $DEBUG)" == true ]] \
  195. && echo "Debug: removed ACME v1 account key $certificate_dir/account_key.json"
  196. fi
  197. # The ACME account key and registration full path are derived from the
  198. # endpoint URI + the account alias (set to 'default' if no alias is provided)
  199. account_dir="../accounts/${acme_ca_uri#*://}"
  200. if [[ $REUSE_ACCOUNT_KEYS == true ]]; then
  201. for type in "key" "reg"; do
  202. file_full_path="${account_dir}/${account_alias}_${type}.json"
  203. simp_le_file="./account_${type}.json"
  204. if [[ -f "$file_full_path" ]]; then
  205. # If there is no symlink to the account file, create it
  206. if [[ ! -L "$simp_le_file" ]]; then
  207. ln -sf "$file_full_path" "$simp_le_file" \
  208. && set_ownership_and_permissions "$simp_le_file"
  209. # If the symlink target the wrong account file, replace it
  210. elif [[ "$(readlink -f "$simp_le_file")" != "$file_full_path" ]]; then
  211. ln -sf "$file_full_path" "$simp_le_file" \
  212. && set_ownership_and_permissions "$simp_le_file"
  213. fi
  214. fi
  215. done
  216. fi
  217. echo "Creating/renewal $base_domain certificates... (${hosts_array_expanded[*]})"
  218. /usr/bin/simp_le \
  219. -f account_key.json -f account_reg.json \
  220. -f key.pem -f chain.pem -f fullchain.pem -f cert.pem \
  221. $params_d_str \
  222. --cert_key_size=$cert_keysize \
  223. --server=$acme_ca_uri \
  224. --default_root /usr/share/nginx/html/
  225. simp_le_return=$?
  226. if [[ $REUSE_ACCOUNT_KEYS == true ]]; then
  227. mkdir -p "$account_dir"
  228. for type in "key" "reg"; do
  229. file_full_path="${account_dir}/${account_alias}_${type}.json"
  230. simp_le_file="./account_${type}.json"
  231. # If the account file to be reused does not exist yet, copy it
  232. # from the CWD and replace the file in CWD with a symlink
  233. if [[ ! -f "$file_full_path" && -f "$simp_le_file" ]]; then
  234. cp "$simp_le_file" "$file_full_path"
  235. ln -sf "$file_full_path" "$simp_le_file"
  236. fi
  237. done
  238. fi
  239. popd || return
  240. if [[ $simp_le_return -ne 2 ]]; then
  241. for domain in "${!hosts_array}"; do
  242. if [[ "$acme_ca_uri" == "$le_staging_uri" ]]; then
  243. create_links "_test_$base_domain" "$domain" && should_reload_nginx='true' && should_restart_container='true'
  244. else
  245. create_links "$base_domain" "$domain" && should_reload_nginx='true' && should_restart_container='true'
  246. fi
  247. done
  248. touch "${certificate_dir}/.companion"
  249. # Set ownership and permissions of the files inside $certificate_dir
  250. for file in .companion cert.pem key.pem chain.pem fullchain.pem account_key.json account_reg.json; do
  251. file_path="${certificate_dir}/${file}"
  252. [[ -e "$file_path" ]] && set_ownership_and_permissions "$file_path"
  253. done
  254. account_path="/etc/nginx/certs/accounts/${acme_ca_uri#*://}"
  255. account_key_perm_path="${account_path}/${account_alias}_key.json"
  256. account_reg_perm_path="${account_path}/${account_alias}_reg.json"
  257. # Account key and registration files do not necessarily exists after
  258. # simp_le exit code 1. Check if they exist before perm check (#591).
  259. [[ -f "$account_key_perm_path" ]] && set_ownership_and_permissions "$account_key_perm_path"
  260. [[ -f "$account_reg_perm_path" ]] && set_ownership_and_permissions "$account_reg_perm_path"
  261. # Set ownership and permissions of the ACME account folder and its
  262. # parent folders (up to /etc/nginx/certs/accounts included)
  263. until [[ "$account_path" == /etc/nginx/certs ]]; do
  264. set_ownership_and_permissions "$account_path"
  265. account_path="$(dirname "$account_path")"
  266. done
  267. # Queue nginx reload if a certificate was issued or renewed
  268. [[ $simp_le_return -eq 0 ]] && should_reload_nginx='true' && should_restart_container='true'
  269. fi
  270. # Restart container if certs are updated and the respective environmental variable is set
  271. restart_container_var="LETSENCRYPT_${cid}_RESTART_CONTAINER"
  272. if [[ $(lc "${!restart_container_var:-}") == true ]] && [[ "$should_restart_container" == 'true' ]]; then
  273. echo "Restarting container (${cid})..."
  274. docker_restart "${cid}"
  275. fi
  276. done
  277. cleanup_links && should_reload_nginx='true'
  278. [[ "$should_reload_nginx" == 'true' ]] && reload_nginx
  279. }
  280. # Allow the script functions to be sourced without starting the Service Loop.
  281. if [ "${1}" == "--source-only" ]; then
  282. return 0
  283. fi
  284. pid=
  285. # Service Loop: When this script exits, start it again.
  286. trap '[[ $pid ]] && kill $pid; exec $0' EXIT
  287. trap 'trap - EXIT' INT TERM
  288. update_certs
  289. # Wait some amount of time
  290. echo "Sleep for ${seconds_to_wait}s"
  291. sleep $seconds_to_wait & pid=$!
  292. wait
  293. pid=