# -*- mode: shell-script -*- yaml_opt_bash_env() { local prefix="$1" key value while read-0 key value; do new_prefix="${prefix}_${key^^}" if [[ "$(echo "$value" | shyaml get-type)" == "struct" ]]; then echo "$value" | yaml_opt_bash_env "${new_prefix}" else printf "%s\0%s\0" "${new_prefix/-/_}" "$value" fi done < <(shyaml key-values-0) } yaml_opt_bash_env_ignore_first_level() { local prefix="$1" key value while read-0 key value; do new_prefix="${prefix}_${key^^}" if [[ "$(echo "$value" | shyaml get-type)" == "struct" ]]; then echo "$value" | yaml_opt_bash_env "${new_prefix}" fi done < <(shyaml key-values-0) } get_dc_env() { local cfg="$1" action="$2" domain="$3" config="\ $SERVICE_NAME: docker-compose: environment:" if USER_EMAIL=$(echo "$cfg" | shyaml get-value email 2>/dev/null); then config+=$'\n'" LETSENCRYPT_USER_MAIL: $USER_EMAIL" fi if environment_def="$(printf "%s" "$cfg" | shyaml -y get-value env 2>/dev/null)"; then while read-0 key value; do config+="$(printf "\n %s: %s" "$key" "$value")" done < <(e "$environment_def" | yaml_opt_bash_env_ignore_first_level LEXICON) if ! provider=$(e "$environment_def" | shyaml get-value provider 2>/dev/null); then provider= ## If no provider is given, we fallback on the first found while read-0 key value; do [[ "$(echo "$value" | shyaml get-type)" == "struct" ]] && { provider="$key" break } done < <(e "$environment_def" | shyaml key-values-0) warn "No ${WHITE}provider${NORMAL} key given, had to infer it, chose '$key'." fi if [ "$provider" ]; then config+=$(echo -en "\n LEXICON_PROVIDER: $provider") fi fi challenge_type=$(get_challenge_type "$cfg" "$action" "$domain") config+=$(echo -en "\n CHALLENGE_TYPE: $challenge_type") info "Challenge type is $challenge_type" echo "$config" } compose_get_challenge_type() { local cfg="$1" e "$cfg" | shyaml get-value "challenge-type" 2>/dev/null } letsencrypt_get_challenge_type() { local domain="$1" renewal_file renewal_file="$SERVICE_DATASTORE"/etc/letsencrypt/renewal/"$domain".conf [ -e "$renewal_file" ] || return 1 grep '^pref_challs' "$renewal_file" | cut -f 2 -d "=" | nspc } letsencrypt_set_renew_before_expiry() { local domain="$1" days="$2" renewal_file domain="${domain#\*.}" ## remove '*.' from wildcard domain renewal_file="$SERVICE_DATASTORE"/etc/letsencrypt/renewal/"$domain".conf [ -e "$renewal_file" ] || return 1 sed -ri "s/^(#\s+)?(renew_before_expiry\s*=)\s*[0-9]+(\s+days)$/\2 $days\3/g" "$renewal_file" } letsencrypt_get_renew_before_expiry() { local domain="$1" renewal_file renewal_file="$SERVICE_DATASTORE"/etc/letsencrypt/renewal/"$domain".conf [ -e "$renewal_file" ] || return 1 if out=$(egrep "^renew_before_expiry\s*=\s*[0-9]+\s+days$" "$renewal_file" 2>/dev/null); then e "$out" | sed -r "s/^renew_before_expiry\s*=\s*([0-9]+)\s+days$/\1/g" else err "Couldn't find 'renew_before_expiry' in letsencrypt renewal" \ "configuration for domain '$domain'." return 1 fi } get_challenge_type() { local cfg="$1" action="$2" domain="$3" challenge_type renewal_file challenge case "$action" in create) if ! challenge_type=$(compose_get_challenge_type "$cfg"); then warn "No ${WHITE}challenge-type${NORMAL} provided, defaulting to 'http'." challenge_type=http fi echo "$challenge_type" ;; renew) challenge=$(letsencrypt_get_challenge_type "$domain") if [[ "$challenge" =~ ^http ]]; then echo "http" else echo "$challenge" fi ;; *) err "Invalid action '$action'." ;; esac } will_need_http_access() { local cfg="$1" action="$2" domain="$3" domains args_domains remaining challenge_type=$(get_challenge_type "$cfg" "$action" "$domain") [ "$challenge_type" == "http" ] || return 1 } has_existing_cert() { local domain="$1" [ -d "$SERVICE_DATASTORE/etc/letsencrypt/live/$domain" ] || return 1 } letsencrypt_cert_info() { local domain="$1" compose -q --no-init --no-relations run -T --rm "$SERVICE_NAME" \ crt info "$domain" } letsencrypt_cert_delete() { local domain="$1" compose --debug --no-init --no-relations run --rm "$SERVICE_NAME" \ certbot delete --cert-name "$domain" } valid_existing_cert() { local renew_before_expiry="$1" domain="$2" args_domains domains remaining shift args_domains=("$@") has_existing_cert "$domain" || return 1 info "Querying $domain for previous info..." out=$(letsencrypt_cert_info "$domain") ## check if output is valid yaml err=$(e "$out" | shyaml get-value 2>&1 >/dev/null) || { err "Cert info on '$domain' output do not seem to be valid YAML:" echo " cert info content:" >&2 e "$out" | prefix " ${GRAY}|$NORMAL " >&2 echo >&2 echo " parsing error:" >&2 e "$err" | prefix " ${RED}!$NORMAL " >&2 echo >&2 return 3 } domains=$(e "$out" | shyaml get-value domains) || return 1 domains=$(printf "%s " $domains | tr " " "\n" | sort) args_domains=$(printf "%s " "${args_domains[@]}" | tr " " "\n" | sort) # info domains: "$domains" # info args_domain: "$args_domains" remaining=$(e "$out" | shyaml get-value remaining) || return 1 if [ "$domains" != "$args_domains" ]; then info "Domains mismatch:" info " old: $domains" info " new: $args_domains" return 2 fi if [ "$remaining" == EXPIRED ]; then info "Existing certificate expired." return 1 fi if [ "$remaining" -lt "$renew_before_expiry" ]; then info "Existing certificate in renew period" \ "($remaining remaining days of validity)." return 1 fi } get_domain_list() { compose -q --no-init --no-relations run --rm "$SERVICE_NAME" crt list } crt() { local cfg="$1" action="$2" domain="$3" config \ stopped_containers container_ids shift shift ## expiry was checked, launch the action on the real charm, but take care of ## correctly running it. ## - provide env ## - declare proper ports ## - stop containers and restart them if necessary config=$(get_dc_env "$cfg" "$action" "$domain") || return 1 stopped_containers=() if will_need_http_access "$cfg" "$action" "$domain"; then container_ids=($(docker ps \ --filter label="compose.project=$PROJECT_NAME" \ --filter publish=80 \ --format "{{.ID}}" )) || exit 1 for container_id in "${container_ids[@]}"; do info "Attempting to clear port 80 by stopping $container_id" docker stop -t 5 "$container_id" stopped_containers+=("$container_id") done config+=$(echo -en "\n ports: - \"0.0.0.0:80:80\"") fi compose_opts=() if [ "$DEBUG" ]; then compose_opts+=("--debug") else compose_opts+=("--quiet") fi compose "${compose_opts[@]}" --no-init --no-relations --add-compose-content "$config" \ run --service-ports --rm "$SERVICE_NAME" crt "$action" "$@" errlvl="$?" for container_id in "${stopped_containers[@]}"; do info "Attempting restart $container_id" docker start "$container_id" done return "$errlvl" } crt_create() { local force service_def cfg renew_before_expiry msg domains usage=" $exname [-h|--help] $exname MAIN_DOMAIN [ALT_DOMAINS...]" force= domains=() while [ "$1" ]; do case "$1" in "--help"|"-h") print_usage return 0 ;; "--force"|"-f") force=1;; *) domains+=("$1");; esac shift done if [ "${#domains[@]}" == 0 ]; then err "At least one domain should be provided as argument." print_usage >&2 return 1 fi service_def=$(get_compose_service_def "$SERVICE_NAME") || return 1 cfg=$(e "$service_def" | shyaml get-value "options" 2>/dev/null) renew_before_expiry=$(e "$cfg" | shyaml get-value "renew-before-expiry" 30 2>/dev/null) renew_before_expiry=${renew_before_expiry:-30} valid_existing_cert "$renew_before_expiry" "${domains[@]}" valid_existing_cert="$?" if [ -z "$force" ] && [ "$valid_existing_cert" == 0 ]; then if [ "${#domains[@]}" -gt 1 ]; then msg=" (with ${domains[*]:1})" fi info "A valid cert already exists for domain ${domains[0]}$msg." return 0 fi if [ "$valid_existing_cert" == 2 ]; then err "Domain mismatch detected, lets delete previous cert." letsencrypt_cert_delete "${domains[0]}" || return 1 err "Previous cert for ${domains[0]} deleted." fi if [ "$valid_existing_cert" == 3 ]; then err "Unexpected failure while checking previous cert info" return 1 fi crt "$cfg" create "${domains[@]}" || { err "Certificate creation/renew failed for domain '${domains[0]}'." return 1 } letsencrypt_set_renew_before_expiry "${domains[0]}" "$renew_before_expiry" || { err "Setting renew-before-expiry on '${domains[0]}' failed." return 1 } } crt_renew() { local service_def cfg renew_before_expiry msg start domains_yml \ domain domain_cfg usage="$ $exname [-h|--help] " while [ "$1" ]; do case "$1" in "--help"|"-h") print_usage return 0 ;; *) err "No argument required" print_usage >&2 return 1 ;; esac shift done service_def=$(get_compose_service_def "$SERVICE_NAME") || return 1 cfg=$(e "$service_def" | shyaml get-value "options" 2>/dev/null) default_renew_before_expiry=$(e "$cfg" | shyaml get-value "renew-before-expiry" 2>/dev/null) default_renew_before_expiry=${renew_before_expiry:-30} if ! renew_before_expiry=$(letsencrypt_get_renew_before_expiry "$domain") || \ [ -z "$renew_before_expiry" ]; then renew_before_expiry=$default_renew_before_expiry fi start="$SECONDS" info "Get domain list.." domains_yml=$(get_domain_list) || return 1 info " .. Done ${GRAY}($((SECONDS - start))s)${NORMAL}" [ "$domains_yml" ] || { info "No domain founds" return 0 } failed=() while read-0 domain domain_cfg; do remaining=$(e "$domain_cfg" | shyaml get-value "remaining") || return 1 if [ "$remaining" == EXPIRED ] || [ "$remaining" -lt "$renew_before_expiry" ]; then if [ "$remaining" == EXPIRED ]; then info "Renewing domain $domain (expired)." else info "Renewing domain $domain ($remaining days left)." fi crt "$cfg" renew "$domain" if [ "$?" != "0" ]; then failed+=("$domain") err "Certificate renew of '$domain' failed." fi else info "Domain $domain does not need renewing ($remaining days left)." fi done < <(e "$domains_yml" | shyaml key-values-0) if [ "${#failed[@]}" -gt 0 ]; then err "At least one domain failed to be renewed: ${failed[@]}" return 1 fi }