#!/usr/bin/env bash # # Reconcile the set of TLS-enabled domains on this server against a desired # list. Idempotent and safe to run on a timer: it issues what is missing, # leaves what is current alone, and disables what has been removed. # # The nginx catch-all already serves ANY Host over HTTP with no config, so a # new domain works on port 80 the moment DNS resolves. This script exists only # because TLS needs a certificate per name. # # sync-domains.sh # reconcile from $DOMAINS_SOURCE # sync-domains.sh --dry-run # print the plan, change nothing # # Config: /etc/marketplaces/domains.env # DOMAINS_SOURCE=file:/etc/marketplaces/domains.txt # DOMAINS_SOURCE=https://api.example.com/api/admin/v2/domains (JSON array) # CERTBOT_EMAIL=ops@example.com # MAX_ISSUE_PER_RUN=10 # # Let's Encrypt caps new certificates per registered domain per week. The # per-run issuance cap keeps a misconfigured source from burning that budget # in one pass; the remainder is picked up on the next run. set -euo pipefail CONFIG="/etc/marketplaces/domains.env" STATE_DIR="/var/lib/marketplaces" DRY_RUN=0 RENEW_WINDOW_DAYS=30 [[ "${1:-}" == "--dry-run" ]] && DRY_RUN=1 # shellcheck source=/dev/null [[ -f "$CONFIG" ]] && source "$CONFIG" DOMAINS_SOURCE="${DOMAINS_SOURCE:-file:/etc/marketplaces/domains.txt}" CERTBOT_EMAIL="${CERTBOT_EMAIL:-}" MAX_ISSUE_PER_RUN="${MAX_ISSUE_PER_RUN:-10}" WILDCARD_APEX="${WILDCARD_APEX:-}" [[ $EUID -eq 0 ]] || { echo "must run as root" >&2; exit 1; } [[ -n "$CERTBOT_EMAIL" ]] || { echo "CERTBOT_EMAIL not set in $CONFIG" >&2; exit 1; } mkdir -p "$STATE_DIR" log() { printf '%s %s\n' "$(date -Is)" "$*"; } # ---------------------------------------------------------------- desired set fetch_desired() { case "$DOMAINS_SOURCE" in file:*) local path="${DOMAINS_SOURCE#file:}" [[ -f "$path" ]] || { log "source file $path missing"; return 1; } # one domain per line; # comments and blanks ignored sed -e 's/#.*//' -e 's/[[:space:]]//g' "$path" | grep -v '^$' || true ;; http://*|https://*) # Expected shape: ["a.example.com","b.example.com"] or # [{"domain":"a.example.com","status":"active"}, ...] local body body="$(curl -fsS --max-time 20 ${DOMAINS_API_TOKEN:+-H "Authorization: Bearer $DOMAINS_API_TOKEN"} "$DOMAINS_SOURCE")" || { log "ERROR: could not fetch $DOMAINS_SOURCE — leaving current config untouched" return 1 } echo "$body" | jq -r ' if type=="array" and (.[0]|type)=="object" then .[] | select((.status // "active") == "active") | .domain else .[] end' 2>/dev/null || { log "ERROR: unparseable response from $DOMAINS_SOURCE" return 1 } ;; *) log "ERROR: unsupported DOMAINS_SOURCE: $DOMAINS_SOURCE"; return 1 ;; esac } # A source failure must never be read as "all domains removed". Bail instead. if ! DESIRED_RAW="$(fetch_desired)"; then log "reconcile aborted: desired set unavailable" exit 1 fi # Reject anything that is not a plausible hostname before it reaches certbot # or an nginx server_name. DESIRED="$(echo "$DESIRED_RAW" | grep -Ei '^[a-z0-9]([a-z0-9-]*[a-z0-9])?(\.[a-z0-9]([a-z0-9-]*[a-z0-9])?)+$' | sort -u || true)" REJECTED="$(comm -23 <(echo "$DESIRED_RAW" | sort -u) <(echo "$DESIRED") || true)" [[ -n "$REJECTED" ]] && log "WARNING: ignoring malformed entries: $(echo "$REJECTED" | tr '\n' ' ')" if [[ -z "$DESIRED" ]]; then log "desired set is empty — nothing to do (not treating this as 'remove everything')" exit 0 fi log "desired: $(echo "$DESIRED" | wc -l) domain(s)" # ------------------------------------------------------------------ helpers covered_by_wildcard() { # A domain one label under the wildcard apex needs no certificate of its own. local d="$1" [[ -n "$WILDCARD_APEX" ]] || return 1 [[ "$d" == *".$WILDCARD_APEX" ]] || return 1 [[ "${d%.$WILDCARD_APEX}" != *.* ]] } cert_is_current() { local d="$1" live="/etc/letsencrypt/live/$1/cert.pem" [[ -f "$live" ]] || return 1 openssl x509 -in "$live" -noout -checkend $((RENEW_WINDOW_DAYS * 86400)) >/dev/null 2>&1 } resolves_here() { local d="$1" local got want got="$(getent hosts "$d" | awk '{print $1}' | sort -u)" [[ -n "$got" ]] || return 1 # Compare against every address this host actually answers on. want="$(hostname -I | tr ' ' '\n' | grep -v '^$')" grep -qxF -f <(echo "$want") <(echo "$got") } write_block() { local d="$1" conf="/etc/nginx/sites-available/tenant-$1.conf" cat > "$conf" </dev/null 2>&1; then log "ERROR: nginx config invalid after adding $d — reverting that block" rm -f "/etc/nginx/sites-enabled/tenant-$d.conf" failed=$((failed + 1)); continue fi systemctl reload nginx if certbot --nginx -d "$d" --non-interactive --agree-tos \ --email "$CERTBOT_EMAIL" --redirect --keep-until-expiring >>"$STATE_DIR/certbot.log" 2>&1; then issued=$((issued + 1)) log "issued: $d" else log "ERROR: certbot failed for $d (see $STATE_DIR/certbot.log) — HTTP still served, HTTPS not yet" failed=$((failed + 1)) fi done <<< "$DESIRED" # Domains dropped from the source: stop serving them, but never delete the # certificate — a domain re-added next week should not need a fresh issuance. for link in /etc/nginx/sites-enabled/tenant-*.conf; do [[ -e "$link" ]] || continue name="$(basename "$link")"; name="${name#tenant-}"; name="${name%.conf}" if ! grep -qxF "$name" <<< "$DESIRED"; then if [[ $DRY_RUN -eq 1 ]]; then log "DRY RUN would disable: $name" else log "disabling (removed from source): $name" rm -f "$link" fi fi done if [[ $DRY_RUN -eq 0 ]]; then nginx -t && systemctl reload nginx fi log "done. issued=$issued current=$skipped wildcard=$wildcarded awaiting-dns=$waiting failed=$failed" [[ $failed -eq 0 ]]