Files
certbot/docker/scripts/1-ensure-cert.sh
T
bitdeals a842e0771e
Build docker image and push to registry.bitdeals.org / main-build-job (push) Successful in 36s
fix: HAProxy could not read the placeholder, so a failed issuance took the site down
The placeholder is the whole reason a first start works at all: HAProxy
resolves `bind ... ssl crt` while parsing its configuration, so it cannot
start without site.pem, and certbot writes a self-signed one to break
that circle. It wrote it 0600 root-only, under the umask the subshell
sets for the private key it builds it from. HAProxy runs unprivileged
(uid 1001 in the bitnami image), so it could not open the file and exited
-- and because it never came up, it never created the runtime API socket
the pass waits for, and the pass hung in that wait instead of reaching
the 12-hour sleep. A node whose first issuance failed -- a typo in
CERTBOT_DOMAIN is enough -- served nothing at all on 80 or 443, retried
nothing, and said so only in two container logs.

The real certificate escapes this by accident: 2-concatenate-cert.sh
writes it with `cat >` under the default umask, so site.pem is 0644 on
every node that ever issued one. That is why nobody hit this -- it needs
a first issuance that fails.

chmod on the temporary file rather than after the rename, so the atomic
rename stays the only thing HAProxy can observe and site.pem never exists
with a mode that stops it. The intermediates keep the tight umask.

Reproduced on a disposable haproxy+certbot stack with empty volumes and
CERTBOT_DOMAIN=testnet2.bitdeals.invalid: before, HAProxy crash-looped on
"cannot open the file" while certbot waited for admin.sock; after, HAProxy
starts on the placeholder, certbot reports the rejected domain, the loop
retries in 12h, and the site answers on 80 and terminates TLS on 443 with
the self-signed certificate.

Claude-Session: https://claude.ai/code/session_01BvgYcYPWd1KGABLKViVPSk
2026-08-24 13:13:23 +00:00

92 lines
4.0 KiB
Bash

#!/bin/sh
# One pass of the loop: make the certificate on the volume the one CERTBOT_DOMAIN
# asks for, then hand it to the running HAProxy. Every pass, not just the first.
#
# `certbot certonly` rather than `certbot renew`, and that is the whole reason
# this script is the pass: renew takes the names from the certificate it already
# holds and never reads CERTBOT_DOMAIN, so an edit to the variable stayed
# invisible until somebody issued the new set by hand. certonly is told the names
# on every pass, so the certificate follows the variable in both directions.
#
# --keep-until-expiring is what makes calling it twice a day free: with the same
# names and no expiry due, certbot answers "Certificate not yet due for renewal;
# no action taken" without opening a connection, so nothing is spent against
# Let's Encrypt's rate limits.
set -e
if [ ! -f /etc/certificates/site.pem ]; then
# Self-signed placeholder, so HAProxy can bind :443 before a real certificate
# exists. Built inside a subshell: this script is sourced, so a bare cd or
# umask would change the caller's shell too. Relative paths used to resolve
# against the working directory (/opt/certbot) and left the private key there
# for good; here they resolve against the volume the key belongs on, and the
# intermediates are removed before site.pem appears.
(
umask 077
cd /etc/certificates
openssl req -x509 -newkey rsa:2048 -nodes -days 365 -batch \
-subj "/CN=${CERTBOT_DOMAIN:-localhost}" \
-keyout site.key.tmp -out site.crt.tmp
cat site.key.tmp site.crt.tmp > site.pem.tmp
rm -f site.key.tmp site.crt.tmp
# Readable by HAProxy, which runs unprivileged (uid 1001) and opens this
# file while parsing `bind ... ssl crt`. Under the umask above the
# placeholder came out 0600 root-only, HAProxy could not start at all, and
# the pass then waited forever for a runtime API socket that no longer had
# anyone to create it — a failed issuance took the whole site down instead
# of leaving it on the placeholder. The real certificate has been 0644 all
# along: 2-concatenate-cert.sh writes it with the default umask. Set on the
# temporary file, so the rename below stays the only thing HAProxy can see.
chmod 644 site.pem.tmp
# Last and atomic: HAProxy reads site.pem at start-up and must never find
# it half-written.
mv site.pem.tmp site.pem
)
fi
# Wait for haproxy: Let's Encrypt reaches this container's challenge server only
# through it, so asking for a certificate first would just fail validation. The
# runtime API socket appearing is the signal — HAProxy creates it once the
# configuration has parsed and the binds have succeeded. Waiting on the socket
# rather than a TCP probe also means there is no port left to probe.
HAPROXY_SOCKET=/var/lib/haproxy/admin.sock
while [ ! -S "$HAPROXY_SOCKET" ]; do
echo "Waiting for $HAPROXY_SOCKET..."
sleep 7
done
if [ -n "$CERTBOT_DOMAIN" ]; then
# E-mail for Let's Encrypt expiry notices. Built with set -- so that the flag
# and its value stay two separate arguments: a single "$CERTBOT_OPTS" word
# reached certbot as one token ("--email you@example.org") and was rejected
# as an unknown argument, which failed every issuance with an address set.
# The entrypoint passes no positional parameters, so there are none to lose.
if [ -n "$CERTBOT_EMAIL" ]; then
set -- --email "$CERTBOT_EMAIL"
else
set -- --register-unsafely-without-email
fi
# Request certificate. --cert-name pins the lineage to the first name, so a
# changed list updates that certificate instead of starting a second one
# beside it; with the lineage named, certbot needs no --expand to accept an
# added name non-interactively.
certbot certonly --standalone \
--non-interactive --agree-tos --http-01-port=380 \
"$@" \
--cert-name "${CERTBOT_DOMAIN%%,*}" \
--keep-until-expiring \
-d "$CERTBOT_DOMAIN"
# Concatenate certificates
. $(dirname $0)/2-concatenate-cert.sh
fi
# Update certificates in HAProxy
. $(dirname $0)/3-update-haproxy-cert.sh