1
0
Fork 0

Merge pull request #61 from byjg/fix-some-issues

Improve ACME and Docker documentation with fixes
This commit is contained in:
Joao M 2025-08-24 19:02:36 -04:00 committed by GitHub
commit c861d9b276
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
10 changed files with 170 additions and 25 deletions

View file

@ -1,4 +1,4 @@
FROM alpine:3.20 FROM alpine:3.22
ARG RELEASE_VERSION_ARG ARG RELEASE_VERSION_ARG
@ -6,6 +6,7 @@ ENV RELEASE_VERSION=$RELEASE_VERSION_ARG
ENV TZ="Etc/UTC" ENV TZ="Etc/UTC"
RUN apk add --no-cache haproxy bash python3 py3-pip py-yaml certbot openssl \ RUN apk add --no-cache haproxy bash python3 py3-pip py-yaml certbot openssl \
&& apk add --no-cache --virtual .build-deps build-base python3-dev musl-dev linux-headers \
&& pip3 install --upgrade pip --break-system-packages && pip3 install --upgrade pip --break-system-packages
RUN openssl dhparam -out /etc/haproxy/dhparam 2048 \ RUN openssl dhparam -out /etc/haproxy/dhparam 2048 \
@ -19,6 +20,8 @@ COPY src/ /scripts/
RUN pip install -r requirements.txt --break-system-packages RUN pip install -r requirements.txt --break-system-packages
RUN apk del .build-deps
RUN pytest -s -vv tests/ RUN pytest -s -vv tests/
CMD ["/usr/bin/python", "-u", "/scripts/main.py" ] CMD ["/usr/bin/python", "-u", "/scripts/main.py" ]

View file

@ -7,7 +7,7 @@ docker volume create certs_certbot
docker volume create certs_haproxy docker volume create certs_haproxy
docker run -d --rm --name easyhaproxy_install -v certs_haproxy:/certs alpine tail -f /dev/null docker run -d --rm --name easyhaproxy_install -v certs_haproxy:/certs alpine tail -f /dev/null
docker cp $ASSETS_DIR/.place_holder_cert.pem easyhaproxy_install:/certs/.place_holder_cert.pem docker cp $ASSETS_DIR/place_holder_cert.pem easyhaproxy_install:/certs/place_holder_cert.pem
docker stop easyhaproxy_install docker stop easyhaproxy_install
echo echo

View file

@ -6,6 +6,46 @@ allowing the automated deployment of public key infrastructure.
Most of the issuers offers Automatic Issuing free of cost. Most of the issuers offers Automatic Issuing free of cost.
## Supported ACME Challenge Methods
Easy HAProxy supports the following ACME challenge types:
- **HTTP-01 Challenge (Default and Only)**
The ACME server validates ownership by making an HTTP request to a temporary endpoint served on port 80. Easy HAProxy provisions a standalone Certbot responder on an internal port and routes `/.well-known/acme-challenge/` traffic to it.
> Note:
> - TLS-ALPN-01 is not supported natively by Easy HAProxy.
> - DNS-01 (often used for wildcard certificates) is not supported natively. If you need DNS-01, obtain certificates externally and mount them via `sslcert` as static certificates.
## How ACME works with Easy HAProxy
At a high level, ACME with Easy HAProxy works in two stages:
1. Global ACME/Certbot setup (one-time per EasyHAProxy instance)
- Choose your Certificate Authority (CA) either by:
- Using AUTOCONFIG with `EASYHAPROXY_CERTBOT_AUTOCONFIG` (e.g., zerossl, letsencrypt_test, google, etc.), or
- Manually setting `EASYHAPROXY_CERTBOT_SERVER` (and `EASYHAPROXY_CERTBOT_EAB_KID` / `EASYHAPROXY_CERTBOT_EAB_HMAC_KEY` when your CA requires EAB).
- Always set your contact email via `EASYHAPROXY_CERTBOT_EMAIL`.
- Ensure ports 80 and 443 are publicly reachable on the EasyHAProxy host.
- Persist the folder `/certs/certbot` on a durable volume so issued/renewed certificates survive container restarts and avoid hitting CA rate limits.
- Challenge method is HTTP-01 only; EasyHAProxy configures a standalone Certbot responder internally.
2. Enable ACME per domain (per service/app)
- Add the label `easyhaproxy.<definition>.certbot=true` to the service you want a certificate for.
- Ensure the service is exposed on HTTP port 80 from EasyHAProxys perspective (e.g., `easyhaproxy.<definition>.port=80`). ACME HTTP-01 will not work if the front port is not 80.
- Provide the domain via `easyhaproxy.<definition>.host=yourdomain.tld` (and additional labels per your install method).
What happens under the hood
- When a labeled domain is detected and a certificate is needed, EasyHAProxy runs Certbot with `--preferred-challenges http` and a standalone responder bound to internal port 2080.
- HAProxy temporarily routes `/.well-known/acme-challenge/` for that domain to the Certbot responder, allowing the CA to validate via HTTP-01.
- On success, EasyHAProxy merges the issued cert and key and stores them under `/certs/certbot` (one PEM per domain), then reloads HAProxy to serve HTTPS for that domain.
- Certificates are monitored and renewed automatically before expiry.
Tips
- Do not map port 443 for your backend app; EasyHAProxy will terminate TLS at the proxy once the certificate is issued.
- If you do not set `EASYHAPROXY_CERTBOT_EMAIL`, EasyHAProxy will not request certificates.
- DNS-01 is not supported natively; for wildcards or DNS-only environments, issue certificates externally and mount them via `sslcert` as static certificates.
## Environment Variables ## Environment Variables
To enable the ACME protocol we need to enable Certbot in EasyHAProxy by setting up to the following environment variables: To enable the ACME protocol we need to enable Certbot in EasyHAProxy by setting up to the following environment variables:

View file

@ -0,0 +1,41 @@
# This example shows how to setup HTTP-01 ACME CA Challenge
#
# You need
# - public IP pointing your machine
# - open ports 80 and 443 in your firewall
version: "3"
services:
haproxy:
image: byjg/easy-haproxy:4.5.0
volumes:
- /var/run/docker.sock:/var/run/docker.sock
# Persist the CERTBOT to avoid re-challenge when the server restarts
- ./certs/certbot:/certs/certbot
environment:
EASYHAPROXY_DISCOVER: docker
HAPROXY_CUSTOMERRORS: "true"
HAPROXY_USERNAME: admin
HAPROXY_PASSWORD: password
HAPROXY_STATS_PORT: 1936
# SETUP THE EMAIL for CertBot
EASYHAPROXY_CERTBOT_EMAIL: user@example.com
# Let's encrypt don´t need AUTOCONFIG, just email.
# If you want other, please refer to the documentation
# EASYHAPROXY_CERTBOT_AUTOCONFIG: zerossl
ports:
- "80:80/tcp"
- "443:443/tcp"
- "1936:1936/tcp"
container:
image: byjg/static-httpserver
labels:
# Setup here the domain will have the SSL issued
easyhaproxy.http.redirect_ssl: true
easyhaproxy.http.host: test.xpto.us
easyhaproxy.http.localport: 8080
easyhaproxy.http.certbot: true

View file

@ -2,12 +2,12 @@ import os
import shlex import shlex
import subprocess import subprocess
import sys import sys
import psutil
import time import time
import logging import logging
from datetime import datetime from datetime import datetime
from multiprocessing import Process from multiprocessing import Process
from typing import Final from typing import Final
import requests import requests
from OpenSSL import crypto from OpenSSL import crypto
@ -120,6 +120,7 @@ class Functions:
log_source_handler = logging.StreamHandler(sys.stdout) log_source_handler = logging.StreamHandler(sys.stdout)
log_source_formatter = logging.Formatter('%(name)s [%(asctime)s] %(levelname)s - %(message)s') log_source_formatter = logging.Formatter('%(name)s [%(asctime)s] %(levelname)s - %(message)s')
log_source_handler.setFormatter(log_source_formatter) log_source_handler.setFormatter(log_source_formatter)
log_source_handler.addFilter(SingleLineNonEmptyFilter())
source.setLevel(selected_level) source.setLevel(selected_level)
source.addHandler(log_source_handler) source.addHandler(log_source_handler)
return selected_level return selected_level
@ -202,18 +203,26 @@ class DaemonizeHAProxy:
if len(list(self.get_custom_config_files().keys())) != 0: if len(list(self.get_custom_config_files().keys())) != 0:
custom_config_files = "-f %s" % self.custom_config_folder custom_config_files = "-f %s" % self.custom_config_folder
if action == DaemonizeHAProxy.HAPROXY_START: if action == DaemonizeHAProxy.HAPROXY_START or not os.path.exists(pid_file):
return "/usr/sbin/haproxy -W -f /etc/haproxy/haproxy.cfg %s -p %s -S /var/run/haproxy.sock" % (custom_config_files, pid_file) return "/usr/sbin/haproxy -W -f /etc/haproxy/haproxy.cfg %s -p %s -S /var/run/haproxy.sock" % (custom_config_files, pid_file)
else: else:
return_code, output = Functions().run_bash(loggerHaproxy, "cat %s" % pid_file, log_output=False) return_code, output = Functions().run_bash(loggerHaproxy, "cat %s" % pid_file, log_output=False)
pid = "".join(output) pid = "".join(output).rstrip()
if psutil.pid_exists(int(pid)):
return "/usr/sbin/haproxy -W -f /etc/haproxy/haproxy.cfg %s -p %s -x /var/run/haproxy.sock -sf %s" % (custom_config_files, pid_file, pid) return "/usr/sbin/haproxy -W -f /etc/haproxy/haproxy.cfg %s -p %s -x /var/run/haproxy.sock -sf %s" % (custom_config_files, pid_file, pid)
else:
os.unlink(pid_file)
loggerHaproxy.warning(
"PID file %s does not exist. Restarting haproxy instead of reload." % pid_file
)
return self.get_haproxy_command(DaemonizeHAProxy.HAPROXY_START, pid_file)
def __prepare(self, command): def __prepare(self, command):
if not isinstance(command, (list, tuple)): if not isinstance(command, (list, tuple)):
command = shlex.split(command) command = shlex.split(command)
try: try:
loggerHaproxy.debug("HAPROXY command: %s" % command)
self.process = subprocess.Popen(command, self.process = subprocess.Popen(command,
shell=False, shell=False,
stdout=subprocess.PIPE, stdout=subprocess.PIPE,
@ -228,7 +237,7 @@ class DaemonizeHAProxy:
try: try:
with self.process.stdout: with self.process.stdout:
for line in iter(self.process.stdout.readline, b''): for line in iter(self.process.stdout.readline, b''):
loggerHaproxy.info(line) loggerHaproxy.info(line.rstrip())
return_code = self.process.wait() return_code = self.process.wait()
loggerHaproxy.debug("Return code %s" % return_code) loggerHaproxy.debug("Return code %s" % return_code)
@ -354,6 +363,11 @@ class Certbot:
if self.certbot_manual_auth_hook: if self.certbot_manual_auth_hook:
certbot_certonly += ' --manual --manual-auth-hook \'{hook}\''.format(hook=self.certbot_manual_auth_hook) certbot_certonly += ' --manual --manual-auth-hook \'{hook}\''.format(hook=self.certbot_manual_auth_hook)
if loggerCertbot.level == logging.DEBUG:
certbot_certonly += ' -v'
loggerCertbot.debug("certbot_certonly: %s" % certbot_certonly)
ret_reload = False ret_reload = False
return_code_issue = 0 return_code_issue = 0
return_code_renew = 0 return_code_renew = 0
@ -424,6 +438,38 @@ class Certbot:
self.freeze_issue[host] = self.retry_count self.freeze_issue[host] = self.retry_count
loggerCertbot.debug("Freeze issuing ssl for %s due failure. The certificate is %s" % (host, cert_status)) loggerCertbot.debug("Freeze issuing ssl for %s due failure. The certificate is %s" % (host, cert_status))
class SingleLineNonEmptyFilter(logging.Filter):
"""
Logging filter that ensures messages are single-line and non-empty.
- Collapses newlines into spaces and strips surrounding whitespace.
- Drops the record if the resulting message is empty.
"""
def filter(self, record: logging.LogRecord) -> int:
try:
msg = record.getMessage()
except Exception:
# If formatting fails, drop the record
return 0
# Convert any non-string to string representation
if not isinstance(msg, str):
msg = str(msg)
# Collapse multi-line to single line and trim
sanitized = " ".join(msg.splitlines()).strip()
if sanitized == "":
return 0
# If we changed the message, update the record and clear args
if sanitized != record.getMessage():
record.msg = sanitized
record.args = ()
return 1
# #################################################################################################################### # ####################################################################################################################
# Setup Global Log # Setup Global Log
loggerInit = logging.getLogger(Functions.INIT_LOG) loggerInit = logging.getLogger(Functions.INIT_LOG)

View file

@ -6,3 +6,4 @@ docker
kubernetes kubernetes
deepdiff deepdiff
pyopenssl pyopenssl
psutil

View file

@ -11,11 +11,14 @@
acl is_rule_{{ host }}_2 hdr(host) -i {{ k }}:{{ o["port"] }} acl is_rule_{{ host }}_2 hdr(host) -i {{ k }}:{{ o["port"] }}
{% if certbot %} {% if certbot %}
acl is_certbot_{{ host }} path_beg /.well-known/acme-challenge/ acl is_certbot_{{ host }} path_beg /.well-known/acme-challenge/
use_backend certbot_backend if is_certbot_{{ host }} is_rule_{{ host }}_1 OR is_certbot_{{ host }} is_rule_{{ host }}_2
{% endif %} {% endif %}
{% if o["hosts"][k]["redirect_ssl"] %} {% if o["hosts"][k]["redirect_ssl"] %}
http-request redirect scheme https code 301 if {% if certbot %}!is_certbot_{{ host }} {% endif %}is_rule_{{ host }}_1 OR {% if certbot %}!is_certbot_{{ host }} {% endif %}is_rule_{{ host }}_2 http-request redirect scheme https code 301 if {% if certbot %}!is_certbot_{{ host }} {% endif %}is_rule_{{ host }}_1 OR {% if certbot %}!is_certbot_{{ host }} {% endif %}is_rule_{{ host }}_2
{% else %} {% endif %}
{% if certbot %}
use_backend certbot_backend if is_certbot_{{ host }} is_rule_{{ host }}_1 OR is_certbot_{{ host }} is_rule_{{ host }}_2
{% endif %}
{% if not o["hosts"][k]["redirect_ssl"] %}
use_backend srv_{{ host }} if is_rule_{{ host }}_1 OR is_rule_{{ host }}_2 use_backend srv_{{ host }} if is_rule_{{ host }}_1 OR is_rule_{{ host }}_2
{% endif %} {% endif %}
{% endfor %} {% endfor %}

View file

@ -51,8 +51,8 @@ frontend http_in_80
acl is_rule_test_example_org_80_1 hdr(host) -i test.example.org acl is_rule_test_example_org_80_1 hdr(host) -i test.example.org
acl is_rule_test_example_org_80_2 hdr(host) -i test.example.org:80 acl is_rule_test_example_org_80_2 hdr(host) -i test.example.org:80
acl is_certbot_test_example_org_80 path_beg /.well-known/acme-challenge/ acl is_certbot_test_example_org_80 path_beg /.well-known/acme-challenge/
use_backend certbot_backend if is_certbot_test_example_org_80 is_rule_test_example_org_80_1 OR is_certbot_test_example_org_80 is_rule_test_example_org_80_2
http-request redirect scheme https code 301 if !is_certbot_test_example_org_80 is_rule_test_example_org_80_1 OR !is_certbot_test_example_org_80 is_rule_test_example_org_80_2 http-request redirect scheme https code 301 if !is_certbot_test_example_org_80 is_rule_test_example_org_80_1 OR !is_certbot_test_example_org_80 is_rule_test_example_org_80_2
use_backend certbot_backend if is_certbot_test_example_org_80 is_rule_test_example_org_80_1 OR is_certbot_test_example_org_80 is_rule_test_example_org_80_2
acl is_rule_test2_example_org_80_1 hdr(host) -i test2.example.org acl is_rule_test2_example_org_80_1 hdr(host) -i test2.example.org
acl is_rule_test2_example_org_80_2 hdr(host) -i test2.example.org:80 acl is_rule_test2_example_org_80_2 hdr(host) -i test2.example.org:80

View file

@ -1,5 +1,7 @@
import os import os
import psutil
from functions import DaemonizeHAProxy, Functions from functions import DaemonizeHAProxy, Functions
@ -17,10 +19,31 @@ def test_daemonize_haproxy_get_haproxy_command_start():
command = daemon.get_haproxy_command(DaemonizeHAProxy.HAPROXY_START) command = daemon.get_haproxy_command(DaemonizeHAProxy.HAPROXY_START)
assert command == "/usr/sbin/haproxy -W -f /etc/haproxy/haproxy.cfg -p /run/haproxy.pid -S /var/run/haproxy.sock" assert command == "/usr/sbin/haproxy -W -f /etc/haproxy/haproxy.cfg -p /run/haproxy.pid -S /var/run/haproxy.sock"
def test_daemonize_haproxy_get_haproxy_command_reload(): def test_daemonize_haproxy_get_haproxy_command_reload_nopid():
daemon = DaemonizeHAProxy() daemon = DaemonizeHAProxy()
command = daemon.get_haproxy_command(DaemonizeHAProxy.HAPROXY_RELOAD) command = daemon.get_haproxy_command(DaemonizeHAProxy.HAPROXY_RELOAD)
assert command == "/usr/sbin/haproxy -W -f /etc/haproxy/haproxy.cfg -p /run/haproxy.pid -x /var/run/haproxy.sock -sf " assert command == "/usr/sbin/haproxy -W -f /etc/haproxy/haproxy.cfg -p /run/haproxy.pid -S /var/run/haproxy.sock"
def test_daemonize_haproxy_get_haproxy_command_reload_pidinvalid():
daemon = DaemonizeHAProxy()
try:
with open("/tmp/temp.pid", 'w') as file:
file.write("-1001")
command = daemon.get_haproxy_command(DaemonizeHAProxy.HAPROXY_RELOAD, "/tmp/temp.pid")
assert command == "/usr/sbin/haproxy -W -f /etc/haproxy/haproxy.cfg -p /tmp/temp.pid -S /var/run/haproxy.sock"
finally:
assert not os.path.exists("/tmp/temp.pid")
def test_daemonize_haproxy_get_haproxy_command_reload_existing_pin():
daemon = DaemonizeHAProxy()
try:
with open("/tmp/temp.pid", 'w') as file:
file.write("1")
command = daemon.get_haproxy_command(DaemonizeHAProxy.HAPROXY_RELOAD, "/tmp/temp.pid")
assert command == "/usr/sbin/haproxy -W -f /etc/haproxy/haproxy.cfg -p /tmp/temp.pid -x /var/run/haproxy.sock -sf 1"
finally:
assert os.path.exists("/tmp/temp.pid")
os.unlink("/tmp/temp.pid")
def test_daemonize_haproxy2_check_config(): def test_daemonize_haproxy2_check_config():
daemon = DaemonizeHAProxy(os.path.abspath(os.path.dirname(__file__)) + '/fixtures') daemon = DaemonizeHAProxy(os.path.abspath(os.path.dirname(__file__)) + '/fixtures')
@ -34,15 +57,3 @@ def test_daemonize_haproxy2_get_haproxy_command_start():
daemon = DaemonizeHAProxy(os.path.abspath(os.path.dirname(__file__)) + '/fixtures') daemon = DaemonizeHAProxy(os.path.abspath(os.path.dirname(__file__)) + '/fixtures')
command = daemon.get_haproxy_command(DaemonizeHAProxy.HAPROXY_START) command = daemon.get_haproxy_command(DaemonizeHAProxy.HAPROXY_START)
assert command == "/usr/sbin/haproxy -W -f /etc/haproxy/haproxy.cfg -f %s -p /run/haproxy.pid -S /var/run/haproxy.sock" % (os.path.dirname(__file__) + "/fixtures") assert command == "/usr/sbin/haproxy -W -f /etc/haproxy/haproxy.cfg -f %s -p /run/haproxy.pid -S /var/run/haproxy.sock" % (os.path.dirname(__file__) + "/fixtures")
def test_daemonize_haproxy2_get_haproxy_command_reload():
tmp_pid_file = "/tmp/tmp_pid.txt"
Functions.save(tmp_pid_file, "10")
try:
daemon = DaemonizeHAProxy(os.path.abspath(os.path.dirname(__file__)) + '/fixtures')
command = daemon.get_haproxy_command(DaemonizeHAProxy.HAPROXY_RELOAD, tmp_pid_file)
assert command == "/usr/sbin/haproxy -W -f /etc/haproxy/haproxy.cfg -f %s -p %s -x /var/run/haproxy.sock -sf %s" % (os.path.dirname(__file__) + "/fixtures", tmp_pid_file, 10)
finally:
os.remove(tmp_pid_file)