Files
certbot/docker/scripts/1-ensure-cert.sh
T
bitdeals 0801b0d376
Build docker image and push to registry.bitdeals.org / main-build-job (push) Successful in 1m4s
feat: put the log in the volume, so the container can be read-only
/var/log/letsencrypt was the only thing this image wrote outside its volumes,
and it is what stopped `read_only: true` from working. --logs-dir moves it to
/etc/letsencrypt/logs, beside the account key and the renewal config it
already keeps there.

The second gain is the one that matters more here. This is the service whose
breakage does not announce itself: a renewal that stops working is an expired
certificate sixty days later, and the log is what says why. On the root
filesystem it died with every container the registry replaced; in the volume
it outlives them.

Nothing else about the container needs to change to be confined. It never
changes user, the files it touches are its own, the ACME challenge is served
on 380 rather than a privileged port, and the HAProxy runtime socket it writes
to is group-owned by root -- so it reaches it by permission rather than by
CAP_DAC_OVERRIDE, and `cap_drop: ALL` takes nothing away. Measured on the live
relay: no file under /etc/letsencrypt or /etc/certificates is owned by anyone
but root, and the socket is srw-rw---- 1001:0.

Both READMEs say what a caller should set now, stop_grace_period included --
the entrypoint has asked for that one since it was written.
2026-09-10 11:49:04 +00:00

101 lines
4.5 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.
#
# --logs-dir moves certbot's log out of /var/log on the root filesystem and
# into the volume the rest of its state already lives in. Two things follow.
# The container can be run with `read_only: true`, which the log was the only
# thing standing in the way of. And the log of a renewal that failed outlives
# the container, which matters here more than anywhere else: this is the one
# service whose breakage surfaces sixty days later, and the log is what says
# why.
certbot certonly --standalone \
--non-interactive --agree-tos --http-01-port=380 \
--logs-dir /etc/letsencrypt/logs \
"$@" \
--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