diff --git a/build/Dockerfile b/build/Dockerfile index 9c03c36..51772bf 100644 --- a/build/Dockerfile +++ b/build/Dockerfile @@ -1,4 +1,4 @@ -FROM alpine:3.20 +FROM alpine:3.22 ARG RELEASE_VERSION_ARG @@ -6,6 +6,7 @@ ENV RELEASE_VERSION=$RELEASE_VERSION_ARG ENV TZ="Etc/UTC" 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 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 apk del .build-deps + RUN pytest -s -vv tests/ CMD ["/usr/bin/python", "-u", "/scripts/main.py" ] diff --git a/build/assets/certs/haproxy/.place_holder_cert.pem b/build/assets/certs/haproxy/place_holder_cert.pem similarity index 100% rename from build/assets/certs/haproxy/.place_holder_cert.pem rename to build/assets/certs/haproxy/place_holder_cert.pem diff --git a/deploy/docker/install.sh b/deploy/docker/install.sh index 366b146..347ded6 100755 --- a/deploy/docker/install.sh +++ b/deploy/docker/install.sh @@ -7,7 +7,7 @@ docker volume create certs_certbot docker volume create certs_haproxy 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 echo diff --git a/docs/acme.md b/docs/acme.md index 2de2e26..8e43d3e 100644 --- a/docs/acme.md +++ b/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. +## 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..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..port=80`). ACME HTTP-01 will not work if the front port is not 80. + - Provide the domain via `easyhaproxy..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 To enable the ACME protocol we need to enable Certbot in EasyHAProxy by setting up to the following environment variables: diff --git a/examples/docker/docker-compose-acme.yml b/examples/docker/docker-compose-acme.yml new file mode 100644 index 0000000..7141a12 --- /dev/null +++ b/examples/docker/docker-compose-acme.yml @@ -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 + diff --git a/src/functions/__init__.py b/src/functions/__init__.py index d7a6eaa..c6e8bcf 100644 --- a/src/functions/__init__.py +++ b/src/functions/__init__.py @@ -2,12 +2,12 @@ import os import shlex import subprocess import sys +import psutil import time import logging from datetime import datetime from multiprocessing import Process from typing import Final - import requests from OpenSSL import crypto @@ -120,6 +120,7 @@ class Functions: log_source_handler = logging.StreamHandler(sys.stdout) log_source_formatter = logging.Formatter('%(name)s [%(asctime)s] %(levelname)s - %(message)s') log_source_handler.setFormatter(log_source_formatter) + log_source_handler.addFilter(SingleLineNonEmptyFilter()) source.setLevel(selected_level) source.addHandler(log_source_handler) return selected_level @@ -202,18 +203,26 @@ class DaemonizeHAProxy: if len(list(self.get_custom_config_files().keys())) != 0: 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) else: return_code, output = Functions().run_bash(loggerHaproxy, "cat %s" % pid_file, log_output=False) - pid = "".join(output) - 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) + 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) + 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): if not isinstance(command, (list, tuple)): command = shlex.split(command) try: + loggerHaproxy.debug("HAPROXY command: %s" % command) self.process = subprocess.Popen(command, shell=False, stdout=subprocess.PIPE, @@ -228,7 +237,7 @@ class DaemonizeHAProxy: try: with self.process.stdout: for line in iter(self.process.stdout.readline, b''): - loggerHaproxy.info(line) + loggerHaproxy.info(line.rstrip()) return_code = self.process.wait() loggerHaproxy.debug("Return code %s" % return_code) @@ -354,6 +363,11 @@ class Certbot: if 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 return_code_issue = 0 return_code_renew = 0 @@ -424,6 +438,38 @@ class Certbot: self.freeze_issue[host] = self.retry_count 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 loggerInit = logging.getLogger(Functions.INIT_LOG) diff --git a/src/requirements.txt b/src/requirements.txt index 6a6cc6b..d7cd171 100644 --- a/src/requirements.txt +++ b/src/requirements.txt @@ -5,4 +5,5 @@ pytest docker kubernetes deepdiff -pyopenssl \ No newline at end of file +pyopenssl +psutil \ No newline at end of file diff --git a/src/templates/frontend-mode-http.j2 b/src/templates/frontend-mode-http.j2 index 5f9b0b0..e61f362 100644 --- a/src/templates/frontend-mode-http.j2 +++ b/src/templates/frontend-mode-http.j2 @@ -11,11 +11,14 @@ acl is_rule_{{ host }}_2 hdr(host) -i {{ k }}:{{ o["port"] }} {% if certbot %} 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 %} {% 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 - {% 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 {% endif %} {% endfor %} diff --git a/src/tests/expected/services-letsencrypt.txt b/src/tests/expected/services-letsencrypt.txt index ca78ace..d95732d 100644 --- a/src/tests/expected/services-letsencrypt.txt +++ b/src/tests/expected/services-letsencrypt.txt @@ -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_2 hdr(host) -i test.example.org:80 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 + 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_2 hdr(host) -i test2.example.org:80 diff --git a/src/tests/test_daemonize.py b/src/tests/test_daemonize.py index f5a4a23..8f5bf1d 100644 --- a/src/tests/test_daemonize.py +++ b/src/tests/test_daemonize.py @@ -1,5 +1,7 @@ import os +import psutil + 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) 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() 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(): 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') 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") - - -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)