Nginx must key the cert map on $ssl_server_name during handshake, and www-data needs ssl-cert group access to Let's Encrypt files when certificates are loaded via variables. Co-authored-by: Cursor <cursoragent@cursor.com>
127 lines
4.4 KiB
Bash
Executable File
127 lines
4.4 KiB
Bash
Executable File
#!/usr/bin/env bash
|
|
# Issue / renew Let's Encrypt for one tenant's dashboard hosts only:
|
|
# business.{apex} + customer.{apex}
|
|
# Updates nginx cert map and reloads. Does NOT touch other domains.
|
|
set -euo pipefail
|
|
|
|
CONF=/etc/meshkee/ssl-sync.env
|
|
# shellcheck disable=SC1090
|
|
source "$CONF"
|
|
|
|
APEX="${SSL_TENANT_APEX:-}"
|
|
if [[ -z "$APEX" ]]; then
|
|
echo "$(date -Is) ERROR: SSL_TENANT_APEX not set"
|
|
exit 1
|
|
fi
|
|
|
|
APEX="${APEX,,}"
|
|
CERT_NAME="${SSL_TENANT_CERT_NAME:-meshkee-dash-${APEX//./-}}"
|
|
EMAIL="${SSL_EMAIL:-info@meshkee.com}"
|
|
WEBROOT="${SSL_WEBROOT:-/var/www/certbot}"
|
|
MAP_FILE="${SSL_DASHBOARD_CERT_MAP:-/etc/nginx/conf.d/meshkee-dashboard-certs-map.conf}"
|
|
|
|
BUSINESS_HOST="business.${APEX}"
|
|
CUSTOMER_HOST="customer.${APEX}"
|
|
|
|
mkdir -p "$WEBROOT"
|
|
|
|
echo "$(date -Is) Issuing tenant dashboard cert ${CERT_NAME} for ${BUSINESS_HOST} ${CUSTOMER_HOST}"
|
|
|
|
certbot certonly \
|
|
--webroot -w "$WEBROOT" \
|
|
--cert-name "$CERT_NAME" \
|
|
--email "$EMAIL" \
|
|
--agree-tos \
|
|
--non-interactive \
|
|
-d "$BUSINESS_HOST" \
|
|
-d "$CUSTOMER_HOST"
|
|
|
|
CERT_DIR="/etc/letsencrypt/live/${CERT_NAME}"
|
|
if [[ ! -f "${CERT_DIR}/fullchain.pem" ]]; then
|
|
echo "$(date -Is) ERROR: cert not found at ${CERT_DIR}"
|
|
exit 1
|
|
fi
|
|
|
|
python3 - "$MAP_FILE" "$APEX" "$CERT_NAME" <<'PY'
|
|
import pathlib
|
|
import sys
|
|
|
|
map_path = pathlib.Path(sys.argv[1])
|
|
apex = sys.argv[2].strip().lower()
|
|
cert_name = sys.argv[3].strip()
|
|
cert_dir = f"/etc/letsencrypt/live/{cert_name}"
|
|
business = f"business.{apex}"
|
|
customer = f"customer.{apex}"
|
|
|
|
default_cert = "/etc/letsencrypt/live/meshkee-dashboards/fullchain.pem"
|
|
default_key = "/etc/letsencrypt/live/meshkee-dashboards/privkey.pem"
|
|
tenant_cert = f"{cert_dir}/fullchain.pem"
|
|
tenant_key = f"{cert_dir}/privkey.pem"
|
|
|
|
entries_cert: dict[str, str] = {"default": default_cert}
|
|
entries_key: dict[str, str] = {"default": default_key}
|
|
|
|
if map_path.is_file():
|
|
mode = None
|
|
for raw in map_path.read_text().splitlines():
|
|
line = raw.strip()
|
|
# Accept legacy $host maps and rewrite them to $ssl_server_name.
|
|
if line.startswith("map $ssl_server_name $meshkee_dashboard_ssl_cert") or line.startswith(
|
|
"map $host $meshkee_dashboard_ssl_cert"
|
|
):
|
|
mode = "cert"
|
|
continue
|
|
if line.startswith("map $ssl_server_name $meshkee_dashboard_ssl_key") or line.startswith(
|
|
"map $host $meshkee_dashboard_ssl_key"
|
|
):
|
|
mode = "key"
|
|
continue
|
|
if mode and line and not line.startswith("#") and " " in line:
|
|
host, path = line.split(None, 1)
|
|
host = host.rstrip(";")
|
|
path = path.rstrip(";")
|
|
if host != "default":
|
|
if mode == "cert":
|
|
entries_cert[host] = path
|
|
else:
|
|
entries_key[host] = path
|
|
|
|
entries_cert[business] = tenant_cert
|
|
entries_cert[customer] = tenant_cert
|
|
entries_key[business] = tenant_key
|
|
entries_key[customer] = tenant_key
|
|
|
|
def render_map(name: str, entries: dict[str, str]) -> str:
|
|
# $ssl_server_name is available during handshake; $host is not.
|
|
lines = [f"map $ssl_server_name ${name} {{"]
|
|
lines.append(f" default {entries['default']};")
|
|
for host in sorted(h for h in entries if h != "default"):
|
|
lines.append(f" {host} {entries[host]};")
|
|
lines.append("}")
|
|
return "\n".join(lines)
|
|
|
|
content = (
|
|
"# Managed by ssl-issue-tenant.sh — per-tenant dashboard TLS paths\n"
|
|
"# Keyed by $ssl_server_name (SNI); $host is empty during TLS handshake.\n"
|
|
+ render_map("meshkee_dashboard_ssl_cert", entries_cert)
|
|
+ "\n\n"
|
|
+ render_map("meshkee_dashboard_ssl_key", entries_key)
|
|
+ "\n"
|
|
)
|
|
map_path.write_text(content)
|
|
print(f"Updated {map_path} for {business} / {customer}")
|
|
PY
|
|
|
|
# Variable ssl_certificate is loaded by www-data workers — LE dirs must be group-readable.
|
|
if getent group ssl-cert >/dev/null 2>&1; then
|
|
usermod -aG ssl-cert www-data 2>/dev/null || true
|
|
chmod 750 /etc/letsencrypt/live /etc/letsencrypt/archive 2>/dev/null || true
|
|
chgrp -R ssl-cert /etc/letsencrypt/live /etc/letsencrypt/archive 2>/dev/null || true
|
|
find /etc/letsencrypt/live /etc/letsencrypt/archive -type d -exec chmod 750 {} \; 2>/dev/null || true
|
|
find /etc/letsencrypt/archive -type f -name 'privkey*.pem' -exec chmod 640 {} \; 2>/dev/null || true
|
|
find /etc/letsencrypt/archive -type f -name 'privkey*.pem' -exec chgrp ssl-cert {} \; 2>/dev/null || true
|
|
fi
|
|
|
|
nginx -t && systemctl reload nginx
|
|
echo "$(date -Is) Tenant cert ${CERT_NAME} installed and nginx reloaded"
|