You can not select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.

363 lines
11 KiB

  1. # -*- mode: shell-script -*-
  2. yaml_opt_bash_env() {
  3. local prefix="$1" key value
  4. while read-0 key value; do
  5. new_prefix="${prefix}_${key^^}"
  6. if [[ "$(echo "$value" | shyaml get-type)" == "struct" ]]; then
  7. echo "$value" | yaml_opt_bash_env "${new_prefix}"
  8. else
  9. printf "%s\0%s\0" "${new_prefix/-/_}" "$value"
  10. fi
  11. done < <(shyaml key-values-0)
  12. }
  13. yaml_opt_bash_env_ignore_first_level() {
  14. local prefix="$1" key value
  15. while read-0 key value; do
  16. new_prefix="${prefix}_${key^^}"
  17. if [[ "$(echo "$value" | shyaml get-type)" == "struct" ]]; then
  18. echo "$value" | yaml_opt_bash_env "${new_prefix}"
  19. fi
  20. done < <(shyaml key-values-0)
  21. }
  22. get_dc_env() {
  23. local cfg="$1" action="$2" domain="$3"
  24. config="\
  25. $SERVICE_NAME:
  26. docker-compose:
  27. environment:"
  28. if USER_EMAIL=$(echo "$cfg" | shyaml get-value email 2>/dev/null); then
  29. config+=$'\n'" LETSENCRYPT_USER_MAIL: $USER_EMAIL"
  30. fi
  31. if environment_def="$(printf "%s" "$cfg" | shyaml -y get-value env 2>/dev/null)"; then
  32. while read-0 key value; do
  33. config+="$(printf "\n %s: %s" "$key" "$value")"
  34. done < <(e "$environment_def" | yaml_opt_bash_env_ignore_first_level LEXICON)
  35. if ! provider=$(e "$environment_def" | shyaml get-value provider 2>/dev/null); then
  36. provider=
  37. ## If no provider is given, we fallback on the first found
  38. while read-0 key value; do
  39. [[ "$(echo "$value" | shyaml get-type)" == "struct" ]] && {
  40. provider="$key"
  41. break
  42. }
  43. done < <(e "$environment_def" | shyaml key-values-0)
  44. warn "No ${WHITE}provider${NORMAL} key given, had to infer it, chose '$key'."
  45. fi
  46. if [ "$provider" ]; then
  47. config+=$(echo -en "\n LEXICON_PROVIDER: $provider")
  48. fi
  49. fi
  50. challenge_type=$(get_challenge_type "$cfg" "$action" "$domain")
  51. config+=$(echo -en "\n CHALLENGE_TYPE: $challenge_type")
  52. info "Challenge type is $challenge_type"
  53. echo "$config"
  54. }
  55. compose_get_challenge_type() {
  56. local cfg="$1"
  57. e "$cfg" | shyaml get-value "challenge-type" 2>/dev/null
  58. }
  59. letsencrypt_get_challenge_type() {
  60. local domain="$1" renewal_file
  61. renewal_file="$SERVICE_DATASTORE"/etc/letsencrypt/renewal/"$domain".conf
  62. [ -e "$renewal_file" ] || return 1
  63. grep '^pref_challs' "$renewal_file" | cut -f 2 -d "=" | nspc
  64. }
  65. letsencrypt_set_renew_before_expiry() {
  66. local domain="$1" days="$2" renewal_file
  67. renewal_file="$SERVICE_DATASTORE"/etc/letsencrypt/renewal/"$domain".conf
  68. [ -e "$renewal_file" ] || return 1
  69. sed -ri "s/^(#\s+)?(renew_before_expiry\s*=)\s*[0-9]+(\s+days)$/\2 $days\3/g" "$renewal_file"
  70. }
  71. letsencrypt_get_renew_before_expiry() {
  72. local domain="$1" renewal_file
  73. renewal_file="$SERVICE_DATASTORE"/etc/letsencrypt/renewal/"$domain".conf
  74. [ -e "$renewal_file" ] || return 1
  75. if out=$(egrep "^renew_before_expiry\s*=\s*[0-9]+\s+days$" "$renewal_file" 2>/dev/null); then
  76. e "$out" | sed -r "s/^renew_before_expiry\s*=\s*([0-9]+)\s+days$/\1/g"
  77. else
  78. err "Couldn't find 'renew_before_expiry' in letsencrypt renewal" \
  79. "configuration for domain '$domain'."
  80. return 1
  81. fi
  82. }
  83. get_challenge_type() {
  84. local cfg="$1" action="$2" domain="$3" challenge_type renewal_file challenge
  85. case "$action" in
  86. create)
  87. if ! challenge_type=$(compose_get_challenge_type "$cfg"); then
  88. warn "No ${WHITE}challenge-type${NORMAL} provided, defaulting to 'http'."
  89. challenge_type=http
  90. fi
  91. echo "$challenge_type"
  92. ;;
  93. renew)
  94. challenge=$(letsencrypt_get_challenge_type "$domain")
  95. if [[ "$challenge" =~ ^http ]]; then
  96. echo "http"
  97. else
  98. echo "$challenge"
  99. fi
  100. ;;
  101. *)
  102. err "Invalid action '$action'."
  103. ;;
  104. esac
  105. }
  106. will_need_http_access() {
  107. local cfg="$1" action="$2" domain="$3" domains args_domains remaining
  108. challenge_type=$(get_challenge_type "$cfg" "$action" "$domain")
  109. [ "$challenge_type" == "http" ] || return 1
  110. }
  111. has_existing_cert() {
  112. local domain="$1"
  113. [ -d "$SERVICE_DATASTORE/etc/letsencrypt/live/$domain" ] || return 1
  114. }
  115. letsencrypt_cert_info() {
  116. local domain="$1"
  117. compose -q --no-init --no-relations run --rm "$SERVICE_NAME" \
  118. crt info "$domain"
  119. }
  120. letsencrypt_cert_delete() {
  121. local domain="$1"
  122. compose --debug --no-init --no-relations run --rm "$SERVICE_NAME" \
  123. certbot delete --cert-name "$domain"
  124. }
  125. valid_existing_cert() {
  126. local renew_before_expiry="$1" domain="$2" args_domains domains remaining
  127. shift
  128. args_domains=("$@")
  129. has_existing_cert "$domain" || return 1
  130. info "Querying $domain for previous info..."
  131. out=$(letsencrypt_cert_info "$domain") || return 1
  132. domains=$(e "$out" | shyaml get-value domains) || return 1
  133. domains=$(printf "%s " $domains | tr " " "\n" | sort)
  134. args_domains=$(printf "%s " "${args_domains[@]}" | tr " " "\n" | sort)
  135. # info domains: "$domains"
  136. # info args_domain: "$args_domains"
  137. remaining=$(e "$out" | shyaml get-value remaining) || return 1
  138. if [ "$domains" != "$args_domains" ]; then
  139. info "Domains mismatch:"
  140. info " old: $domains"
  141. info " new: $args_domains"
  142. return 2
  143. fi
  144. if [ "$remaining" == EXPIRED ]; then
  145. info "Existing certificate expired."
  146. return 1
  147. fi
  148. if [ "$remaining" -lt "$renew_before_expiry" ]; then
  149. info "Existing certificate in renew period" \
  150. "($remaining remaining days of validity)."
  151. return 1
  152. fi
  153. }
  154. get_domain_list() {
  155. compose -q --no-init --no-relations run --rm "$SERVICE_NAME" crt list
  156. }
  157. crt() {
  158. local cfg="$1" action="$2" domain="$3" config \
  159. stopped_containers container_ids
  160. shift
  161. shift
  162. ## expiry was checked, launch the action on the real charm, but take care of
  163. ## correctly running it.
  164. ## - provide env
  165. ## - declare proper ports
  166. ## - stop containers and restart them if necessary
  167. config=$(get_dc_env "$cfg" "$action" "$domain") || return 1
  168. stopped_containers=()
  169. if will_need_http_access "$cfg" "$action" "$domain"; then
  170. container_ids=($(docker ps \
  171. --filter label="compose.project=$PROJECT_NAME" \
  172. --filter publish=80 \
  173. --format "{{.ID}}"
  174. )) || exit 1
  175. for container_id in "${container_ids[@]}"; do
  176. info "Attempting to clear port 80 by stopping $container_id"
  177. docker stop -t 5 "$container_id"
  178. stopped_containers+=("$container_id")
  179. done
  180. config+=$(echo -en "\n ports:
  181. - \"0.0.0.0:80:80\"")
  182. fi
  183. compose_opts=()
  184. if [ "$DEBUG" ]; then
  185. compose_opts+=("--debug")
  186. else
  187. compose_opts+=("--quiet")
  188. fi
  189. compose "${compose_opts[@]}" --no-init --no-relations --add-compose-content "$config" \
  190. run --service-ports --rm "$SERVICE_NAME" crt "$action" "$@"
  191. errlvl="$?"
  192. for container_id in "${stopped_containers[@]}"; do
  193. info "Attempting restart $container_id"
  194. docker start "$container_id"
  195. done
  196. return "$errlvl"
  197. }
  198. crt_create() {
  199. local force service_def cfg renew_before_expiry msg domains
  200. usage="
  201. $exname [-h|--help]
  202. $exname MAIN_DOMAIN [ALT_DOMAINS...]"
  203. force=
  204. domains=()
  205. while [ "$1" ]; do
  206. case "$1" in
  207. "--help"|"-h")
  208. print_usage
  209. return 0
  210. ;;
  211. "--force"|"-f") force=1;;
  212. *) domains+=("$1");;
  213. esac
  214. shift
  215. done
  216. if [ "${#domains[@]}" == 0 ]; then
  217. err "At least one domain should be provided as argument."
  218. print_usage >&2
  219. return 1
  220. fi
  221. service_def=$(get_compose_service_def "$SERVICE_NAME") || return 1
  222. cfg=$(e "$service_def" | shyaml get-value "options" 2>/dev/null)
  223. renew_before_expiry=$(e "$cfg" | shyaml get-value "renew-before-expiry" 30 2>/dev/null)
  224. renew_before_expiry=${renew_before_expiry:-30}
  225. valid_existing_cert "$renew_before_expiry" "${domains[@]}"
  226. valid_existing_cert="$?"
  227. if [ -z "$force" ] && [ "$valid_existing_cert" == 0 ]; then
  228. if [ "${#domains[@]}" -gt 1 ]; then
  229. msg=" (with ${domains[*]:1})"
  230. fi
  231. info "A valid cert already exists for domain ${domains[0]}$msg."
  232. return 0
  233. fi
  234. if [ "$valid_existing_cert" == 2 ]; then
  235. err "Domain mismatch detected, lets delete previous cert."
  236. letsencrypt_cert_delete "${domains[0]}" || return 1
  237. err "Previous cert for ${domains[0]} deleted."
  238. fi
  239. crt "$cfg" create "${domains[@]}" || {
  240. err "Certificate creation/renew failed for domain '${domains[0]}'."
  241. return 1
  242. }
  243. letsencrypt_set_renew_before_expiry "${domains[0]}" "$renew_before_expiry" || {
  244. err "Setting renew-before-expiry on '${domains[0]}' failed."
  245. return 1
  246. }
  247. }
  248. crt_renew() {
  249. local service_def cfg renew_before_expiry msg start domains_yml \
  250. domain domain_cfg
  251. usage="$
  252. $exname [-h|--help]
  253. "
  254. while [ "$1" ]; do
  255. case "$1" in
  256. "--help"|"-h")
  257. print_usage
  258. return 0
  259. ;;
  260. *)
  261. err "No argument required"
  262. print_usage >&2
  263. return 1
  264. ;;
  265. esac
  266. shift
  267. done
  268. service_def=$(get_compose_service_def "$SERVICE_NAME") || return 1
  269. cfg=$(e "$service_def" | shyaml get-value "options" 2>/dev/null)
  270. default_renew_before_expiry=$(e "$cfg" | shyaml get-value "renew-before-expiry" 2>/dev/null)
  271. default_renew_before_expiry=${renew_before_expiry:-30}
  272. if ! renew_before_expiry=$(letsencrypt_get_renew_before_expiry "$domain") || \
  273. [ -z "$renew_before_expiry" ]; then
  274. renew_before_expiry=$default_renew_before_expiry
  275. fi
  276. start="$SECONDS"
  277. info "Get domain list.."
  278. domains_yml=$(get_domain_list) || return 1
  279. info " .. Done ${GRAY}($((SECONDS - start))s)${NORMAL}"
  280. [ "$domains_yml" ] || {
  281. info "No domain founds"
  282. return 0
  283. }
  284. failed=()
  285. while read-0 domain domain_cfg; do
  286. remaining=$(e "$domain_cfg" | shyaml get-value "remaining") || return 1
  287. if [ "$remaining" == EXPIRED ] || [ "$remaining" -lt "$renew_before_expiry" ]; then
  288. if [ "$remaining" == EXPIRED ]; then
  289. info "Renewing domain $domain (expired)."
  290. else
  291. info "Renewing domain $domain ($remaining days left)."
  292. fi
  293. crt "$cfg" renew "$domain"
  294. if [ "$?" != "0" ]; then
  295. failed+=("$domain")
  296. err "Certificate renew of '$domain' failed."
  297. fi
  298. else
  299. info "Domain $domain does not need renewing ($remaining days left)."
  300. fi
  301. done < <(e "$domains_yml" | shyaml key-values-0)
  302. if [ "${#failed[@]}" -gt 0 ]; then
  303. err "At least one domain failed to be renewed: ${failed[@]}"
  304. return 1
  305. fi
  306. }