Previously unpinned, so the binary could move at Debian's timing and break an unrelated commit's build -- or ship an edge that refuses to start. Pinning and the haproxy -c gate compose: pinned means the version moves deliberately, and the gate then answers whether the new binary still accepts fleet config. Also makes the image reproducible.
111 lines
6.8 KiB
Docker
111 lines
6.8 KiB
Docker
# Base image mirrored into the in-house registry to remove docker.io
|
|
# (Cloudflare R2) as a single point of failure for CI builds. The 2026-05-12
|
|
# Cloudflare incident took down docker.io blob pulls and broke this image's CI.
|
|
# Refresh procedure (run on a workstation that can reach docker.io, e.g.
|
|
# monthly or when Python patches drop):
|
|
# docker pull docker.io/library/python:3.12-slim
|
|
# docker tag docker.io/library/python:3.12-slim \
|
|
# repo.anhonesthost.net/cloud-hosting-platform/python:3.12-slim
|
|
# docker push repo.anhonesthost.net/cloud-hosting-platform/python:3.12-slim
|
|
# Future improvement: a scheduled Gitea Action that does the above automatically.
|
|
FROM repo.anhonesthost.net/cloud-hosting-platform/python:3.12-slim
|
|
|
|
# image.source is what ghcr.io uses to link the package to a GitHub repo
|
|
# sidebar; pointing at the public GitHub mirror enables that linking. The
|
|
# canonical source-of-truth git remote is still Gitea, but Gitea's registry
|
|
# doesn't consume this label, so there's no contention.
|
|
# Stamped from the VERSION file by CI (build-arg) so `docker inspect` reports
|
|
# what's running on any host. Defaults to "dev" for local/manual builds.
|
|
ARG VERSION=dev
|
|
LABEL org.opencontainers.image.title="haproxy-manager-base" \
|
|
org.opencontainers.image.description="HAProxy management API with Let's Encrypt automation, Coraza WAF integration, and template-driven config" \
|
|
org.opencontainers.image.source="https://github.com/shadowdao/haproxy-manager-base" \
|
|
org.opencontainers.image.version="${VERSION}" \
|
|
org.opencontainers.image.licenses="MIT"
|
|
|
|
# haproxy is PINNED. It was previously unpinned, so the binary could move under
|
|
# us at Debian's timing — an upstream release that rejected our config would have
|
|
# broken an unrelated commit's build, or worse, shipped an edge that refuses to
|
|
# start (see the `haproxy -c` gate below for why that matters: a config HAProxy
|
|
# rejects leaves the container Up with 80/443 unbound and /health still 200).
|
|
#
|
|
# Pinning does NOT make the gate redundant, and the gate does NOT make pinning
|
|
# unnecessary — they compose. Pinned means the version moves deliberately; the
|
|
# gate then answers immediately whether the new binary still accepts fleet config.
|
|
# It also makes the image reproducible, which it previously was not.
|
|
#
|
|
# To move it: bump the version here, rebuild, and let the gate verify. If Debian
|
|
# security-updates the package (e.g. -1+deb13u4) the build FAILS until this pin is
|
|
# updated — that failure is the point, not a bug. Check availability with:
|
|
# apt-cache policy haproxy
|
|
ARG HAPROXY_VERSION=3.0.11-1+deb13u3
|
|
RUN apt update -y && apt dist-upgrade -y && apt install socat "haproxy=${HAPROXY_VERSION}" cron certbot curl jq net-tools -y && apt-mark hold haproxy && apt clean && rm -rf /var/lib/apt/lists/*
|
|
WORKDIR /haproxy
|
|
COPY ./templates /haproxy/templates
|
|
COPY requirements.txt /haproxy/
|
|
COPY haproxy_manager.py /haproxy/
|
|
COPY scripts /haproxy/scripts
|
|
COPY trusted_ips.list /etc/haproxy/trusted_ips.list
|
|
COPY trusted_ips.map /etc/haproxy/trusted_ips.map
|
|
# /etc/haproxy is a named volume in deployed containers, so baked-in files
|
|
# under that path get shadowed by the volume on existing deployments. The
|
|
# trusted_ips.* pair above predates that discovery and is handled by the
|
|
# older start-up.sh guard (out of scope here). cloudflare_ips.list and
|
|
# trusted_proxies.list are staged under /haproxy/defaults instead, so
|
|
# start-up.sh can always read the image's baked copy regardless of what the
|
|
# volume shadows /etc/haproxy with.
|
|
COPY cloudflare_ips.list /haproxy/defaults/cloudflare_ips.list
|
|
COPY trusted_proxies.list /haproxy/defaults/trusted_proxies.list
|
|
COPY wpadmin_gate_exempt.list /haproxy/defaults/wpadmin_gate_exempt.list
|
|
# Place errorfiles outside the volumed path; the HAProxy config references
|
|
# them by absolute path.
|
|
COPY errors /haproxy/errors
|
|
RUN chmod +x /haproxy/scripts/*
|
|
RUN pip install -r requirements.txt
|
|
# ---------------------------------------------------------------------------
|
|
# Build gate: no image ships unless the real haproxy binary accepts the config
|
|
# this image's templates actually produce.
|
|
#
|
|
# On 2026-08-14 a template change rendered fine, passed all 13 unit tests, and
|
|
# was rejected by HAProxy ("invalid arg 2 in converter 'regsub'"). It was only
|
|
# caught because someone built an image by hand and ran `haproxy -c`. Nothing
|
|
# in the build or in CI would have stopped it: .gitea/workflows/build-push.yaml
|
|
# is checkout -> build -> push, and test-config-rollback.py's "haproxy" is a
|
|
# shell stub that only rejects a sentinel token. In production an invalid
|
|
# haproxy.cfg means init.py refuses to start HAProxy while the container stays
|
|
# Up - ports 80/443 unbound, every site on the host down, /health still 200.
|
|
#
|
|
# This lives in the Dockerfile rather than in the workflow deliberately:
|
|
# * it cannot be skipped, and it protects local `docker build` too;
|
|
# * no workflow restructuring (build-push-action builds and pushes in one
|
|
# step, so gating in CI would mean splitting build from push);
|
|
# * it validates against the EXACT haproxy binary in this image. Line 26
|
|
# installs haproxy unpinned, so that binary moves between builds - this
|
|
# turns "the new haproxy rejects our config" from a silent production
|
|
# risk into a build failure.
|
|
#
|
|
# The unit suites run here too. They had never run anywhere automated either,
|
|
# and they cost a few seconds.
|
|
RUN python3 /haproxy/scripts/test-wpadmin-gate.py \
|
|
&& python3 /haproxy/scripts/test-trusted-proxy-gate.py \
|
|
&& python3 /haproxy/scripts/test-xmlrpc-rate-limit.py \
|
|
&& python3 /haproxy/scripts/test-config-rollback.py \
|
|
&& python3 /haproxy/scripts/test-cert-write-safety.py \
|
|
&& python3 /haproxy/scripts/test-cert-scripts.py \
|
|
&& python3 /haproxy/scripts/validate-rendered-config.py
|
|
# Create log directories
|
|
RUN mkdir -p /var/log && touch /var/log/haproxy-manager.log /var/log/haproxy-manager-errors.log
|
|
RUN chmod 755 /var/log/haproxy-manager.log /var/log/haproxy-manager-errors.log
|
|
# Set up cron for certificate renewal with proper permissions and environment
|
|
RUN mkdir -p /var/spool/cron/crontabs && \
|
|
echo 'PATH=/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin' > /var/spool/cron/crontabs/root && \
|
|
echo '0 */12 * * * /haproxy/scripts/renew-certificates.sh >> /var/log/haproxy-manager.log 2>&1' >> /var/spool/cron/crontabs/root && \
|
|
chmod 600 /var/spool/cron/crontabs/root && \
|
|
chown root:crontab /var/spool/cron/crontabs/root
|
|
# 443/udp carries HTTP/3 (QUIC). EXPOSE is documentation only — the container
|
|
# must still be run with `-p 443:443/udp` for the UDP listener to be reachable.
|
|
EXPOSE 80 443 443/udp 8000
|
|
# Add health check
|
|
HEALTHCHECK --interval=30s --timeout=10s --start-period=60s --retries=3 \
|
|
CMD curl -sf --max-time 5 http://localhost:8000/health && curl -s --max-time 5 -o /dev/null http://localhost/ || exit 1
|
|
CMD ["/haproxy/scripts/start-up.sh"] |