Files
marketplaces/scripts/deploy/setup-wildcard-tls.sh

149 lines
5.5 KiB
Bash
Raw Normal View History

#!/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"