From 19dfe793e0c14a9e21cfc593e9754303bd44db23 Mon Sep 17 00:00:00 2001 From: rob thijssen Date: Wed, 8 Jul 2026 12:19:31 +0300 Subject: [PATCH] feat(infra): provision the public rob.fyi vhost + LE cert in infra-setup MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit infra-setup only handled the internal (newsfeed.internal) cert and vhost and just printed a generic "set it up yourself" note for the WAN name. Now it also, on the web host: - issues the Let's Encrypt cert for rob.fyi idempotently (certbot, Cloudflare DNS-01, ECDSA, --keep-until-expiring), skipping cleanly if the CF token or certbot is absent; - installs the certbot renew deploy-hook (reload nginx); - installs the public vhost, gated on the cert existing so a missing cert can't break `nginx -t` for all of nginx (external-tls.md §4). PUBLIC_FQDN/CERT_EMAIL/CF_CREDS are infra-truth vars at the top; set PUBLIC_FQDN empty to skip. Cloudflare A record + OPNsense :443 forward remain manual and are called out in the closing output. Co-Authored-By: Claude Opus 4.8 (1M context) Claude-Session: https://claude.ai/code/session_016fKZzDpvjiJ9eYbPGgJvUP --- readme.md | 9 ++++-- script/infra-setup.sh | 65 +++++++++++++++++++++++++++++++++++++++++-- 2 files changed, 69 insertions(+), 5 deletions(-) diff --git a/readme.md b/readme.md index cf4cfb3..a44e3e6 100644 --- a/readme.md +++ b/readme.md @@ -132,9 +132,12 @@ sudoers, the `newsfeed` service account, directories, the `newsfeed.internal` TL renewal, and the nginx vhost). Thereafter every push to `main` builds static musl binaries + the SPA bundle and rsyncs them to the targets. -Mesh users reach `https://newsfeed.internal`. Public access is at `https://rob.fyi` -(`asset/nginx/newsfeed.public.conf`) — provision its Let's Encrypt cert, then symlink the -vhost into `sites-enabled`. +Mesh users reach `https://newsfeed.internal`; public access is at `https://rob.fyi`. +`infra-setup.sh` provisions **both** vhosts on oolon — the internal one (internal-CA cert) +and the public one (Let's Encrypt via certbot + Cloudflare DNS-01, gated on the cert +existing). The public cert needs the Cloudflare API token at `/root/.certbot-internal` on +oolon; you still add the Cloudflare A record (`rob.fyi` → kosherinata WAN, unproxied) and +the OPNsense `:443` forward separately. For the workspace-wide architectural conventions this project inherits, see the [architecture repo](https://git.lair.cafe/grenade/architecture). diff --git a/script/infra-setup.sh b/script/infra-setup.sh index 4a5904a..be43249 100755 --- a/script/infra-setup.sh +++ b/script/infra-setup.sh @@ -21,6 +21,9 @@ API_HOST="slartibartfast.kosherinata.internal" # newsfeed-api + newsfeed-worke WEB_HOST="oolon.kosherinata.internal" # nginx: SPA + API reverse proxy API_PORT="8081" CERT_NAME="newsfeed" # dot-free; serves ${CERT_NAME}.internal +PUBLIC_FQDN="rob.fyi" # WAN name (Let's Encrypt); "" to skip +CERT_EMAIL="ops@rob.fyi" # certbot registration email +CF_CREDS="/root/.certbot-internal" # Cloudflare API token on the web host RUNNER_PUBKEY="${HOME}/.ssh/id_gitea_ci.pub" # runner key distributed to gitea_ci PROVISIONER_PW="${HOME}/.step/secrets/provisioner" ROOT_CA="/etc/pki/ca-trust/source/anchors/root-internal.pem" @@ -163,8 +166,66 @@ VHOST nginx -t systemctl reload nginx " - info "[$host] to publish on the WAN, set server_name + a Let's Encrypt cert in" - info " asset/nginx/newsfeed.public.conf and symlink it into sites-enabled." + + provision_public_vhost "$host" +} + +# Issue the public Let's Encrypt cert for $PUBLIC_FQDN (idempotent) and install the WAN +# vhost, gated on the cert existing (external-tls.md). Skips cleanly if PUBLIC_FQDN is +# unset or the Cloudflare credential isn't on the host. +provision_public_vhost() { + local host="$1" + [[ -n "$PUBLIC_FQDN" ]] || { info "[$host] PUBLIC_FQDN unset; skipping WAN vhost"; return 0; } + + if [[ "$DRY_RUN" == 1 ]]; then info "[$host] would issue LE cert + install ${PUBLIC_FQDN} vhost"; return 0; fi + + info "[$host] issuing Let's Encrypt cert for ${PUBLIC_FQDN} (certbot, Cloudflare DNS-01)" + # --keep-until-expiring makes this a no-op when the cert is still valid, so it is safe + # to run unconditionally. DNS-01 needs no inbound :80 and works even before the WAN A + # record / OPNsense forward exist. Requires the Cloudflare token at $CF_CREDS. + run_remote "$host" " + if [ ! -f '$CF_CREDS' ]; then + echo 'WARN: $CF_CREDS missing; cannot issue ${PUBLIC_FQDN} cert' >&2 + exit 0 + fi + command -v certbot >/dev/null || { echo 'WARN: certbot not installed' >&2; exit 0; } + certbot certonly \ + -m '$CERT_EMAIL' --agree-tos --no-eff-email --noninteractive \ + --cert-name '$PUBLIC_FQDN' \ + --key-type ecdsa \ + --dns-cloudflare \ + --dns-cloudflare-credentials '$CF_CREDS' \ + --dns-cloudflare-propagation-seconds 60 \ + --keep-until-expiring \ + -d '$PUBLIC_FQDN' + # nginx reload after future renewals (external-tls.md §3), once per host. + install -d -m 0755 /etc/letsencrypt/renewal-hooks/deploy + cat > /etc/letsencrypt/renewal-hooks/deploy/reload-nginx.sh <<'HOOK' +#!/bin/sh +systemctl reload nginx 2>/dev/null || true +HOOK + chmod +x /etc/letsencrypt/renewal-hooks/deploy/reload-nginx.sh + " + + info "[$host] installing WAN vhost (${PUBLIC_FQDN})" + local vhost; vhost="$(cat "$REPO_ROOT/asset/nginx/newsfeed.public.conf")" + # Gate the vhost on the cert existing — nginx -t fails on a missing ssl_certificate and + # would block ALL of nginx from reloading (external-tls.md §4). + run_remote "$host" " + if ! test -d /etc/letsencrypt/live/${PUBLIC_FQDN}; then + echo 'NOTE: ${PUBLIC_FQDN} cert not present; skipping WAN vhost install' >&2 + exit 0 + fi + cat > /etc/nginx/sites-available/newsfeed.public.conf <<'VHOST' +${vhost} +VHOST + ln -sfn ../sites-available/newsfeed.public.conf /etc/nginx/sites-enabled/newsfeed.public.conf + nginx -t + systemctl reload nginx + " + info "[$host] ${PUBLIC_FQDN} served. Ensure the Cloudflare A record ${PUBLIC_FQDN} ->" + info " kosherinata WAN IP exists (unproxied) and OPNsense forwards :443 -> oolon" + info " (reverse-proxies.md §2)." } # Mint the first internal cert (idempotent) and enable renewal (internal-tls.md §4).