381 lines
12 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 -T --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")
  132. ## check if output is valid yaml
  133. err=$(e "$out" | shyaml get-value 2>&1 >/dev/null) || {
  134. err "Cert info on '$domain' output do not seem to be valid YAML:"
  135. echo " cert info content:" >&2
  136. e "$out" | prefix " ${GRAY}|$NORMAL " >&2
  137. echo >&2
  138. echo " parsing error:" >&2
  139. e "$err" | prefix " ${RED}!$NORMAL " >&2
  140. echo >&2
  141. return 3
  142. }
  143. domains=$(e "$out" | shyaml get-value domains) || return 1
  144. domains=$(printf "%s " $domains | tr " " "\n" | sort)
  145. args_domains=$(printf "%s " "${args_domains[@]}" | tr " " "\n" | sort)
  146. # info domains: "$domains"
  147. # info args_domain: "$args_domains"
  148. remaining=$(e "$out" | shyaml get-value remaining) || return 1
  149. if [ "$domains" != "$args_domains" ]; then
  150. info "Domains mismatch:"
  151. info " old: $domains"
  152. info " new: $args_domains"
  153. return 2
  154. fi
  155. if [ "$remaining" == EXPIRED ]; then
  156. info "Existing certificate expired."
  157. return 1
  158. fi
  159. if [ "$remaining" -lt "$renew_before_expiry" ]; then
  160. info "Existing certificate in renew period" \
  161. "($remaining remaining days of validity)."
  162. return 1
  163. fi
  164. }
  165. get_domain_list() {
  166. compose -q --no-init --no-relations run --rm "$SERVICE_NAME" crt list
  167. }
  168. crt() {
  169. local cfg="$1" action="$2" domain="$3" config \
  170. stopped_containers container_ids
  171. shift
  172. shift
  173. ## expiry was checked, launch the action on the real charm, but take care of
  174. ## correctly running it.
  175. ## - provide env
  176. ## - declare proper ports
  177. ## - stop containers and restart them if necessary
  178. config=$(get_dc_env "$cfg" "$action" "$domain") || return 1
  179. stopped_containers=()
  180. if will_need_http_access "$cfg" "$action" "$domain"; then
  181. container_ids=($(docker ps \
  182. --filter label="compose.project=$PROJECT_NAME" \
  183. --filter publish=80 \
  184. --format "{{.ID}}"
  185. )) || exit 1
  186. for container_id in "${container_ids[@]}"; do
  187. info "Attempting to clear port 80 by stopping $container_id"
  188. docker stop -t 5 "$container_id"
  189. stopped_containers+=("$container_id")
  190. done
  191. config+=$(echo -en "\n ports:
  192. - \"0.0.0.0:80:80\"")
  193. fi
  194. compose_opts=()
  195. if [ "$DEBUG" ]; then
  196. compose_opts+=("--debug")
  197. else
  198. compose_opts+=("--quiet")
  199. fi
  200. compose "${compose_opts[@]}" --no-init --no-relations --add-compose-content "$config" \
  201. run --service-ports --rm "$SERVICE_NAME" crt "$action" "$@"
  202. errlvl="$?"
  203. for container_id in "${stopped_containers[@]}"; do
  204. info "Attempting restart $container_id"
  205. docker start "$container_id"
  206. done
  207. return "$errlvl"
  208. }
  209. crt_create() {
  210. local force service_def cfg renew_before_expiry msg domains
  211. usage="
  212. $exname [-h|--help]
  213. $exname MAIN_DOMAIN [ALT_DOMAINS...]"
  214. force=
  215. domains=()
  216. while [ "$1" ]; do
  217. case "$1" in
  218. "--help"|"-h")
  219. print_usage
  220. return 0
  221. ;;
  222. "--force"|"-f") force=1;;
  223. *) domains+=("$1");;
  224. esac
  225. shift
  226. done
  227. if [ "${#domains[@]}" == 0 ]; then
  228. err "At least one domain should be provided as argument."
  229. print_usage >&2
  230. return 1
  231. fi
  232. service_def=$(get_compose_service_def "$SERVICE_NAME") || return 1
  233. cfg=$(e "$service_def" | shyaml get-value "options" 2>/dev/null)
  234. renew_before_expiry=$(e "$cfg" | shyaml get-value "renew-before-expiry" 30 2>/dev/null)
  235. renew_before_expiry=${renew_before_expiry:-30}
  236. valid_existing_cert "$renew_before_expiry" "${domains[@]}"
  237. valid_existing_cert="$?"
  238. if [ -z "$force" ] && [ "$valid_existing_cert" == 0 ]; then
  239. if [ "${#domains[@]}" -gt 1 ]; then
  240. msg=" (with ${domains[*]:1})"
  241. fi
  242. info "A valid cert already exists for domain ${domains[0]}$msg."
  243. return 0
  244. fi
  245. if [ "$valid_existing_cert" == 2 ]; then
  246. err "Domain mismatch detected, lets delete previous cert."
  247. letsencrypt_cert_delete "${domains[0]}" || return 1
  248. err "Previous cert for ${domains[0]} deleted."
  249. fi
  250. if [ "$valid_existing_cert" == 3 ]; then
  251. err "Unexpected failure while checking previous cert info"
  252. return 1
  253. fi
  254. crt "$cfg" create "${domains[@]}" || {
  255. err "Certificate creation/renew failed for domain '${domains[0]}'."
  256. return 1
  257. }
  258. letsencrypt_set_renew_before_expiry "${domains[0]}" "$renew_before_expiry" || {
  259. err "Setting renew-before-expiry on '${domains[0]}' failed."
  260. return 1
  261. }
  262. }
  263. crt_renew() {
  264. local service_def cfg renew_before_expiry msg start domains_yml \
  265. domain domain_cfg
  266. usage="$
  267. $exname [-h|--help]
  268. "
  269. while [ "$1" ]; do
  270. case "$1" in
  271. "--help"|"-h")
  272. print_usage
  273. return 0
  274. ;;
  275. *)
  276. err "No argument required"
  277. print_usage >&2
  278. return 1
  279. ;;
  280. esac
  281. shift
  282. done
  283. service_def=$(get_compose_service_def "$SERVICE_NAME") || return 1
  284. cfg=$(e "$service_def" | shyaml get-value "options" 2>/dev/null)
  285. default_renew_before_expiry=$(e "$cfg" | shyaml get-value "renew-before-expiry" 2>/dev/null)
  286. default_renew_before_expiry=${renew_before_expiry:-30}
  287. if ! renew_before_expiry=$(letsencrypt_get_renew_before_expiry "$domain") || \
  288. [ -z "$renew_before_expiry" ]; then
  289. renew_before_expiry=$default_renew_before_expiry
  290. fi
  291. start="$SECONDS"
  292. info "Get domain list.."
  293. domains_yml=$(get_domain_list) || return 1
  294. info " .. Done ${GRAY}($((SECONDS - start))s)${NORMAL}"
  295. [ "$domains_yml" ] || {
  296. info "No domain founds"
  297. return 0
  298. }
  299. failed=()
  300. while read-0 domain domain_cfg; do
  301. remaining=$(e "$domain_cfg" | shyaml get-value "remaining") || return 1
  302. if [ "$remaining" == EXPIRED ] || [ "$remaining" -lt "$renew_before_expiry" ]; then
  303. if [ "$remaining" == EXPIRED ]; then
  304. info "Renewing domain $domain (expired)."
  305. else
  306. info "Renewing domain $domain ($remaining days left)."
  307. fi
  308. crt "$cfg" renew "$domain"
  309. if [ "$?" != "0" ]; then
  310. failed+=("$domain")
  311. err "Certificate renew of '$domain' failed."
  312. fi
  313. else
  314. info "Domain $domain does not need renewing ($remaining days left)."
  315. fi
  316. done < <(e "$domains_yml" | shyaml key-values-0)
  317. if [ "${#failed[@]}" -gt 0 ]; then
  318. err "At least one domain failed to be renewed: ${failed[@]}"
  319. return 1
  320. fi
  321. }