A config change can render perfectly, pass every unit test in scripts/, and
still be rejected outright by HAProxy. That happened on 2026-08-14: an inline
`regsub((^|/)wp-admin/.*,\1wp-login.php)` in a redirect location produced
"invalid arg 2 in converter 'regsub'". Thirteen tests were green. It was only
caught because someone built an image by hand and ran `haproxy -c`.
Nothing between commit and production would have stopped it. The unit suites
assert on the TEXT of the rendered config with regexes, which says what the
template emits, never whether HAProxy accepts it. test-config-rollback.py's
"validation" stubs the haproxy binary with a shell script that rejects one
sentinel token and has never parsed a line of real syntax. And
.gitea/workflows/build-push.yaml is checkout -> build -> push, with no tests
at all.
The production consequence is not a broken deploy, it is a silent outage:
init.py refuses to start HAProxy on an invalid config while the container
still comes up, so ports 80/443 are unbound, every site on the host is down,
and /health keeps answering 200.
scripts/validate-rendered-config.py renders the config through the real
generate_config() - every template, real order, both conditional branches
({%- if suspension_enabled %} and {%- if coraza_spoe_backend %}) rendered on
in one scenario and off in the other - creates the stub files the config
loads via `-f` (a missing one is a FATAL haproxy error and would be a false
failure), then runs `haproxy -c` and gates on its EXIT CODE. Warnings are
expected on a clean config ("Can't load stats file", path_reg advisories) and
are not failures; on a real failure the full haproxy output plus the offending
config lines go to the build log.
It runs as a Dockerfile RUN rather than a CI step so it cannot be skipped, so
it protects local builds too, and - the reason that matters most - so it
validates against the EXACT haproxy binary in the image being built. The
Dockerfile 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. Gating in CI instead would also have meant splitting
build-push-action's single build-and-push step.
The six existing unit suites run in the same step. They had never run
anywhere automated either, and they cost about five seconds.
Verified both ways: the clean build passes and the gate's output appears in
the log; reintroducing the known-bad regsub into a copy of the template fails
the build with HAProxy's own "invalid arg 2 in converter 'regsub' : missing
arguments (got 1/2)".
95 lines
5.7 KiB
Docker
95 lines
5.7 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"
|
|
|
|
RUN apt update -y && apt dist-upgrade -y && apt install socat haproxy cron certbot curl jq net-tools -y && 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"] |