ci: make TLS provisioning dynamic as domains are added
Adding a domain was a manual per-domain script run. With domains arriving continuously that does not hold, so certificate issuance is now automatic. HTTP already needed no work: the nginx catch-all serves any Host and the SPA resolves its tenant from that header. Only TLS needed a name-by-name step. Two mechanisms: - setup-wildcard-tls.sh issues one DNS-01 wildcard for *.<apex>, after which a new tenant subdomain is live over HTTPS with zero certificate work. - sync-domains.sh reconciles tenant-owned domains against a desired list on a 10-minute timer: issues what is missing, skips certificates with >30 days left, skips names already covered by the wildcard, waits out unpropagated DNS, and caps issuance per run so a bad source cannot burn the weekly ACME budget. Safety properties worth stating: a failed fetch of the desired list aborts the run rather than reading as "remove every domain"; removing a domain disables its server block but keeps the certificate, so re-adding is instant; malformed hostnames are rejected before reaching certbot or an nginx server_name. The source is pluggable - a file today, the Phase 9 domain registry once it exists, whose MarketplaceDomain statuses already match what this needs. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
148
scripts/deploy/setup-wildcard-tls.sh
Normal file
148
scripts/deploy/setup-wildcard-tls.sh
Normal file
@@ -0,0 +1,148 @@
|
||||
#!/usr/bin/env bash
|
||||
#
|
||||
# Issue ONE wildcard certificate covering every tenant subdomain under an apex.
|
||||
# After this, a new tenant at <slug>.<apex> needs no certificate work at all —
|
||||
# DNS record, and it is live over HTTPS immediately.
|
||||
#
|
||||
# setup-wildcard-tls.sh --apex marketplaces.example.com --email ops@example.com --dns cloudflare
|
||||
# setup-wildcard-tls.sh --apex marketplaces.example.com --email ops@example.com --dns manual
|
||||
#
|
||||
# Wildcards require DNS-01 validation — HTTP-01 cannot issue them. That means
|
||||
# certbot must create a _acme-challenge TXT record, which needs either a DNS
|
||||
# provider plugin (automatic, renews unattended) or manual intervention every
|
||||
# 60-90 days. Prefer a plugin. Use manual only to prove the idea out.
|
||||
#
|
||||
# Tenants on their OWN domains are not covered by a wildcard; those are handled
|
||||
# per-domain by sync-domains.sh.
|
||||
|
||||
set -euo pipefail
|
||||
|
||||
APEX=""; EMAIL=""; DNS_PLUGIN="manual"; CREDS=""
|
||||
|
||||
while [[ $# -gt 0 ]]; do
|
||||
case "$1" in
|
||||
--apex) APEX="$2"; shift 2 ;;
|
||||
--email) EMAIL="$2"; shift 2 ;;
|
||||
--dns) DNS_PLUGIN="$2"; shift 2 ;;
|
||||
--creds) CREDS="$2"; shift 2 ;;
|
||||
*) echo "unknown argument: $1" >&2; exit 2 ;;
|
||||
esac
|
||||
done
|
||||
|
||||
[[ $EUID -eq 0 ]] || { echo "must run as root" >&2; exit 1; }
|
||||
[[ -n "$APEX" ]] || { echo "--apex is required" >&2; exit 2; }
|
||||
[[ -n "$EMAIL" ]] || { echo "--email is required" >&2; exit 2; }
|
||||
|
||||
export DEBIAN_FRONTEND=noninteractive
|
||||
CERT_ARGS=(-d "$APEX" -d "*.$APEX")
|
||||
|
||||
case "$DNS_PLUGIN" in
|
||||
cloudflare)
|
||||
apt-get install -y -qq python3-certbot-dns-cloudflare
|
||||
[[ -n "$CREDS" ]] || { echo "--creds <file> required for cloudflare (contains the API token)" >&2; exit 2; }
|
||||
chmod 600 "$CREDS"
|
||||
CERT_ARGS+=(--dns-cloudflare --dns-cloudflare-credentials "$CREDS" --dns-cloudflare-propagation-seconds 30)
|
||||
;;
|
||||
route53)
|
||||
apt-get install -y -qq python3-certbot-dns-route53
|
||||
CERT_ARGS+=(--dns-route53) # credentials come from the instance role or ~/.aws
|
||||
;;
|
||||
manual)
|
||||
cat >&2 <<'WARN'
|
||||
WARNING: manual DNS-01.
|
||||
|
||||
certbot will print a TXT record for you to create by hand, and will do so again
|
||||
at every renewal (every 60-90 days). Unattended renewal will NOT work. This is
|
||||
acceptable to prove the setup out; it is not acceptable as the steady state.
|
||||
|
||||
Hostinger has no certbot plugin. If DNS lives there, the options are: move DNS
|
||||
to a provider with a plugin (Cloudflare is free and takes minutes), or drive
|
||||
issuance from the Phase 9 domain-automation API instead.
|
||||
|
||||
WARN
|
||||
CERT_ARGS+=(--manual --preferred-challenges dns)
|
||||
;;
|
||||
*)
|
||||
echo "unsupported --dns: $DNS_PLUGIN (cloudflare|route53|manual)" >&2; exit 2 ;;
|
||||
esac
|
||||
|
||||
echo "==> issuing wildcard for $APEX and *.$APEX via $DNS_PLUGIN"
|
||||
certbot certonly "${CERT_ARGS[@]}" \
|
||||
--agree-tos --email "$EMAIL" --keep-until-expiring \
|
||||
$([[ "$DNS_PLUGIN" != "manual" ]] && echo --non-interactive)
|
||||
|
||||
LIVE="/etc/letsencrypt/live/$APEX"
|
||||
[[ -f "$LIVE/fullchain.pem" ]] || { echo "certificate not found at $LIVE" >&2; exit 1; }
|
||||
|
||||
echo "==> nginx: TLS on the catch-all, so every subdomain is served immediately"
|
||||
cat > /etc/nginx/snippets/marketplaces-wildcard-tls.conf <<SNIPPET
|
||||
# Managed by setup-wildcard-tls.sh
|
||||
ssl_certificate $LIVE/fullchain.pem;
|
||||
ssl_certificate_key $LIVE/privkey.pem;
|
||||
ssl_protocols TLSv1.2 TLSv1.3;
|
||||
ssl_prefer_server_ciphers off;
|
||||
ssl_session_cache shared:SSL:10m;
|
||||
ssl_session_timeout 1d;
|
||||
ssl_stapling on;
|
||||
ssl_stapling_verify on;
|
||||
SNIPPET
|
||||
|
||||
cat > /etc/nginx/sites-available/marketplaces-tls.conf <<NGINX
|
||||
# Wildcard TLS catch-all for *.$APEX
|
||||
# Any tenant subdomain is served here with no per-tenant configuration.
|
||||
server {
|
||||
listen 443 ssl default_server;
|
||||
listen [::]:443 ssl default_server;
|
||||
http2 on;
|
||||
server_name $APEX *.$APEX;
|
||||
|
||||
include /etc/nginx/snippets/marketplaces-wildcard-tls.conf;
|
||||
|
||||
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 Strict-Transport-Security "max-age=31536000" always;
|
||||
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 /etc/nginx/sites-available/marketplaces-tls.conf /etc/nginx/sites-enabled/marketplaces-tls.conf
|
||||
nginx -t
|
||||
systemctl reload nginx
|
||||
systemctl enable --now certbot.timer
|
||||
|
||||
# Tell sync-domains.sh which names it can skip.
|
||||
mkdir -p /etc/marketplaces
|
||||
if [[ -f /etc/marketplaces/domains.env ]]; then
|
||||
sed -i '/^WILDCARD_APEX=/d' /etc/marketplaces/domains.env
|
||||
fi
|
||||
echo "WILDCARD_APEX=$APEX" >> /etc/marketplaces/domains.env
|
||||
|
||||
echo
|
||||
echo "done. every <slug>.$APEX is now served over HTTPS with no further action."
|
||||
echo "verify: curl -I https://anything.$APEX/health"
|
||||
Reference in New Issue
Block a user