feat(infra): provision the public rob.fyi vhost + LE cert in infra-setup
Some checks failed
deploy / build-api (push) Waiting to run
deploy / build-web (push) Successful in 1m33s
deploy / deploy-api (push) Has been cancelled
deploy / deploy-web (push) Has been cancelled

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) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016fKZzDpvjiJ9eYbPGgJvUP
This commit is contained in:
2026-07-08 12:19:31 +03:00
parent 62d5bc6569
commit 19dfe793e0
2 changed files with 69 additions and 5 deletions

View File

@@ -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 renewal, and the nginx vhost). Thereafter every push to `main` builds static musl
binaries + the SPA bundle and rsyncs them to the targets. binaries + the SPA bundle and rsyncs them to the targets.
Mesh users reach `https://newsfeed.internal`. Public access is at `https://rob.fyi` 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 `infra-setup.sh` provisions **both** vhosts on oolon — the internal one (internal-CA cert)
vhost into `sites-enabled`. 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 For the workspace-wide architectural conventions this project inherits, see the
[architecture repo](https://git.lair.cafe/grenade/architecture). [architecture repo](https://git.lair.cafe/grenade/architecture).

View File

@@ -21,6 +21,9 @@ API_HOST="slartibartfast.kosherinata.internal" # newsfeed-api + newsfeed-worke
WEB_HOST="oolon.kosherinata.internal" # nginx: SPA + API reverse proxy WEB_HOST="oolon.kosherinata.internal" # nginx: SPA + API reverse proxy
API_PORT="8081" API_PORT="8081"
CERT_NAME="newsfeed" # dot-free; serves ${CERT_NAME}.internal 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 RUNNER_PUBKEY="${HOME}/.ssh/id_gitea_ci.pub" # runner key distributed to gitea_ci
PROVISIONER_PW="${HOME}/.step/secrets/provisioner" PROVISIONER_PW="${HOME}/.step/secrets/provisioner"
ROOT_CA="/etc/pki/ca-trust/source/anchors/root-internal.pem" ROOT_CA="/etc/pki/ca-trust/source/anchors/root-internal.pem"
@@ -163,8 +166,66 @@ VHOST
nginx -t nginx -t
systemctl reload nginx 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). # Mint the first internal cert (idempotent) and enable renewal (internal-tls.md §4).