Ctrl K
ComunidadGuías…

cli/cert.sh

Rama main · réplica de lectura del repositorio original.

Archivo original ↗Preguntar a KiBot
#!/usr/bin/env bash

set -euo pipefail

usage() {
	echo "Usage: $0 <namespace> <deployment>" >&2
}

fail() {
	echo "certbot magicdns auth hook: $1" >&2
	exit 1
}

validate_dns_label() {
	value=$1
	[ -n "$value" ] || return 1
	[ "${#value}" -le 63 ] || return 1
	case "$value" in
	*[!a-z0-9-]* | -* | *-) return 1 ;;
	esac
}

validate_dns_subdomain() {
	subdomain=$1
	[ -n "$subdomain" ] || return 1
	[ "${#subdomain}" -le 253 ] || return 1
	case "$subdomain" in
	.* | *. | *..*) return 1 ;;
	esac
	while [ "${subdomain#*.}" != "$subdomain" ]; do
		validate_dns_label "${subdomain%%.*}" || return 1
		subdomain=${subdomain#*.}
	done
	validate_dns_label "$subdomain"
}

normalize_uint() {
	number=$1
	allow_zero=$2
	case "$number" in
	'' | *[!0-9]*) return 1 ;;
	esac
	while [ "${number#0}" != "$number" ]; do
		number=${number#0}
	done
	[ -n "$number" ] || number=0
	[ "${#number}" -le 10 ] || return 1
	if [ "${#number}" -eq 10 ] && [ "$number" -gt 2147483647 ]; then
		return 1
	fi
	if [ "$allow_zero" -eq 0 ] && [ "$number" -eq 0 ]; then
		return 1
	fi
	NORMALIZED_UINT=$number
}

validate_resolver() {
	server=$1
	[ -n "$server" ] || return 0
	[ "${#server}" -le 253 ] || return 1
	case "$server" in
	*[!A-Za-z0-9.-]* | .* | *. | *..* | -* | *-) return 1 ;;
	esac
}

require_command() {
	command -v "$1" >/dev/null 2>&1 || fail "required command is unavailable: $1"
}

read_deployment() {
	if ! DEPLOYMENT_JSON=$(kubectl -n "$namespace" get deployment "$deployment" -o json 2>/dev/null); then
		fail "could not read Deployment"
	fi
}

validate_mapped_variable() {
	mapped_name=$1
	if ! printf '%s' "$DEPLOYMENT_JSON" | jq -e --arg name "$mapped_name" '
        [.spec.template.spec.containers[]? as $container
          | ($container.env // [])[]?
          | select(.name == $name)
          | {container: $container.name, entry: .}]
        | length == 1
          and (.[0].container | type == "string" and length > 0)
          and (.[0].entry | has("value"))
          and (.[0].entry.value | type == "string" and length > 0)
          and (.[0].entry | has("valueFrom") | not)
    ' >/dev/null 2>&1; then
		fail "Deployment variable $mapped_name must exist exactly once as a direct non-empty value"
	fi
}

mapped_container() {
	printf '%s' "$DEPLOYMENT_JSON" | jq -er --arg name "$1" '
        .spec.template.spec.containers[]
        | select(any((.env // [])[]; .name == $name))
        | .name
    ' 2>/dev/null
}

mapped_value() {
	printf '%s' "$DEPLOYMENT_JSON" | jq -er --arg name "$1" '
        .spec.template.spec.containers[]
        | (.env // [])[]
        | select(.name == $name)
        | .value
    ' 2>/dev/null
}

dns_record_matches() {
	expected=$1
	printf '%s\n' "$DIG_OUTPUT" | jq -Rse --arg expected "$expected" '
        split("\n")
        | map(select(length > 0))
        | any(.[];
            test("^[[:space:]]*\\\"(?:[^\\\"\\\\]|\\\\.)*\\\"(?:[[:space:]]+\\\"(?:[^\\\"\\\\]|\\\\.)*\\\")*[[:space:]]*$")
            and ([scan("\\\"(?:[^\\\"\\\\]|\\\\.)*\\\"") | fromjson] | add == $expected)
          )
    ' >/dev/null 2>&1
}

quote_for_shell() {
	printf "'"
	printf '%s' "$1" | sed "s/'/'\\\\''/g"
	printf "'"
}

certbot_auth_hook() {
	if [ "$#" -ne 2 ]; then
		fail "expected Kubernetes namespace and Deployment name"
	fi

	namespace=$1
	deployment=$2
	validate_dns_label "$namespace" || fail "invalid Kubernetes namespace"
	validate_dns_subdomain "$deployment" || fail "invalid Kubernetes Deployment name"

	[ "${CERTBOT_DOMAIN+x}" = x ] && [ -n "$CERTBOT_DOMAIN" ] || fail "CERTBOT_DOMAIN is required"
	[ "${CERTBOT_VALIDATION+x}" = x ] && [ -n "$CERTBOT_VALIDATION" ] || fail "CERTBOT_VALIDATION is required"
	[ "${CERTBOT_REMAINING_CHALLENGES+x}" = x ] || fail "CERTBOT_REMAINING_CHALLENGES is required"

	case "$CERTBOT_DOMAIN" in
	ai.local.nhost.run) mapped_variable=ACME_CHALLENGE_AI ;;
	auth.local.nhost.run) mapped_variable=ACME_CHALLENGE_AUTH ;;
	dashboard.local.nhost.run) mapped_variable=ACME_CHALLENGE_DASHBOARD ;;
	db.local.nhost.run) mapped_variable=ACME_CHALLENGE_DB ;;
	functions.local.nhost.run) mapped_variable=ACME_CHALLENGE_FUNCTIONS ;;
	graphql.local.nhost.run) mapped_variable=ACME_CHALLENGE_GRAPHQL ;;
	hasura.local.nhost.run) mapped_variable=ACME_CHALLENGE_HASURA ;;
	mailhog.local.nhost.run) mapped_variable=ACME_CHALLENGE_MAILHOG ;;
	storage.local.nhost.run) mapped_variable=ACME_CHALLENGE_STORAGE ;;
	*) fail "unsupported Certbot domain" ;;
	esac

	normalize_uint "$CERTBOT_REMAINING_CHALLENGES" 1 || fail "CERTBOT_REMAINING_CHALLENGES must be a non-negative base-10 integer"
	remaining_challenges=$NORMALIZED_UINT

	ACME_ROLLOUT_TIMEOUT_SECONDS=${ACME_ROLLOUT_TIMEOUT_SECONDS-300}
	ACME_DNS_TIMEOUT_SECONDS=${ACME_DNS_TIMEOUT_SECONDS-300}
	ACME_DNS_POLL_INTERVAL_SECONDS=${ACME_DNS_POLL_INTERVAL_SECONDS-2}
	ACME_DNS_SERVER=${ACME_DNS_SERVER:-}

	normalize_uint "$ACME_ROLLOUT_TIMEOUT_SECONDS" 0 || fail "ACME_ROLLOUT_TIMEOUT_SECONDS must be a positive base-10 integer"
	rollout_timeout=$NORMALIZED_UINT
	normalize_uint "$ACME_DNS_TIMEOUT_SECONDS" 0 || fail "ACME_DNS_TIMEOUT_SECONDS must be a positive base-10 integer"
	dns_timeout=$NORMALIZED_UINT
	normalize_uint "$ACME_DNS_POLL_INTERVAL_SECONDS" 0 || fail "ACME_DNS_POLL_INTERVAL_SECONDS must be a positive base-10 integer"
	poll_interval=$NORMALIZED_UINT
	validate_resolver "$ACME_DNS_SERVER" || fail "ACME_DNS_SERVER must be a hostname or IPv4 address for port 53"

	require_command kubectl
	require_command jq
	require_command dig
	require_command date
	require_command sleep

	read_deployment
	validate_mapped_variable "$mapped_variable"
	if ! container=$(mapped_container "$mapped_variable"); then
		fail "could not identify the container for $mapped_variable"
	fi

	if ! kubectl -n "$namespace" set env "deployment/$deployment" --containers="$container" \
		"$mapped_variable=$CERTBOT_VALIDATION" >/dev/null 2>&1; then
		fail "could not update Deployment variable $mapped_variable"
	fi
	echo "Published challenge for $CERTBOT_DOMAIN"

	if [ "$remaining_challenges" -ne 0 ]; then
		exit 0
	fi

	read_deployment
	for variable in \
		ACME_CHALLENGE_AI \
		ACME_CHALLENGE_AUTH \
		ACME_CHALLENGE_DASHBOARD \
		ACME_CHALLENGE_DB \
		ACME_CHALLENGE_FUNCTIONS \
		ACME_CHALLENGE_GRAPHQL \
		ACME_CHALLENGE_HASURA \
		ACME_CHALLENGE_MAILHOG \
		ACME_CHALLENGE_STORAGE; do
		validate_mapped_variable "$variable"
	done

	if ! challenge_ai=$(mapped_value ACME_CHALLENGE_AI) ||
		! challenge_auth=$(mapped_value ACME_CHALLENGE_AUTH) ||
		! challenge_dashboard=$(mapped_value ACME_CHALLENGE_DASHBOARD) ||
		! challenge_db=$(mapped_value ACME_CHALLENGE_DB) ||
		! challenge_functions=$(mapped_value ACME_CHALLENGE_FUNCTIONS) ||
		! challenge_graphql=$(mapped_value ACME_CHALLENGE_GRAPHQL) ||
		! challenge_hasura=$(mapped_value ACME_CHALLENGE_HASURA) ||
		! challenge_mailhog=$(mapped_value ACME_CHALLENGE_MAILHOG) ||
		! challenge_storage=$(mapped_value ACME_CHALLENGE_STORAGE); then
		fail "could not capture Deployment challenge values"
	fi

	if ! deployment_uid=$(printf '%s' "$DEPLOYMENT_JSON" | jq -er '.metadata.uid | select(type == "string" and length > 0)' 2>/dev/null) ||
		! deployment_generation=$(printf '%s' "$DEPLOYMENT_JSON" | jq -er '.metadata.generation | select(type == "number" and floor == . and . > 0)' 2>/dev/null); then
		fail "Deployment identity or generation is invalid"
	fi
	normalize_uint "$deployment_generation" 0 || fail "Deployment generation is outside the supported range"
	deployment_generation=$NORMALIZED_UINT

	if ! kubectl -n "$namespace" rollout status "deployment/$deployment" \
		--timeout="${rollout_timeout}s" >/dev/null 2>&1; then
		fail "Deployment rollout did not converge"
	fi

	read_deployment
	if ! printf '%s' "$DEPLOYMENT_JSON" | jq -e \
		--arg uid "$deployment_uid" \
		--argjson generation "$deployment_generation" '
	        .metadata.uid == $uid
	        and .metadata.generation == $generation
	        and (.status.observedGeneration | type == "number" and floor == . and . >= $generation)
	    ' >/dev/null 2>&1; then
		fail "Deployment changed or was not observed at the captured generation"
	fi

	if ! started_at=$(date +%s 2>/dev/null); then
		fail "could not read the system clock"
	fi
	normalize_uint "$started_at" 1 || fail "system clock returned an invalid value"
	started_at=$NORMALIZED_UINT
	deadline=$((started_at + dns_timeout))

	while :; do
		all_ready=1
		for record_and_value in \
			"_acme-challenge.ai.local.nhost.run|$challenge_ai" \
			"_acme-challenge.auth.local.nhost.run|$challenge_auth" \
			"_acme-challenge.dashboard.local.nhost.run|$challenge_dashboard" \
			"_acme-challenge.db.local.nhost.run|$challenge_db" \
			"_acme-challenge.functions.local.nhost.run|$challenge_functions" \
			"_acme-challenge.graphql.local.nhost.run|$challenge_graphql" \
			"_acme-challenge.hasura.local.nhost.run|$challenge_hasura" \
			"_acme-challenge.mailhog.local.nhost.run|$challenge_mailhog" \
			"_acme-challenge.storage.local.nhost.run|$challenge_storage"; do
			record=${record_and_value%%|*}
			expected=${record_and_value#*|}
			if ! now=$(date +%s 2>/dev/null); then
				fail "could not read the system clock"
			fi
			normalize_uint "$now" 1 || fail "system clock returned an invalid value"
			now=$NORMALIZED_UINT
			if [ "$now" -ge "$deadline" ]; then
				all_ready=0
				echo "DNS deadline reached before querying $record" >&2
				continue
			fi

			if [ -n "$ACME_DNS_SERVER" ]; then
				if DIG_OUTPUT=$(dig "@$ACME_DNS_SERVER" +time=1 +tries=1 +short TXT "$record" 2>/dev/null); then
					dig_ok=1
				else
					dig_ok=0
				fi
			else
				if DIG_OUTPUT=$(dig +time=1 +tries=1 +short TXT "$record" 2>/dev/null); then
					dig_ok=1
				else
					dig_ok=0
				fi
			fi

			if [ "$dig_ok" -eq 1 ] && dns_record_matches "$expected"; then
				echo "DNS record $record is ready"
			else
				all_ready=0
				echo "DNS record $record is pending" >&2
			fi
		done

		if [ "$all_ready" -eq 1 ]; then
			echo "All magicdns ACME challenge records are ready"
			exit 0
		fi

		if ! now=$(date +%s 2>/dev/null); then
			fail "could not read the system clock"
		fi
		normalize_uint "$now" 1 || fail "system clock returned an invalid value"
		now=$NORMALIZED_UINT
		if [ "$now" -ge "$deadline" ]; then
			fail "DNS challenge records did not converge before the deadline"
		fi
		remaining_time=$((deadline - now))
		sleep_for=$poll_interval
		if [ "$sleep_for" -gt "$remaining_time" ]; then
			sleep_for=$remaining_time
		fi
		if ! sleep "$sleep_for"; then
			fail "DNS polling sleep failed"
		fi
	done
}

if [ "${1:-}" = --certbot-auth-hook ]; then
	shift
	certbot_auth_hook "$@"
	exit 0
fi

if [ "$#" -ne 2 ]; then
	usage
	exit 2
fi

namespace=$1
deployment=$2
if ! validate_dns_label "$namespace"; then
	echo "Invalid Kubernetes namespace" >&2
	exit 2
fi
if ! validate_dns_subdomain "$deployment"; then
	echo "Invalid Kubernetes Deployment name" >&2
	exit 2
fi

for required_command in kubectl jq dig; do
	if ! command -v "$required_command" >/dev/null 2>&1; then
		echo "Required command is unavailable: $required_command" >&2
		exit 1
	fi
done
if ! kubectl -n "$namespace" get deployment "$deployment" -o name >/dev/null 2>&1; then
	echo "Could not find Kubernetes Deployment $namespace/$deployment" >&2
	exit 1
fi

if ! script_dir=$(CDPATH='' cd -P -- "$(dirname -- "$0")" 2>/dev/null && pwd -P); then
	echo "Could not resolve cert.sh directory" >&2
	exit 1
fi
script_path=$script_dir/cert.sh
# Certbot validates the first word without shell-unquoting it, so keep bash literal.
auth_hook_command="bash $(quote_for_shell "$script_path") --certbot-auth-hook $(quote_for_shell "$namespace") $(quote_for_shell "$deployment")"

certbot certonly \
	-v \
	--dns-route53 \
	-d local.ai.nhost.run \
	-d local.auth.nhost.run \
	-d local.dashboard.nhost.run \
	-d local.db.nhost.run \
	-d local.functions.nhost.run \
	-d local.graphql.nhost.run \
	-d local.hasura.nhost.run \
	-d local.mailhog.nhost.run \
	-d local.storage.nhost.run \
	-m 'admin@nhost.io' \
	--non-interactive \
	--agree-tos \
	--server https://acme-v02.api.letsencrypt.org/directory \
	--logs-dir letsencrypt \
	--config-dir letsencrypt \
	--work-dir letsencrypt

cp letsencrypt/live/local.ai.nhost.run/fullchain.pem ssl/.ssl/local-fullchain.pem
cp letsencrypt/live/local.ai.nhost.run/privkey.pem ssl/.ssl/local-privkey.pem

certbot certonly \
	-v \
	--manual \
	--preferred-challenges dns \
	--manual-auth-hook "$auth_hook_command" \
	-d *.ai.local.nhost.run \
	-d *.auth.local.nhost.run \
	-d *.dashboard.local.nhost.run \
	-d *.db.local.nhost.run \
	-d *.functions.local.nhost.run \
	-d *.graphql.local.nhost.run \
	-d *.hasura.local.nhost.run \
	-d *.mailhog.local.nhost.run \
	-d *.storage.local.nhost.run \
	-m 'admin@nhost.io' \
	--agree-tos \
	--server https://acme-v02.api.letsencrypt.org/directory \
	--logs-dir letsencrypt \
	--config-dir letsencrypt \
	--work-dir letsencrypt

cp letsencrypt/live/ai.local.nhost.run/fullchain.pem ssl/.ssl/sub-fullchain.pem
cp letsencrypt/live/ai.local.nhost.run/privkey.pem ssl/.ssl/sub-privkey.pem

rm -rf letsencrypt