Merge pull request #61 from byjg/fix-some-issues
Improve ACME and Docker documentation with fixes
This commit is contained in:
commit
c861d9b276
10 changed files with 170 additions and 25 deletions
|
|
@ -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" ]
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
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.
|
||||
|
||||
## 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
|
||||
|
||||
To enable the ACME protocol we need to enable Certbot in EasyHAProxy by setting up to the following environment variables:
|
||||
|
|
|
|||
41
examples/docker/docker-compose-acme.yml
Normal file
41
examples/docker/docker-compose-acme.yml
Normal 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
|
||||
|
||||
|
|
@ -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)
|
||||
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)
|
||||
|
|
|
|||
|
|
@ -6,3 +6,4 @@ docker
|
|||
kubernetes
|
||||
deepdiff
|
||||
pyopenssl
|
||||
psutil
|
||||
|
|
@ -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 %}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue