381 lines
12 KiB

# -*- 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
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
}