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:
parent
b4b02916af
commit
ea9e4ee7c8
8 changed files with 125 additions and 24 deletions
|
|
@ -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
|
||||||
|
|
|
||||||
40
docs/acme.md
40
docs/acme.md
|
|
@ -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 EasyHAProxy’s 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:
|
||||||
|
|
|
||||||
|
|
@ -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)
|
||||||
|
|
|
||||||
|
|
@ -5,4 +5,5 @@ pytest
|
||||||
docker
|
docker
|
||||||
kubernetes
|
kubernetes
|
||||||
deepdiff
|
deepdiff
|
||||||
pyopenssl
|
pyopenssl
|
||||||
|
psutil
|
||||||
|
|
@ -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 %}
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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)
|
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue