235 lines
8.0 KiB
Bash
235 lines
8.0 KiB
Bash
|
|
#!/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" <<NGINX
|
||
|
|
# Managed by sync-domains.sh. Manual edits are overwritten.
|
||
|
|
# Same root as the catch-all: the SPA resolves its tenant from the Host header.
|
||
|
|
server {
|
||
|
|
listen 80;
|
||
|
|
listen [::]:80;
|
||
|
|
server_name $d;
|
||
|
|
root /srv/marketplaces/current/frontend;
|
||
|
|
index index.html;
|
||
|
|
|
||
|
|
location = /index.html {
|
||
|
|
add_header Cache-Control "no-store, must-revalidate" always;
|
||
|
|
try_files \$uri =404;
|
||
|
|
}
|
||
|
|
location ~* \.(js|css|woff2?|png|jpe?g|svg|gif|webp|avif|ico)\$ {
|
||
|
|
expires 1y;
|
||
|
|
add_header Cache-Control "public, immutable" always;
|
||
|
|
try_files \$uri =404;
|
||
|
|
}
|
||
|
|
location /health { access_log off; return 200 "ok\n"; add_header Content-Type text/plain; }
|
||
|
|
location /api/ {
|
||
|
|
proxy_pass http://127.0.0.1:8080;
|
||
|
|
proxy_http_version 1.1;
|
||
|
|
proxy_set_header Host \$host;
|
||
|
|
proxy_set_header X-Real-IP \$remote_addr;
|
||
|
|
proxy_set_header X-Forwarded-For \$proxy_add_x_forwarded_for;
|
||
|
|
proxy_set_header X-Forwarded-Proto \$scheme;
|
||
|
|
}
|
||
|
|
location / { try_files \$uri \$uri/ /index.html; }
|
||
|
|
|
||
|
|
add_header X-Content-Type-Options "nosniff" always;
|
||
|
|
add_header X-Frame-Options "SAMEORIGIN" always;
|
||
|
|
add_header Referrer-Policy "strict-origin-when-cross-origin" always;
|
||
|
|
gzip on;
|
||
|
|
gzip_types text/plain text/css application/javascript application/json image/svg+xml;
|
||
|
|
gzip_min_length 1024;
|
||
|
|
}
|
||
|
|
NGINX
|
||
|
|
ln -sfn "$conf" "/etc/nginx/sites-enabled/tenant-$d.conf"
|
||
|
|
}
|
||
|
|
|
||
|
|
# ------------------------------------------------------------------ reconcile
|
||
|
|
|
||
|
|
issued=0 skipped=0 waiting=0 wildcarded=0 failed=0
|
||
|
|
|
||
|
|
while read -r d; do
|
||
|
|
[[ -n "$d" ]] || continue
|
||
|
|
|
||
|
|
if covered_by_wildcard "$d"; then
|
||
|
|
wildcarded=$((wildcarded + 1)); continue
|
||
|
|
fi
|
||
|
|
|
||
|
|
if cert_is_current "$d"; then
|
||
|
|
skipped=$((skipped + 1)); continue
|
||
|
|
fi
|
||
|
|
|
||
|
|
if ! resolves_here "$d"; then
|
||
|
|
log "waiting on DNS: $d (does not resolve to this server yet)"
|
||
|
|
waiting=$((waiting + 1)); continue
|
||
|
|
fi
|
||
|
|
|
||
|
|
if [[ $issued -ge $MAX_ISSUE_PER_RUN ]]; then
|
||
|
|
log "issuance cap ($MAX_ISSUE_PER_RUN) reached — remaining domains roll to the next run"
|
||
|
|
break
|
||
|
|
fi
|
||
|
|
|
||
|
|
if [[ $DRY_RUN -eq 1 ]]; then
|
||
|
|
log "DRY RUN would issue: $d"; issued=$((issued + 1)); continue
|
||
|
|
fi
|
||
|
|
|
||
|
|
log "issuing: $d"
|
||
|
|
write_block "$d"
|
||
|
|
if ! nginx -t >/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 ]]
|