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.

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