1
0
Fork 0

Some fixes and documentation

- Introduced ACME HTTP-01 challenge documentation.
- Added Docker Compose example for HTTP-01 setup.
- Updated `get_haproxy_command` logic with improved PID validation using `psutil`.
- Enhanced logging with a new `SingleLineNonEmptyFilter`.
- Expanded tests for daemonize functionalities.
- Updated dependencies to include `psutil`.
This commit is contained in:
Joao Gilberto Magalhaes 2025-08-24 16:46:30 -04:00
parent b4b02916af
commit ea9e4ee7c8
8 changed files with 125 additions and 24 deletions

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

@ -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()
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) 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)
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

@ -5,4 +5,5 @@ pytest
docker 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)