From 7b61be60d8cba33e35246394be9a41fcebcf0bdf Mon Sep 17 00:00:00 2001 From: Joao Gilberto Date: Sat, 11 Feb 2023 11:44:48 -0600 Subject: [PATCH 01/33] Added documentation for volume --- docs/volumes.md | 14 ++++++++++++++ 1 file changed, 14 insertions(+) create mode 100644 docs/volumes.md diff --git a/docs/volumes.md b/docs/volumes.md new file mode 100644 index 0000000..50f6065 --- /dev/null +++ b/docs/volumes.md @@ -0,0 +1,14 @@ +# Volumes + +You can map the following volumes: + +| Volume | Description | +|------|------| +| /etc/haproxy/static/ | The folder that will contain the [config.yml](static.md) file for static configuration | +| /certs/haproxy/ | The folder that will contain the certificates (`PEM`) for the [SSL](ssl.md) | +| /certs/letsencrypt/ | The folder that will contain the certificates (`PEM`) for the SSL. Use this volume to cache the [letsencrypt](letsencrypt.md) certificate and avoid re-issue certificates between restarts. | +| /etc/haproxy/conf.d/ | The folder that will contain the [custom configuration](other.md) files. | +| /etc/haproxy/errors-custom/ | The folder that will contain the [custom error](other.md) files. | + +---- +[Open source ByJG](http://opensource.byjg.com) \ No newline at end of file From c2beff08fc88dd9e9a7d4e74edcb7a2de6e4da61 Mon Sep 17 00:00:00 2001 From: Joao Gilberto Date: Sat, 11 Feb 2023 15:34:18 -0600 Subject: [PATCH 02/33] Load custom config files --- src/functions/__init__.py | 29 +++++++++++++++----- src/main.py | 4 ++- src/tests/fixtures/00_haproxy.cfg | 2 ++ src/tests/fixtures/10_haproxy.cfg | 2 ++ src/tests/test_daemonize.py | 44 +++++++++++++++++++++++++++++++ 5 files changed, 74 insertions(+), 7 deletions(-) create mode 100644 src/tests/fixtures/00_haproxy.cfg create mode 100644 src/tests/fixtures/10_haproxy.cfg create mode 100644 src/tests/test_daemonize.py diff --git a/src/functions/__init__.py b/src/functions/__init__.py index d174dcf..7f0a331 100644 --- a/src/functions/__init__.py +++ b/src/functions/__init__.py @@ -100,21 +100,19 @@ class Functions: class Consts: easyhaproxy_config = "/etc/haproxy/static/config.yml" haproxy_config = "/etc/haproxy/haproxy.cfg" + custom_config_folder = "/etc/haproxy/conf.d" certs_letsencrypt = "/certs/letsencrypt" certs_haproxy = "/certs/haproxy" class DaemonizeHAProxy: - def __init__(self): + def __init__(self, custom_config_folder = None): self.process = None self.thread = None self.sleep_secs = None + self.custom_config_folder = custom_config_folder if custom_config_folder is not None else Consts.custom_config_folder def haproxy(self, action): - if action == "start": - self.__prepare("/usr/sbin/haproxy -W -f /etc/haproxy/haproxy.cfg -p /run/haproxy.pid -S /var/run/haproxy.sock") - else: - pid = "".join(Functions().run_bash(Functions.HAPROXY_LOG, "cat /run/haproxy.pid", log_output=False)) - self.__prepare("/usr/sbin/haproxy -W -f /etc/haproxy/haproxy.cfg -p /run/haproxy.pid -x /var/run/haproxy.sock -sf %s" % (pid)) + self.__prepare(self.get_haproxy_command(action)) if self.process is None: return @@ -122,6 +120,15 @@ class DaemonizeHAProxy: self.thread = Process(target=self.__start, args=()) self.thread.start() + def get_haproxy_command(self, action): + custom_config_files = " ".join(["-f %s" % (file) for file in self.get_custom_config_files()]) + + if action == "start": + return "/usr/sbin/haproxy -W -f /etc/haproxy/haproxy.cfg %s -p /run/haproxy.pid -S /var/run/haproxy.sock" % (custom_config_files) + else: + pid = "".join(Functions().run_bash(Functions.HAPROXY_LOG, "cat /run/haproxy.pid", log_output=False)) + return "/usr/sbin/haproxy -W -f /etc/haproxy/haproxy.cfg %s -p /run/haproxy.pid -x /var/run/haproxy.sock -sf %s" % (custom_config_files, pid) + def __prepare(self, command): source = Functions.HAPROXY_LOG if not isinstance(command, (list, tuple)): @@ -172,6 +179,16 @@ class DaemonizeHAProxy: time.sleep(self.sleep_secs) + def get_custom_config_files(self): + if not os.path.exists(self.custom_config_folder): + return {} + + files = {} + for file in os.listdir(self.custom_config_folder): + if file.endswith(".cfg"): + files[os.path.join(self.custom_config_folder, file)] = os.path.getmtime(os.path.join(self.custom_config_folder, file)) + return dict(sorted(files.items(), key=lambda t: t[0])) + class Certbot: def __init__(self, certs, email, test_server): diff --git a/src/main.py b/src/main.py index 34e29d8..48440e1 100644 --- a/src/main.py +++ b/src/main.py @@ -19,6 +19,7 @@ def start(): old_haproxy = None haproxy = DaemonizeHAProxy() + current_custom_config_files = haproxy.get_custom_config_files() haproxy.haproxy("start") haproxy.sleep() @@ -31,7 +32,7 @@ def start(): try: old_parsed = processor_obj.get_parsed_object() processor_obj.refresh() - if certbot.check_certificates(letsencrypt_certs_found) or DeepDiff(old_parsed, processor_obj.get_parsed_object()) != {} or not haproxy.is_alive(): + if certbot.check_certificates(letsencrypt_certs_found) or DeepDiff(old_parsed, processor_obj.get_parsed_object()) != {} or not haproxy.is_alive() or DeepDiff(current_custom_config_files, haproxy.get_custom_config_files()) != {}: Functions.log(Functions.EASYHAPROXY_LOG, Functions.DEBUG, 'New configuration found. Reloading...') Functions.log(Functions.EASYHAPROXY_LOG, Functions.TRACE, 'Object Found: %s' % (processor_obj.get_parsed_object())) processor_obj.save_config(Consts.haproxy_config) @@ -40,6 +41,7 @@ def start(): Functions.log(Functions.EASYHAPROXY_LOG, Functions.DEBUG, 'Found hosts: %s' % ", ".join(processor_obj.get_hosts())) # Needs to after save_config old_haproxy = haproxy haproxy = DaemonizeHAProxy() + current_custom_config_files = haproxy.get_custom_config_files() haproxy.haproxy("reload") old_haproxy.terminate() diff --git a/src/tests/fixtures/00_haproxy.cfg b/src/tests/fixtures/00_haproxy.cfg new file mode 100644 index 0000000..eaec319 --- /dev/null +++ b/src/tests/fixtures/00_haproxy.cfg @@ -0,0 +1,2 @@ +global + maxconn 4000 \ No newline at end of file diff --git a/src/tests/fixtures/10_haproxy.cfg b/src/tests/fixtures/10_haproxy.cfg new file mode 100644 index 0000000..9a855a9 --- /dev/null +++ b/src/tests/fixtures/10_haproxy.cfg @@ -0,0 +1,2 @@ +global + maxconn 5000 \ No newline at end of file diff --git a/src/tests/test_daemonize.py b/src/tests/test_daemonize.py new file mode 100644 index 0000000..5b8baa3 --- /dev/null +++ b/src/tests/test_daemonize.py @@ -0,0 +1,44 @@ +import json +import pytest +import os +import re +import random +import string +from functions import DaemonizeHAProxy + +def test_daemonize_haproxy(): + daemon = DaemonizeHAProxy() + assert daemon is not None + +def test_daemonize_haproxy_check_config(): + daemon = DaemonizeHAProxy() + filed = daemon.get_custom_config_files() + assert filed == {} + +def test_daemonize_haproxy_get_haproxy_command_start(): + daemon = DaemonizeHAProxy() + command = daemon.get_haproxy_command("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(): + daemon = DaemonizeHAProxy() + command = daemon.get_haproxy_command("reload") + assert command == "/usr/sbin/haproxy -W -f /etc/haproxy/haproxy.cfg -p /run/haproxy.pid -x /var/run/haproxy.sock -sf " + +def test_daemonize_haproxy_check_config(): + daemon = DaemonizeHAProxy(os.path.abspath(os.path.dirname(__file__)) + '/fixtures') + filed = daemon.get_custom_config_files() + assert filed == { + os.path.dirname(__file__) + "/fixtures/00_haproxy.cfg": os.path.getmtime(os.path.dirname(__file__) + "/fixtures/00_haproxy.cfg"), + os.path.dirname(__file__) + "/fixtures/10_haproxy.cfg": os.path.getmtime(os.path.dirname(__file__) + "/fixtures/10_haproxy.cfg") + } + +def test_daemonize_haproxy_get_haproxy_command_start(): + daemon = DaemonizeHAProxy(os.path.abspath(os.path.dirname(__file__)) + '/fixtures') + command = daemon.get_haproxy_command("start") + assert command == "/usr/sbin/haproxy -W -f /etc/haproxy/haproxy.cfg -f %s/00_haproxy.cfg -f %s/10_haproxy.cfg -p /run/haproxy.pid -S /var/run/haproxy.sock" % (os.path.dirname(__file__) + "/fixtures", os.path.dirname(__file__) + "/fixtures") + +def test_daemonize_haproxy_get_haproxy_command_reload(): + daemon = DaemonizeHAProxy(os.path.abspath(os.path.dirname(__file__)) + '/fixtures') + command = daemon.get_haproxy_command("reload") + assert command == "/usr/sbin/haproxy -W -f /etc/haproxy/haproxy.cfg -f %s/00_haproxy.cfg -f %s/10_haproxy.cfg -p /run/haproxy.pid -x /var/run/haproxy.sock -sf " % (os.path.dirname(__file__) + "/fixtures", os.path.dirname(__file__) + "/fixtures") From 089d4434d9475478223fdf344f80944d8e40940d Mon Sep 17 00:00:00 2001 From: Joao Gilberto Magalhaes Date: Sun, 12 Feb 2023 20:08:12 -0600 Subject: [PATCH 03/33] Adjustment in the HAProxy conf.d --- src/functions/__init__.py | 4 +++- src/tests/test_daemonize.py | 4 ++-- 2 files changed, 5 insertions(+), 3 deletions(-) diff --git a/src/functions/__init__.py b/src/functions/__init__.py index 7f0a331..f4b9b3c 100644 --- a/src/functions/__init__.py +++ b/src/functions/__init__.py @@ -121,7 +121,9 @@ class DaemonizeHAProxy: self.thread.start() def get_haproxy_command(self, action): - custom_config_files = " ".join(["-f %s" % (file) for file in self.get_custom_config_files()]) + custom_config_files = "" + if len(list(self.get_custom_config_files().keys())) != 0: + custom_config_files = "-f %s" % (self.custom_config_folder) if action == "start": return "/usr/sbin/haproxy -W -f /etc/haproxy/haproxy.cfg %s -p /run/haproxy.pid -S /var/run/haproxy.sock" % (custom_config_files) diff --git a/src/tests/test_daemonize.py b/src/tests/test_daemonize.py index 5b8baa3..fb1ea08 100644 --- a/src/tests/test_daemonize.py +++ b/src/tests/test_daemonize.py @@ -36,9 +36,9 @@ def test_daemonize_haproxy_check_config(): def test_daemonize_haproxy_get_haproxy_command_start(): daemon = DaemonizeHAProxy(os.path.abspath(os.path.dirname(__file__)) + '/fixtures') command = daemon.get_haproxy_command("start") - assert command == "/usr/sbin/haproxy -W -f /etc/haproxy/haproxy.cfg -f %s/00_haproxy.cfg -f %s/10_haproxy.cfg -p /run/haproxy.pid -S /var/run/haproxy.sock" % (os.path.dirname(__file__) + "/fixtures", 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_haproxy_get_haproxy_command_reload(): daemon = DaemonizeHAProxy(os.path.abspath(os.path.dirname(__file__)) + '/fixtures') command = daemon.get_haproxy_command("reload") - assert command == "/usr/sbin/haproxy -W -f /etc/haproxy/haproxy.cfg -f %s/00_haproxy.cfg -f %s/10_haproxy.cfg -p /run/haproxy.pid -x /var/run/haproxy.sock -sf " % (os.path.dirname(__file__) + "/fixtures", os.path.dirname(__file__) + "/fixtures") + assert command == "/usr/sbin/haproxy -W -f /etc/haproxy/haproxy.cfg -f %s -p /run/haproxy.pid -x /var/run/haproxy.sock -sf " % (os.path.dirname(__file__) + "/fixtures") From 173a31ea808bcb59754e3f6050a54096d49c546f Mon Sep 17 00:00:00 2001 From: Joao Gilberto Magalhaes Date: Sun, 12 Feb 2023 20:29:33 -0600 Subject: [PATCH 04/33] Upgrade HAProxy 2.6 LTS --- build/Dockerfile | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/build/Dockerfile b/build/Dockerfile index 20c3459..74b2970 100644 --- a/build/Dockerfile +++ b/build/Dockerfile @@ -1,4 +1,4 @@ -FROM alpine:3.16 +FROM alpine:3.17 ARG RELEASE_VERSION_ARG From 6e367174d43c3300182ecf7007b2603c76417a4f Mon Sep 17 00:00:00 2001 From: Joao Gilberto Magalhaes Date: Sun, 26 Feb 2023 20:56:32 -0600 Subject: [PATCH 05/33] Add ssl-check --- docs/container-labels.md | 3 ++- docs/kubernetes.md | 1 + src/easymapping/__init__.py | 12 ++++++++---- src/processor/__init__.py | 6 ++++-- src/templates/haproxy.cfg.j2 | 6 +++--- src/tests/fixtures/services-tcp | 2 +- src/tests/test_parser.py | 22 ++++++++++++++++------ 7 files changed, 35 insertions(+), 17 deletions(-) diff --git a/docs/container-labels.md b/docs/container-labels.md index b71cecb..5052c1a 100644 --- a/docs/container-labels.md +++ b/docs/container-labels.md @@ -11,10 +11,11 @@ | easyhaproxy.[definition].redirect | (Optional) JSON containing key/value pair from host/to URL redirect. | *empty* | {"foo.com":"https://bla.com", "bar.com":"https://bar.org"} | | easyhaproxy.[definition].sslcert | (Optional) Cert PEM Base64 encoded. Do not use this if `letsencrypt` is enabled. | *empty* | base64 cert + key | | easyhaproxy.[definition].ssl | (Optional) If `true` you need to provide certificate as a file. See below. Do not use with `sslcert`. | false | true or false | -| easyhaproxy.[definition].health-check | (Optional) `ssl`, enable health check via SSL in `mode tcp` | *empty* | ssl | +| easyhaproxy.[definition].ssl-check | (Optional) `ssl`, enable health check via SSL in `mode tcp` | *empty* | ssl | | easyhaproxy.[definition].letsencrypt | (Optional) Generate certificate with letsencrypt. Do not use with `sslcert` parameter. | false | true OR false | | easyhaproxy.[definition].redirect_ssl | (Optional) Redirect all requests to https | false | true OR false | | easyhaproxy.[definition].clone_to_ssl | (Optional) It copies the configuration to HTTPS(443) and disable SSL from the current config. **Do not use* this with `ssl` or `letsencrypt` parameters | false | true OR false | +| easyhaproxy.[definition].balance | (Optional) HAProxy balance algorithm. See [HAProxy documentation](https://cbonte.github.io/haproxy-dconv/1.8/configuration.html#4.2-balance) | roundrobin | roundrobin, source, uri, url_param, hdr, rdp-cookie, leastconn, first, static-rr, rdp-cookie, hdr_dom, map-based | The `definition` is a string that will group all configurations togethers. Different `definition` will create different configurations. diff --git a/docs/kubernetes.md b/docs/kubernetes.md index 24743f5..96e601a 100644 --- a/docs/kubernetes.md +++ b/docs/kubernetes.md @@ -87,6 +87,7 @@ Caveats: | easyhaproxy.redirect | (optional) JSON. Key pair with a domain and its destination. | *empty* | {"domain":"redirect_url"} | easyhaproxy.mode | (optional) Set the HTTP mode for that connection. | http | http or tcp | easyhaproxy.listen_port | (optional) Set the an additional port for that ingress | http | http or tcp +| easyhaproxy.balance | (optional) Set the balance algorithm for that ingress. See [HAProxy documentation](https://cbonte.github.io/haproxy-dconv/1.8/configuration.html#4.2-balance) | roundrobin | roundrobin, leastconn, source, uri, url_param, hdr, rdp-cookie, static-rr, static-est, hdr(host), rdp-cookie, map-based, map-based(backend) | **Important**: The annotations are per ingress and applied to all hosts in that ingress configuration. diff --git a/src/easymapping/__init__.py b/src/easymapping/__init__.py index c2626a6..b683d07 100644 --- a/src/easymapping/__init__.py +++ b/src/easymapping/__init__.py @@ -117,7 +117,7 @@ class HaproxyConfigGenerator: if port not in easymapping: easymapping[port] = { "mode": mode, - "health-check": "", + "ssl-check": "", "port": port, "hosts": dict(), "redirect": dict(), @@ -129,8 +129,8 @@ class HaproxyConfigGenerator: "80" ) - easymapping[port]["health-check"] = self.label.get( - self.label.create([definition, "health-check"]), + easymapping[port]["ssl-check"] = self.label.get( + self.label.create([definition, "ssl-check"]), "" ) @@ -145,6 +145,10 @@ class HaproxyConfigGenerator: easymapping[port]["hosts"][hostname]["redirect_ssl"] = self.label.get_bool( self.label.create([definition, "redirect_ssl"]) ) + easymapping[port]["hosts"][hostname]["balance"] = self.label.get( + self.label.create([definition, "balance"]), + "roundrobin" + ) easymapping[port]["redirect"] = self.label.get_json( self.label.create([definition, "redirect"]) @@ -154,7 +158,7 @@ class HaproxyConfigGenerator: if "443" not in easymapping: easymapping["443"] = { "mode": "http", - "health-check": "ssl", + "ssl-check": "ssl", "port": "443", "hosts": dict(), "redirect": dict(), diff --git a/src/processor/__init__.py b/src/processor/__init__.py index 748ad1e..fa50232 100644 --- a/src/processor/__init__.py +++ b/src/processor/__init__.py @@ -188,9 +188,9 @@ class Kubernetes(ProcessorInterface): self.cert_cache = {} super().__init__() - def _check_annotation(self, annotations, key): + def _check_annotation(self, annotations, key, default = None): if key not in annotations: - return None + return default return annotations[key] def inspect_network(self): @@ -258,6 +258,8 @@ class Kubernetes(ProcessorInterface): rule_data["%s.redirect" % (definition)] = redirect if mode is not None: rule_data["%s.mode" % (definition)] = mode + rule_data["%s.balance" % (definition)] = self._check_annotation(ingress.metadata.annotations, "easyhaproxy.balance", "roundrobin") + service_name = rule.http.paths[0].backend.service.name try: diff --git a/src/templates/haproxy.cfg.j2 b/src/templates/haproxy.cfg.j2 index a1842b1..1a71e6b 100644 --- a/src/templates/haproxy.cfg.j2 +++ b/src/templates/haproxy.cfg.j2 @@ -59,7 +59,7 @@ frontend {{ mode }}_in_{{ o["port"] }} {% for k in o["hosts"] -%} {% set host = k.replace(".", "_") + "_{0}".format(o["port"]) %} backend srv_{{ host }} - balance roundrobin + balance {{ o["balance"] | default("roundrobin") }} mode {{ mode }} {% if mode == "http" %} option forwardfor @@ -67,10 +67,10 @@ backend srv_{{ host }} http-request add-header X-Forwarded-Proto https if { ssl_fc } {% elif mode == "tcp" %} option tcp-check - tcp-check connect{{ " ssl" if o["health-check"] == "ssl" }} + tcp-check connect{{ " ssl" if o["ssl-check"] == "ssl" }} {% endif %} {% for c in o["hosts"][k]["containers"] %} - server srv-{{ loop.index0 }} {{ c }} check weight 1{{ " verify none" if o["health-check"] == "ssl" }} + server srv-{{ loop.index0 }} {{ c }} check weight 1{{ " verify none" if o["ssl-check"] == "ssl" }} {% endfor %} {% endfor %} {% endfor %} diff --git a/src/tests/fixtures/services-tcp b/src/tests/fixtures/services-tcp index 5b5c2b0..bc924ab 100644 --- a/src/tests/fixtures/services-tcp +++ b/src/tests/fixtures/services-tcp @@ -1,2 +1,2 @@ -{"test_agent": {"easyhaproxy.agent.host":"agent.quantum.local","easyhaproxy.agent.localport":"9001","easyhaproxy.agent.mode":"tcp","easyhaproxy.agent.port":"31339","com.docker.stack.image":"portainer/agent:1.5.1","com.docker.stack.namespace":"test", "easyhaproxy.agent.health-check":"ssl"}, +{"test_agent": {"easyhaproxy.agent.host":"agent.quantum.local","easyhaproxy.agent.localport":"9001","easyhaproxy.agent.mode":"tcp","easyhaproxy.agent.port":"31339","com.docker.stack.image":"portainer/agent:1.5.1","com.docker.stack.namespace":"test", "easyhaproxy.agent.ssl-check":"ssl"}, "test_proxy": {"com.docker.stack.image":"byjg/easy-haproxy:local","com.docker.stack.namespace":"test"}} \ No newline at end of file diff --git a/src/tests/test_parser.py b/src/tests/test_parser.py index a3d35f0..3af83db 100644 --- a/src/tests/test_parser.py +++ b/src/tests/test_parser.py @@ -110,10 +110,11 @@ def test_parser_finds_services_raw(): parsed_object = [ { "mode":"tcp", - "health-check":"", + "ssl-check":"", "port":"31339", "hosts":{ "agent.quantum.example.org": { + "balance": "roundrobin", "containers": [ "my-stack_agent:9001" ], @@ -127,10 +128,11 @@ def test_parser_finds_services_raw(): }, { "mode":"http", - "health-check":"", + "ssl-check":"", "port":"31337", "hosts":{ "cadvisor.quantum.example.org":{ + "balance": "roundrobin", "containers": [ "my-stack_cadvisor:8080" ], @@ -138,6 +140,7 @@ def test_parser_finds_services_raw(): "redirect_ssl": False }, "node-exporter.quantum.example.org":{ + "balance": "roundrobin", "containers": [ "my-stack_node-exporter:9100" ], @@ -151,10 +154,11 @@ def test_parser_finds_services_raw(): }, { "mode":"http", - "health-check":"", + "ssl-check":"", "port":"443", "hosts":{ "node-exporter.quantum.example.org": { + "balance": "roundrobin", "containers": [ "my-stack_node-exporter:9100" ], @@ -162,6 +166,7 @@ def test_parser_finds_services_raw(): "redirect_ssl": False }, "www.somehost.com.br":{ + "balance": "roundrobin", "containers": [ "some-service:80" ], @@ -180,10 +185,11 @@ def test_parser_finds_services_raw(): }, { "mode":"http", - "health-check":"", + "ssl-check":"", "port":"80", "hosts":{ "www.somehost.com.br":{ + "balance": "roundrobin", "containers": [ "some-service:80" ], @@ -444,9 +450,10 @@ def test_parser_finds_services_clone_to_ssl_raw(): parsed_object = [ { - "health-check":"", + "ssl-check":"", "hosts":{ "host2.local":{ + "balance":"roundrobin", "containers":[ "10.152.183.215:8080" ], @@ -454,6 +461,7 @@ def test_parser_finds_services_clone_to_ssl_raw(): "redirect_ssl": False }, "valida.me":{ + "balance":"roundrobin", "containers":[ "10.152.183.62:8080" ], @@ -461,6 +469,7 @@ def test_parser_finds_services_clone_to_ssl_raw(): "redirect_ssl": False }, "www.valida.me":{ + "balance":"roundrobin", "containers":[ "10.152.183.62:8080" ], @@ -475,9 +484,10 @@ def test_parser_finds_services_clone_to_ssl_raw(): } }, { - "health-check":"ssl", + "ssl-check":"ssl", "hosts":{ "host2.local":{ + "balance":"roundrobin", "containers":[ "10.152.183.215:8080" ], From 3cc9547388727fcf1ff17b602a4d9f61d8a7181a Mon Sep 17 00:00:00 2001 From: Joao Gilberto Magalhaes Date: Sun, 26 Feb 2023 20:58:43 -0600 Subject: [PATCH 06/33] Try Fix Swarm connection --- src/processor/__init__.py | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/src/processor/__init__.py b/src/processor/__init__.py index 748ad1e..ef43390 100644 --- a/src/processor/__init__.py +++ b/src/processor/__init__.py @@ -170,10 +170,10 @@ class Swarm(ProcessorInterface): ip_address = endpoint["Addr"].split("/")[0] break network_list.append(endpoint["NetworkID"]) - + + # add the network ha_proxy_network_id to the service object if ip_address is None: - network_list.append(ha_proxy_network_id) - service.update(networks = network_list) + self.client.networks.get(ha_proxy_network_id).connect(service.name) continue # skip to the next service to give time to update the network self.parsed_object[ip_address] = service.attrs["Spec"]["Labels"] From 7dc683b25441d7f1af24e20a9a8062dcd9ceb705 Mon Sep 17 00:00:00 2001 From: Joao Gilberto Magalhaes Date: Sun, 26 Feb 2023 21:39:07 -0600 Subject: [PATCH 07/33] Try to fix Docker Swarm with network attachment --- src/processor/__init__.py | 5 ++--- 1 file changed, 2 insertions(+), 3 deletions(-) diff --git a/src/processor/__init__.py b/src/processor/__init__.py index ef43390..530d077 100644 --- a/src/processor/__init__.py +++ b/src/processor/__init__.py @@ -164,16 +164,15 @@ class Swarm(ProcessorInterface): self.parsed_object = {} for service in self.client.services.list(): ip_address = None - network_list = [] for endpoint in service.attrs["Endpoint"]["VirtualIPs"]: if ha_proxy_network_id == endpoint["NetworkID"]: ip_address = endpoint["Addr"].split("/")[0] break - network_list.append(endpoint["NetworkID"]) # add the network ha_proxy_network_id to the service object if ip_address is None: - self.client.networks.get(ha_proxy_network_id).connect(service.name) + network_attachment = docker.types.NetworkAttachmentConfig(target=ha_proxy_network_id) + service.update(networks = [network_attachment]) continue # skip to the next service to give time to update the network self.parsed_object[ip_address] = service.attrs["Spec"]["Labels"] From aa5f8055f54e36c2daac1bdb69164b2ba86b9805 Mon Sep 17 00:00:00 2001 From: Joao Gilberto Date: Mon, 27 Feb 2023 10:08:38 -0600 Subject: [PATCH 08/33] Better examples for Swarm --- docs/volumes.md | 14 ++++ examples/swarm/certs/host1.local.pem | 82 ++++++++++++++++++++++ examples/swarm/{ => certs}/host2.local.pem | 0 examples/swarm/easyhaproxy.yml | 33 +++++++++ examples/swarm/portainer.yml | 28 ++++++++ examples/swarm/services.yml | 51 ++++++++++++++ 6 files changed, 208 insertions(+) create mode 100644 docs/volumes.md create mode 100644 examples/swarm/certs/host1.local.pem rename examples/swarm/{ => certs}/host2.local.pem (100%) create mode 100644 examples/swarm/easyhaproxy.yml create mode 100644 examples/swarm/portainer.yml create mode 100644 examples/swarm/services.yml diff --git a/docs/volumes.md b/docs/volumes.md new file mode 100644 index 0000000..4413095 --- /dev/null +++ b/docs/volumes.md @@ -0,0 +1,14 @@ +# Volumes + +You can map the following volumes: + +| Volume | Description | +|-----------------------------|----------------------------------------------------------------------------------------| +| /etc/haproxy/static/ | The folder that will contain the [config.yml](static.md) file for static configuration | +| /certs/haproxy/ | The folder that will contain the certificates (`PEM`) for the [SSL](ssl.md) | +| /certs/letsencrypt/ | The folder that will contain the certificates (`PEM`) for the SSL. Use this volume to cache the [letsencrypt](letsencrypt.md) certificate and avoid re-issue certificates between restarts. | +| /etc/haproxy/conf.d/ | The folder that will contain the [custom configuration](other.md) files. | +| /etc/haproxy/errors-custom/ | The folder that will contain the [custom error](other.md) html files. | + +---- +[Open source ByJG](http://opensource.byjg.com) diff --git a/examples/swarm/certs/host1.local.pem b/examples/swarm/certs/host1.local.pem new file mode 100644 index 0000000..0d7eb39 --- /dev/null +++ b/examples/swarm/certs/host1.local.pem @@ -0,0 +1,82 @@ +-----BEGIN CERTIFICATE----- +MIIFDTCCAvWgAwIBAgIURi+w1ZVgeedTlNIAwqQBMJv6dXswDQYJKoZIhvcNAQEL +BQAwFjEUMBIGA1UEAwwLaG9zdDEubG9jYWwwHhcNMjEwODEwMTg0OTA2WhcNMzEw +ODA4MTg0OTA2WjAWMRQwEgYDVQQDDAtob3N0MS5sb2NhbDCCAiIwDQYJKoZIhvcN +AQEBBQADggIPADCCAgoCggIBAMBDAhLAygJuaW6w6ffigzTAAGXpmEz0tIxn1k4Z +x5wN5rpv/qu0QMYz+Av2u1eOKEKZeaFRVpT0r93dX7IvbEZHt25GPiBvlLGqhjKR +PnSk/7U8XmsnttUAV7rVEK1UrdFw8/IwriQC+dhr0mnYfSDMkvBoMFpdhVNTrbAZ +1TB6rQjE7Ar0Mt8my96XJmwrcjK2Tj+E2rgPIUz1e5cekFYIDSBatmw+3+vr+T5x +FNFkJ2o30W5o8ZflCJJzrVaihqQics6ZKDgpf7iqXMFiwWIlhdQpGvx5Gf/KFTK9 +UaOnRZz/X+2CebAFaTHR3k/PYppWTgBBBuRvlpCw+wdnkmteC0SQRF91QWVr7ejo +7KaOlGI5VtvMUsWvTeAZmpaymIaATETuOJaY0JU11OmLeD9DOj5E2SQ7qIX/pFcp +xpzG5j4c+MlgvxP2VAkNTeAXCaYiPBQH5ZZg0HE2WnB1KhLRFlHd4iHQD2GJ5yN/ +6fCFBfZfKSeK8JauwxgWkra53OcDq/mKd+DA/dK+/ruG7tqwVgIa04HOplzM7LYR +GB0Irs9+lr5/PJbQZmU073Mdn6cXAg3p+6wvwFlDkS5v13gBDYNHtF62bc551edF +Z6kGzJ7wmGRo84aBP7MuRZeReLOrSS67a1wLdzZsMnP1TJ7x9Lfr9MKl2uDnQdnY +ex8DAgMBAAGjUzBRMB0GA1UdDgQWBBSQ/mtZd6h8en9YQVH6HO1PlWWiqzAfBgNV +HSMEGDAWgBSQ/mtZd6h8en9YQVH6HO1PlWWiqzAPBgNVHRMBAf8EBTADAQH/MA0G +CSqGSIb3DQEBCwUAA4ICAQChQYNuah3+mTpIBDYxGrjTJNuOTIMaWzMyi1tkf+L0 +sEGwpbmAO2mWWQYF7WVLsi98PULh3adjt2jiud9VlaaC6gnwn5Zo1+Pilo9sNLLW +6ij0+rN4kwIm/pNqi+jDuu2cvAuHIwZWeh8bEe/5UCxo4ihmWFQN8eJ6TUKCphRC +6Eor/SSZZBQHgPl0BchzHOkwu7R3LCndRqxjhAoVb9yQOV+ZsmTeJXulwNzJ1uLt +T8OIgIiDpmBo7HSN2H0k3chx00AsjUyJ9mmAWPejFe/KXLRPcVZR17jhzgfIBEzs +M5WtWFm1aHDjVv6M6iteVm61E9T+k/M11ru1e2YwsxTDvb6x04mcrNu9soqddBbr +VfpluuoQ/hEAbXtFNPoTySpz0cwOwcHCowVOLmdKgvImszZiMyHHG8VGGmPh88n7 +wVxb0gV0P4RMrcMLdeTdn55YQr1CqBr34eB6ol6AsbTm3VzBHRVmFNksl1o5JB5t +tXLgF/G8/rzJ/4m1PaVuxrB7DxUmIk8EPbSIVkvZvd7LBzKwQ6IfVaucewHfEajQ +VIiexSMiFc7lw3KnxjOHZjf6FM9VYg3No++GdC99s7LkIuJwAMLNqTQ7Hvhn7YvP +4FlSIgc6xj0YkGZEQlb5o/5nauEqQU0ABgw6jtI4NxrNLT6cp7CO4M0xIDEg/3YD +aA== +-----END CERTIFICATE----- +-----BEGIN PRIVATE KEY----- +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 +YkU14WzQKleFkiBJI9lVvnfgGnOlgg0= +-----END PRIVATE KEY----- \ No newline at end of file diff --git a/examples/swarm/host2.local.pem b/examples/swarm/certs/host2.local.pem similarity index 100% rename from examples/swarm/host2.local.pem rename to examples/swarm/certs/host2.local.pem diff --git a/examples/swarm/easyhaproxy.yml b/examples/swarm/easyhaproxy.yml new file mode 100644 index 0000000..3feef60 --- /dev/null +++ b/examples/swarm/easyhaproxy.yml @@ -0,0 +1,33 @@ +# To Install +# docker stack deploy -c easyhaproxy.yml easyhaproxy + + +version: "3" + +services: + haproxy: + image: byjg/easy-haproxy:4.3.1-rc1 + volumes: + - /var/run/docker.sock:/var/run/docker.sock + - ./certs:/certs/haproxy + - certs_letsencrypt:/certs/letsencrypt + deploy: + replicas: 1 + environment: + EASYHAPROXY_DISCOVER: swarm + EASYHAPROXY_SSL_MODE: "loose" + EASYHAPROXY_LETSENCRYPT_EMAIL: changeme@example.org + HAPROXY_CUSTOMERRORS: "true" + HAPROXY_USERNAME: admin + HAPROXY_PASSWORD: password + HAPROXY_STATS_PORT: 1936 + ports: + - "80:80/tcp" + - "443:443/tcp" + - "1936:1936/tcp" + +volumes: + certs_letsencrypt: + # external: true + # certs_haproxy: + # external: true \ No newline at end of file diff --git a/examples/swarm/portainer.yml b/examples/swarm/portainer.yml new file mode 100644 index 0000000..b438b58 --- /dev/null +++ b/examples/swarm/portainer.yml @@ -0,0 +1,28 @@ +# To install: +# docker stack deploy -c portainer.yml portainer + +version: "3" + +services: + portainer: + image: portainer/portainer-ce:latest + volumes: + - portainer_data:/data portainer + - /var/run/docker.sock:/var/run/docker.sock + deploy: + replicas: 1 + labels: + # easyhaproxy.http.redirect_ssl: true + # easyhaproxy.http.letsencrypt: true + easyhaproxy.http.host: portainer.local + easyhaproxy.http.port: 80 + easyhaproxy.http.localport: 9000 + +volumes: + certs_letsencrypt: + external: true + # certs_haproxy: + # external: true + portainer_data: + # external: true + diff --git a/examples/swarm/services.yml b/examples/swarm/services.yml new file mode 100644 index 0000000..ad16ce2 --- /dev/null +++ b/examples/swarm/services.yml @@ -0,0 +1,51 @@ +# To install: +# docker stack deploy -c services.yml services +# +# To test: +# curl -k -H "Host: host1.local" https://127.0.0.1/ +# curl -k -H "Host: host2.local" https://127.0.0.1/ +# +# curl -I -H Host:host1.local http://127.0.0.1 +# HTTP/1.1 301 Moved Permanently +# content-length: 0 +# location: https://host1.local/ +# +# curl -I -H Host:host2.local http://127.0.0.1 +# HTTP/1.1 301 Moved Permanently +# content-length: 0 +# location: https://host1.local/ +# +# Test SSL: +# openssl s_client -showcerts -connect 127.0.0.1:443 -servername host1.local + +version: "3" + +services: + container: + image: byjg/static-httpserver + deploy: + replicas: 1 + labels: + easyhaproxy.http.redirect_ssl: "true" + easyhaproxy.http.host: host1.local + easyhaproxy.http.port: 80 + + easyhaproxy.https.port: 443 + easyhaproxy.https.localport: 8080 + easyhaproxy.https.host: host1.local + easyhaproxy.https.sslcert: LS0tLS1CRUdJTiBDRVJUSUZJQ0FURS0tLS0tCk1JSUZEVENDQXZXZ0F3SUJBZ0lVUmkrdzFaVmdlZWRUbE5JQXdxUUJNSnY2ZFhzd0RRWUpLb1pJaHZjTkFRRUwKQlFBd0ZqRVVNQklHQTFVRUF3d0xhRzl6ZERFdWJHOWpZV3d3SGhjTk1qRXdPREV3TVRnME9UQTJXaGNOTXpFdwpPREE0TVRnME9UQTJXakFXTVJRd0VnWURWUVFEREF0b2IzTjBNUzVzYjJOaGJEQ0NBaUl3RFFZSktvWklodmNOCkFRRUJCUUFEZ2dJUEFEQ0NBZ29DZ2dJQkFNQkRBaExBeWdKdWFXNnc2ZmZpZ3pUQUFHWHBtRXowdEl4bjFrNFoKeDV3TjVycHYvcXUwUU1ZeitBdjJ1MWVPS0VLWmVhRlJWcFQwcjkzZFg3SXZiRVpIdDI1R1BpQnZsTEdxaGpLUgpQblNrLzdVOFhtc250dFVBVjdyVkVLMVVyZEZ3OC9Jd3JpUUMrZGhyMG1uWWZTRE1rdkJvTUZwZGhWTlRyYkFaCjFUQjZyUWpFN0FyME10OG15OTZYSm13cmNqSzJUaitFMnJnUElVejFlNWNla0ZZSURTQmF0bXcrMyt2citUNXgKRk5Ga0oybzMwVzVvOFpmbENKSnpyVmFpaHFRaWNzNlpLRGdwZjdpcVhNRml3V0lsaGRRcEd2eDVHZi9LRlRLOQpVYU9uUlp6L1grMkNlYkFGYVRIUjNrL1BZcHBXVGdCQkJ1UnZscEN3K3dkbmttdGVDMFNRUkY5MVFXVnI3ZWpvCjdLYU9sR0k1VnR2TVVzV3ZUZUFabXBheW1JYUFURVR1T0phWTBKVTExT21MZUQ5RE9qNUUyU1E3cUlYL3BGY3AKeHB6RzVqNGMrTWxndnhQMlZBa05UZUFYQ2FZaVBCUUg1WlpnMEhFMlduQjFLaExSRmxIZDRpSFFEMkdKNXlOLwo2ZkNGQmZaZktTZUs4SmF1d3hnV2tyYTUzT2NEcS9tS2QrREEvZEsrL3J1Rzd0cXdWZ0lhMDRIT3Bsek03TFlSCkdCMElyczkrbHI1L1BKYlFabVUwNzNNZG42Y1hBZzNwKzZ3dndGbERrUzV2MTNnQkRZTkh0RjYyYmM1NTFlZEYKWjZrR3pKN3dtR1JvODRhQlA3TXVSWmVSZUxPclNTNjdhMXdMZHpac01uUDFUSjd4OUxmcjlNS2wydURuUWRuWQpleDhEQWdNQkFBR2pVekJSTUIwR0ExVWREZ1FXQkJTUS9tdFpkNmg4ZW45WVFWSDZITzFQbFdXaXF6QWZCZ05WCkhTTUVHREFXZ0JTUS9tdFpkNmg4ZW45WVFWSDZITzFQbFdXaXF6QVBCZ05WSFJNQkFmOEVCVEFEQVFIL01BMEcKQ1NxR1NJYjNEUUVCQ3dVQUE0SUNBUUNoUVlOdWFoMyttVHBJQkRZeEdyalRKTnVPVElNYVd6TXlpMXRrZitMMApzRUd3cGJtQU8ybVdXUVlGN1dWTHNpOThQVUxoM2FkanQyaml1ZDlWbGFhQzZnbnduNVpvMStQaWxvOXNOTExXCjZpajArck40a3dJbS9wTnFpK2pEdXUyY3ZBdUhJd1pXZWg4YkVlLzVVQ3hvNGlobVdGUU44ZUo2VFVLQ3BoUkMKNkVvci9TU1paQlFIZ1BsMEJjaHpIT2t3dTdSM0xDbmRScXhqaEFvVmI5eVFPVitac21UZUpYdWx3TnpKMXVMdApUOE9JZ0lpRHBtQm83SFNOMkgwazNjaHgwMEFzalV5SjltbUFXUGVqRmUvS1hMUlBjVlpSMTdqaHpnZklCRXpzCk01V3RXRm0xYUhEalZ2Nk02aXRlVm02MUU5VCtrL00xMXJ1MWUyWXdzeFREdmI2eDA0bWNyTnU5c29xZGRCYnIKVmZwbHV1b1EvaEVBYlh0Rk5Qb1R5U3B6MGN3T3djSENvd1ZPTG1kS2d2SW1zelppTXlISEc4VkdHbVBoODhuNwp3VnhiMGdWMFA0Uk1yY01MZGVUZG41NVlRcjFDcUJyMzRlQjZvbDZBc2JUbTNWekJIUlZtRk5rc2wxbzVKQjV0CnRYTGdGL0c4L3J6Si80bTFQYVZ1eHJCN0R4VW1JazhFUGJTSVZrdlp2ZDdMQnpLd1E2SWZWYXVjZXdIZkVhalEKVklpZXhTTWlGYzdsdzNLbnhqT0haamY2Rk05VllnM05vKytHZEM5OXM3TGtJdUp3QU1MTnFUUTdIdmhuN1l2UAo0RmxTSWdjNnhqMFlrR1pFUWxiNW8vNW5hdUVxUVUwQUJndzZqdEk0TnhyTkxUNmNwN0NPNE0weElERWcvM1lECmFBPT0KLS0tLS1FTkQgQ0VSVElGSUNBVEUtLS0tLQotLS0tLUJFR0lOIFBSSVZBVEUgS0VZLS0tLS0KTUlJSlF3SUJBREFOQmdrcWhraUc5dzBCQVFFRkFBU0NDUzB3Z2drcEFnRUFBb0lDQVFEQVF3SVN3TW9DYm1sdQpzT24zNG9NMHdBQmw2WmhNOUxTTVo5Wk9HY2VjRGVhNmIvNnJ0RURHTS9nTDlydFhqaWhDbVhtaFVWYVU5Sy9kCjNWK3lMMnhHUjdkdVJqNGdiNVN4cW9ZeWtUNTBwUCsxUEY1cko3YlZBRmU2MVJDdFZLM1JjUFB5TUs0a0F2blkKYTlKcDJIMGd6Skx3YURCYVhZVlRVNjJ3R2RVd2VxMEl4T3dLOURMZkpzdmVseVpzSzNJeXRrNC9oTnE0RHlGTQo5WHVYSHBCV0NBMGdXclpzUHQvcjYvaytjUlRSWkNkcU45RnVhUEdYNVFpU2M2MVdvb2FrSW5MT21TZzRLWCs0CnFsekJZc0ZpSllYVUtScjhlUm4veWhVeXZWR2pwMFdjLzEvdGdubXdCV2t4MGQ1UHoyS2FWazRBUVFia2I1YVEKc1BzSFo1SnJYZ3RFa0VSZmRVRmxhKzNvNk95bWpwUmlPVmJiekZMRnIwM2dHWnFXc3BpR2dFeEU3amlXbU5DVgpOZFRwaTNnL1F6bytSTmtrTzZpRi82UlhLY2FjeHVZK0hQakpZTDhUOWxRSkRVM2dGd21tSWp3VUIrV1dZTkJ4Ck5scHdkU29TMFJaUjNlSWgwQTloaWVjamYrbndoUVgyWHlrbml2Q1dyc01ZRnBLMnVkem5BNnY1aW5mZ3dQM1MKdnY2N2h1N2FzRllDR3RPQnpxWmN6T3kyRVJnZENLN1BmcGErZnp5VzBHWmxOTzl6SForbkZ3SU42ZnVzTDhCWgpRNUV1YjlkNEFRMkRSN1JldG0zT2VkWG5SV2VwQnN5ZThKaGthUE9HZ1QrekxrV1hrWGl6cTBrdXUydGNDM2MyCmJESno5VXllOGZTMzYvVENwZHJnNTBIWjJIc2ZBd0lEQVFBQkFvSUNBUUMveFpiWjBjY3RxYWdzcXZhVk5URWUKZXExcStoZmFHdlBFWVFhWUhJcklFKzJpNVhjbkdjTEtjS2ZvZHhEakFuOFIvemdkT3A2Y01YMENWbi9Qb2hIawpBRUR0RTgrQVZ3d0FNMUZzT3dnTEhWR2FHejhxcnhCbFlkUWdIY3BtdWVJdTJQWGJDOGVIVUJpYVVPSXVoYXc1Ci9SUk1EQUMvQWkyc3NmaTdnT2p2VkU0b1F4UVcwUUcxS0dPT0FVSm4vdVlIdzJSRlkyVXUxcGlteE8ya0RPNTMKZ2N4bUMxV09ueUNIbUhhaVcvVWg3ejZKYW1mU000ZFh0VEpac2x5aDM3ZGhIS05iZzlWa1A3Q1FLQTRoTHpvcApoYmY1cVk2cmFyZ09OaW55MUhnTVB4cm13S3VVb3VKeU90TjB5QnR4akRDVU5hWFVCd2l5N3NOR1MrSDR2c3lCCjVQOUhoSUhTdHUrRlp0M0hHN0VJcUNuZGlhU0tEUzRqV2FWUUFiYm80bloyWnMyQkQreERlUFJDUlVxWDdyTTQKNFh6UElSV1dYbW1XZi83SWcyOUhicnA0YTlMY09tUTJsZUNKdGJhVEZTTjk2T0xVSjVFK2hRMHVsQ1pnQlZtUQpSQ1VZa0pQNGxPemJhS2R6anhnSE1ySHptNDVlVUZmOExpck94aTJ1eXhYSFFtRE51NGIzWDE4a3QzUGdVbVVtCjNkWHBsM2ZxU3lKYTdTQ1Y4Wk5CcnNyRHExRSt0aFl0dTkxUWJWU0d4SGQ5SHJOVmUzWGRMYk9DZFU5Q3VDNjkKTmdsem5hYTdzWkxxbXlLZWpUZkdzWTd4cldkTmNNUGw0cDRmY0lEL080RXBBU1pmb3JwVGVLTlQwWklmWlpldwpiMG1BUWVZWnFRTThpL3FNWU4vdUFRS0NBUUVBNXFnMXNSTk1jNlZkTS90UmdsYXNHWW94amdSQzJPcUFEWmdzCm1BWE1VSjNrRXJweXh0K2VDaW15OGlidVlwelJUSVE4ZkJUV1JrQ3RSWlhKNytLY0xWdGs5UVpJb0xiaHlOd2QKNEl4RVFaRnVVbGpEYnZTalRMU3ljc0h2bzY1aWJXSWZUTDdiZ1dsTEdnR3EvVU96ZkdzZ0g2Uzl3THA1RzMwRwo4RUx5akk1ZVRJWUlDcmZUbVZMK2M0NU1ScEVNS28rY3Z6OFB5c2lhT0ZUbjNjeXN3UFZkWWFlRUVxTVFqVTh3CklHTnNHWkx5dFk3QkFCQmNZMGxkcnRiYS9PK0Z2LytSSDd1VXR6UDd4cENJd0ZDeDgwWnpOK1dSeTlOdkk2M1UKenEzeUlCb1c5R3lBcEQyK1BMYVBOeGY3UUxUVUNoWTFaei9kWVJsdEtPeHYyQWE1Z1FLQ0FRRUExV0xXTnFwMApmaEIvWnRmU0VTaHhGTU04OWNqTjZBYXoxV0tMN3VUQm91OW9TSm54amtoa2FWNzZhY25UL2lxWHR4TU5nSGkxCmZJbURwVTNQdk0wWTRVZDJUNDdvSGM2UDFCclpQTi9HbVh5L3M2QkFFZFB3TGU3Sis0blRJU0hBZEdtcmgrYS8KNXBrdHUzMmc5bFdxZnR4ZWNGSVZTTFBXa3hUMFhLaU14cDFmZmtMK09hdnBNZ01GWks0MWlLczNkTlNoS1BvZwpMOEdTUGNQOXgveW43OFAyZUszTitQR2psQTZwUHpyQU55V1U3TjAvYm1IY0I5VEtQK3VkWVdjalZocnU3TVlOCndOckU0a0tkQzh2OGk3eDd0RGJ2Yjc5VCtGbzZQSWg1M3AwT3NuWnpBOFVSMFFOUit2RFF1ZlF1eWFqOFJFQysKWkc4WXlDS3N2azh5Z3dLQ0FRQS9mc1N4QjBmL2VlRXJZeDZ3QzUzNnRlRW9ZQ0hxeHJzVGd2V2JyOVRyeUZzMQprSi95QVRMblIwMWNmYjBYNW1WemM5K1dwTUhMdXhnMzFLRXZhU2xuRHdhK3NNa2pmTlN3ejI5bUZoYmdHZUhOCngyT2RVcmoxYjdURUJJRXNoTi9SanJaaEVSVXFEY3MvMEgrNmtuMkJYWmdOUGZPQ2I1TFJMMXpPblE5YUJBTVAKZThJUStVUEZyR1FoZVdXajgxL3ZBM081N2VreUFJRDd5dHU5WWcrWVdyTW5JODhtdGo3ak40NWZEQitBOXNQYgptUDJtUDlxKzlqNVUyQTZXbkhVc1FuVTMwQktEVUVzYUFVV3o4MExaWG1adlY4SUg0eDl3S2ZVd0pCQklLQVp6CnFMN005N1k3em1Ha1gvU3BmbDMwbk9KOGxzY2hhTGQxRVlsRVphMkJBb0lCQVFDeWUyNVQ0VFY1TUpGdjB6dVoKTUd1TmcxU2MvTzRGa24yZkVVT2NlV2pod1VCSDRjUGpUL2YxRHdXRHNOYUo5TlJieENyNTkzMU9BclBEYzVjOApBNDA0K1k0ak01UkJRa0tabGk5NHRIQW9kK2pjOVVCQjZUVXZKbGw1OVNsTXdDOTY3OXdTMjFaT0tuZlBLR0NYClNzWkdRRXNaeGY2Wmhoc0hnWEozZ2wvbHpVSlBtUGVPQTVZVlIrT2Q5LzA5S0lGRlRvalNmb3luaFZDdUt4NDkKeGI0dVZZbjJIT0o0eEowZlBUZ2hkQ0hNdnJtWGVlUVJqdmI4OGVhTm1xVlVFSEhGRnRnYjRma2xBNWZFN1JUeApCaGxpUkRCd1o3YlVrSU5LNnlWazluNkJUbnM1bU12UkxtZ2RuSnBZdkU3S0MwMkxUYlpiM0krajhDMFpVYStOCnF5N0RBb0lCQUFpZXJpYlM3V1VjbDJhQmxrbTUrVzdxTm0vSU5tNXp2bm9TUG82VjN3YTVoczZmOStDL2tiZEYKODdqUVBBL1lGZTN1UjJzQUo3c2xYNWV1Wks4V21mcEZtZ3psdTBzRXo4MU1MUS9XeXBadFp5dHlWdFd6QjJQdQpYQ1cxdGRTSDllSTJCbWhYZ29rSE5UTTQ4TmsveE9FTnJQL3NlWHJJeDVMSzBobkRIWm90dS96NitZU2tCOWhGCmNtMmZaeWdEMWRNTFg2bGlSaW14eUZZK2RJQ0pOQjk1SmlmVExXWW5XZUdkZGt3UHRYVWVHWEUxb2x6dk5rTEQKek16RTA5dWhreC9sUkpudGVPQkVaYWY4ME9CLzA5T2k5YjkvcnhZNTlkd3NINkdheExvVGZFS3VQbnZCVk1OUgpZa1UxNFd6UUtsZUZraUJKSTlsVnZuZmdHbk9sZ2cwPQotLS0tLUVORCBQUklWQVRFIEtFWS0tLS0t + + container2: + image: byjg/static-httpserver + deploy: + replicas: 1 + labels: + easyhaproxy.http.host: host2.local + easyhaproxy.http.port: 80 + easyhaproxy.http.redirect_ssl: "true" + + easyhaproxy.https.port: 443 + easyhaproxy.https.localport: 8080 + easyhaproxy.https.host: host2.local + easyhaproxy.https.ssl: "true" + From c0eb766339a2c954b55f134efed4ad0e19ab26bb Mon Sep 17 00:00:00 2001 From: Joao Gilberto Magalhaes Date: Mon, 27 Feb 2023 23:28:09 -0600 Subject: [PATCH 09/33] Fix Swarm networking --- examples/swarm/docker-compose.yml | 67 ------------------------------- examples/swarm/easyhaproxy.yml | 9 ++++- src/processor/__init__.py | 30 ++++++++++---- 3 files changed, 31 insertions(+), 75 deletions(-) delete mode 100644 examples/swarm/docker-compose.yml diff --git a/examples/swarm/docker-compose.yml b/examples/swarm/docker-compose.yml deleted file mode 100644 index d5fa394..0000000 --- a/examples/swarm/docker-compose.yml +++ /dev/null @@ -1,67 +0,0 @@ -# To test: -# curl -k -H "Host: host1.local" https://127.0.0.1/ -# curl -k -H "Host: host2.local" https://127.0.0.1/ -# -# curl -I -H Host:host1.local http://127.0.0.1 -# HTTP/1.1 301 Moved Permanently -# content-length: 0 -# location: https://host1.local/ -# -# curl -I -H Host:host2.local http://127.0.0.1 -# HTTP/1.1 301 Moved Permanently -# content-length: 0 -# location: https://host1.local/ -# -# Test SSL: -# openssl s_client -showcerts -connect 127.0.0.1:443 -servername host1.local - -version: "3" - -services: - haproxy: - image: byjg/easy-haproxy - volumes: - - /var/run/docker.sock:/var/run/docker.sock - - ./host2.local.pem:/certs/haproxy/host2.local.pem - deploy: - replicas: 1 - environment: - EASYHAPROXY_DISCOVER: swarm - EASYHAPROXY_SSL_MODE: "loose" - HAPROXY_CUSTOMERRORS: "true" - HAPROXY_USERNAME: admin - HAPROXY_PASSWORD: password - HAPROXY_STATS_PORT: 1936 - ports: - - "80:80/tcp" - - "443:443/tcp" - - "1936:1936/tcp" - - container: - image: byjg/static-httpserver - deploy: - replicas: 1 - labels: - easyhaproxy.http.redirect_ssl: "true" - easyhaproxy.http.host: host1.local - easyhaproxy.http.port: 80 - - easyhaproxy.https.port: 443 - easyhaproxy.https.localport: 8080 - easyhaproxy.https.host: host1.local - easyhaproxy.https.sslcert: LS0tLS1CRUdJTiBDRVJUSUZJQ0FURS0tLS0tCk1JSUZEVENDQXZXZ0F3SUJBZ0lVUmkrdzFaVmdlZWRUbE5JQXdxUUJNSnY2ZFhzd0RRWUpLb1pJaHZjTkFRRUwKQlFBd0ZqRVVNQklHQTFVRUF3d0xhRzl6ZERFdWJHOWpZV3d3SGhjTk1qRXdPREV3TVRnME9UQTJXaGNOTXpFdwpPREE0TVRnME9UQTJXakFXTVJRd0VnWURWUVFEREF0b2IzTjBNUzVzYjJOaGJEQ0NBaUl3RFFZSktvWklodmNOCkFRRUJCUUFEZ2dJUEFEQ0NBZ29DZ2dJQkFNQkRBaExBeWdKdWFXNnc2ZmZpZ3pUQUFHWHBtRXowdEl4bjFrNFoKeDV3TjVycHYvcXUwUU1ZeitBdjJ1MWVPS0VLWmVhRlJWcFQwcjkzZFg3SXZiRVpIdDI1R1BpQnZsTEdxaGpLUgpQblNrLzdVOFhtc250dFVBVjdyVkVLMVVyZEZ3OC9Jd3JpUUMrZGhyMG1uWWZTRE1rdkJvTUZwZGhWTlRyYkFaCjFUQjZyUWpFN0FyME10OG15OTZYSm13cmNqSzJUaitFMnJnUElVejFlNWNla0ZZSURTQmF0bXcrMyt2citUNXgKRk5Ga0oybzMwVzVvOFpmbENKSnpyVmFpaHFRaWNzNlpLRGdwZjdpcVhNRml3V0lsaGRRcEd2eDVHZi9LRlRLOQpVYU9uUlp6L1grMkNlYkFGYVRIUjNrL1BZcHBXVGdCQkJ1UnZscEN3K3dkbmttdGVDMFNRUkY5MVFXVnI3ZWpvCjdLYU9sR0k1VnR2TVVzV3ZUZUFabXBheW1JYUFURVR1T0phWTBKVTExT21MZUQ5RE9qNUUyU1E3cUlYL3BGY3AKeHB6RzVqNGMrTWxndnhQMlZBa05UZUFYQ2FZaVBCUUg1WlpnMEhFMlduQjFLaExSRmxIZDRpSFFEMkdKNXlOLwo2ZkNGQmZaZktTZUs4SmF1d3hnV2tyYTUzT2NEcS9tS2QrREEvZEsrL3J1Rzd0cXdWZ0lhMDRIT3Bsek03TFlSCkdCMElyczkrbHI1L1BKYlFabVUwNzNNZG42Y1hBZzNwKzZ3dndGbERrUzV2MTNnQkRZTkh0RjYyYmM1NTFlZEYKWjZrR3pKN3dtR1JvODRhQlA3TXVSWmVSZUxPclNTNjdhMXdMZHpac01uUDFUSjd4OUxmcjlNS2wydURuUWRuWQpleDhEQWdNQkFBR2pVekJSTUIwR0ExVWREZ1FXQkJTUS9tdFpkNmg4ZW45WVFWSDZITzFQbFdXaXF6QWZCZ05WCkhTTUVHREFXZ0JTUS9tdFpkNmg4ZW45WVFWSDZITzFQbFdXaXF6QVBCZ05WSFJNQkFmOEVCVEFEQVFIL01BMEcKQ1NxR1NJYjNEUUVCQ3dVQUE0SUNBUUNoUVlOdWFoMyttVHBJQkRZeEdyalRKTnVPVElNYVd6TXlpMXRrZitMMApzRUd3cGJtQU8ybVdXUVlGN1dWTHNpOThQVUxoM2FkanQyaml1ZDlWbGFhQzZnbnduNVpvMStQaWxvOXNOTExXCjZpajArck40a3dJbS9wTnFpK2pEdXUyY3ZBdUhJd1pXZWg4YkVlLzVVQ3hvNGlobVdGUU44ZUo2VFVLQ3BoUkMKNkVvci9TU1paQlFIZ1BsMEJjaHpIT2t3dTdSM0xDbmRScXhqaEFvVmI5eVFPVitac21UZUpYdWx3TnpKMXVMdApUOE9JZ0lpRHBtQm83SFNOMkgwazNjaHgwMEFzalV5SjltbUFXUGVqRmUvS1hMUlBjVlpSMTdqaHpnZklCRXpzCk01V3RXRm0xYUhEalZ2Nk02aXRlVm02MUU5VCtrL00xMXJ1MWUyWXdzeFREdmI2eDA0bWNyTnU5c29xZGRCYnIKVmZwbHV1b1EvaEVBYlh0Rk5Qb1R5U3B6MGN3T3djSENvd1ZPTG1kS2d2SW1zelppTXlISEc4VkdHbVBoODhuNwp3VnhiMGdWMFA0Uk1yY01MZGVUZG41NVlRcjFDcUJyMzRlQjZvbDZBc2JUbTNWekJIUlZtRk5rc2wxbzVKQjV0CnRYTGdGL0c4L3J6Si80bTFQYVZ1eHJCN0R4VW1JazhFUGJTSVZrdlp2ZDdMQnpLd1E2SWZWYXVjZXdIZkVhalEKVklpZXhTTWlGYzdsdzNLbnhqT0haamY2Rk05VllnM05vKytHZEM5OXM3TGtJdUp3QU1MTnFUUTdIdmhuN1l2UAo0RmxTSWdjNnhqMFlrR1pFUWxiNW8vNW5hdUVxUVUwQUJndzZqdEk0TnhyTkxUNmNwN0NPNE0weElERWcvM1lECmFBPT0KLS0tLS1FTkQgQ0VSVElGSUNBVEUtLS0tLQotLS0tLUJFR0lOIFBSSVZBVEUgS0VZLS0tLS0KTUlJSlF3SUJBREFOQmdrcWhraUc5dzBCQVFFRkFBU0NDUzB3Z2drcEFnRUFBb0lDQVFEQVF3SVN3TW9DYm1sdQpzT24zNG9NMHdBQmw2WmhNOUxTTVo5Wk9HY2VjRGVhNmIvNnJ0RURHTS9nTDlydFhqaWhDbVhtaFVWYVU5Sy9kCjNWK3lMMnhHUjdkdVJqNGdiNVN4cW9ZeWtUNTBwUCsxUEY1cko3YlZBRmU2MVJDdFZLM1JjUFB5TUs0a0F2blkKYTlKcDJIMGd6Skx3YURCYVhZVlRVNjJ3R2RVd2VxMEl4T3dLOURMZkpzdmVseVpzSzNJeXRrNC9oTnE0RHlGTQo5WHVYSHBCV0NBMGdXclpzUHQvcjYvaytjUlRSWkNkcU45RnVhUEdYNVFpU2M2MVdvb2FrSW5MT21TZzRLWCs0CnFsekJZc0ZpSllYVUtScjhlUm4veWhVeXZWR2pwMFdjLzEvdGdubXdCV2t4MGQ1UHoyS2FWazRBUVFia2I1YVEKc1BzSFo1SnJYZ3RFa0VSZmRVRmxhKzNvNk95bWpwUmlPVmJiekZMRnIwM2dHWnFXc3BpR2dFeEU3amlXbU5DVgpOZFRwaTNnL1F6bytSTmtrTzZpRi82UlhLY2FjeHVZK0hQakpZTDhUOWxRSkRVM2dGd21tSWp3VUIrV1dZTkJ4Ck5scHdkU29TMFJaUjNlSWgwQTloaWVjamYrbndoUVgyWHlrbml2Q1dyc01ZRnBLMnVkem5BNnY1aW5mZ3dQM1MKdnY2N2h1N2FzRllDR3RPQnpxWmN6T3kyRVJnZENLN1BmcGErZnp5VzBHWmxOTzl6SForbkZ3SU42ZnVzTDhCWgpRNUV1YjlkNEFRMkRSN1JldG0zT2VkWG5SV2VwQnN5ZThKaGthUE9HZ1QrekxrV1hrWGl6cTBrdXUydGNDM2MyCmJESno5VXllOGZTMzYvVENwZHJnNTBIWjJIc2ZBd0lEQVFBQkFvSUNBUUMveFpiWjBjY3RxYWdzcXZhVk5URWUKZXExcStoZmFHdlBFWVFhWUhJcklFKzJpNVhjbkdjTEtjS2ZvZHhEakFuOFIvemdkT3A2Y01YMENWbi9Qb2hIawpBRUR0RTgrQVZ3d0FNMUZzT3dnTEhWR2FHejhxcnhCbFlkUWdIY3BtdWVJdTJQWGJDOGVIVUJpYVVPSXVoYXc1Ci9SUk1EQUMvQWkyc3NmaTdnT2p2VkU0b1F4UVcwUUcxS0dPT0FVSm4vdVlIdzJSRlkyVXUxcGlteE8ya0RPNTMKZ2N4bUMxV09ueUNIbUhhaVcvVWg3ejZKYW1mU000ZFh0VEpac2x5aDM3ZGhIS05iZzlWa1A3Q1FLQTRoTHpvcApoYmY1cVk2cmFyZ09OaW55MUhnTVB4cm13S3VVb3VKeU90TjB5QnR4akRDVU5hWFVCd2l5N3NOR1MrSDR2c3lCCjVQOUhoSUhTdHUrRlp0M0hHN0VJcUNuZGlhU0tEUzRqV2FWUUFiYm80bloyWnMyQkQreERlUFJDUlVxWDdyTTQKNFh6UElSV1dYbW1XZi83SWcyOUhicnA0YTlMY09tUTJsZUNKdGJhVEZTTjk2T0xVSjVFK2hRMHVsQ1pnQlZtUQpSQ1VZa0pQNGxPemJhS2R6anhnSE1ySHptNDVlVUZmOExpck94aTJ1eXhYSFFtRE51NGIzWDE4a3QzUGdVbVVtCjNkWHBsM2ZxU3lKYTdTQ1Y4Wk5CcnNyRHExRSt0aFl0dTkxUWJWU0d4SGQ5SHJOVmUzWGRMYk9DZFU5Q3VDNjkKTmdsem5hYTdzWkxxbXlLZWpUZkdzWTd4cldkTmNNUGw0cDRmY0lEL080RXBBU1pmb3JwVGVLTlQwWklmWlpldwpiMG1BUWVZWnFRTThpL3FNWU4vdUFRS0NBUUVBNXFnMXNSTk1jNlZkTS90UmdsYXNHWW94amdSQzJPcUFEWmdzCm1BWE1VSjNrRXJweXh0K2VDaW15OGlidVlwelJUSVE4ZkJUV1JrQ3RSWlhKNytLY0xWdGs5UVpJb0xiaHlOd2QKNEl4RVFaRnVVbGpEYnZTalRMU3ljc0h2bzY1aWJXSWZUTDdiZ1dsTEdnR3EvVU96ZkdzZ0g2Uzl3THA1RzMwRwo4RUx5akk1ZVRJWUlDcmZUbVZMK2M0NU1ScEVNS28rY3Z6OFB5c2lhT0ZUbjNjeXN3UFZkWWFlRUVxTVFqVTh3CklHTnNHWkx5dFk3QkFCQmNZMGxkcnRiYS9PK0Z2LytSSDd1VXR6UDd4cENJd0ZDeDgwWnpOK1dSeTlOdkk2M1UKenEzeUlCb1c5R3lBcEQyK1BMYVBOeGY3UUxUVUNoWTFaei9kWVJsdEtPeHYyQWE1Z1FLQ0FRRUExV0xXTnFwMApmaEIvWnRmU0VTaHhGTU04OWNqTjZBYXoxV0tMN3VUQm91OW9TSm54amtoa2FWNzZhY25UL2lxWHR4TU5nSGkxCmZJbURwVTNQdk0wWTRVZDJUNDdvSGM2UDFCclpQTi9HbVh5L3M2QkFFZFB3TGU3Sis0blRJU0hBZEdtcmgrYS8KNXBrdHUzMmc5bFdxZnR4ZWNGSVZTTFBXa3hUMFhLaU14cDFmZmtMK09hdnBNZ01GWks0MWlLczNkTlNoS1BvZwpMOEdTUGNQOXgveW43OFAyZUszTitQR2psQTZwUHpyQU55V1U3TjAvYm1IY0I5VEtQK3VkWVdjalZocnU3TVlOCndOckU0a0tkQzh2OGk3eDd0RGJ2Yjc5VCtGbzZQSWg1M3AwT3NuWnpBOFVSMFFOUit2RFF1ZlF1eWFqOFJFQysKWkc4WXlDS3N2azh5Z3dLQ0FRQS9mc1N4QjBmL2VlRXJZeDZ3QzUzNnRlRW9ZQ0hxeHJzVGd2V2JyOVRyeUZzMQprSi95QVRMblIwMWNmYjBYNW1WemM5K1dwTUhMdXhnMzFLRXZhU2xuRHdhK3NNa2pmTlN3ejI5bUZoYmdHZUhOCngyT2RVcmoxYjdURUJJRXNoTi9SanJaaEVSVXFEY3MvMEgrNmtuMkJYWmdOUGZPQ2I1TFJMMXpPblE5YUJBTVAKZThJUStVUEZyR1FoZVdXajgxL3ZBM081N2VreUFJRDd5dHU5WWcrWVdyTW5JODhtdGo3ak40NWZEQitBOXNQYgptUDJtUDlxKzlqNVUyQTZXbkhVc1FuVTMwQktEVUVzYUFVV3o4MExaWG1adlY4SUg0eDl3S2ZVd0pCQklLQVp6CnFMN005N1k3em1Ha1gvU3BmbDMwbk9KOGxzY2hhTGQxRVlsRVphMkJBb0lCQVFDeWUyNVQ0VFY1TUpGdjB6dVoKTUd1TmcxU2MvTzRGa24yZkVVT2NlV2pod1VCSDRjUGpUL2YxRHdXRHNOYUo5TlJieENyNTkzMU9BclBEYzVjOApBNDA0K1k0ak01UkJRa0tabGk5NHRIQW9kK2pjOVVCQjZUVXZKbGw1OVNsTXdDOTY3OXdTMjFaT0tuZlBLR0NYClNzWkdRRXNaeGY2Wmhoc0hnWEozZ2wvbHpVSlBtUGVPQTVZVlIrT2Q5LzA5S0lGRlRvalNmb3luaFZDdUt4NDkKeGI0dVZZbjJIT0o0eEowZlBUZ2hkQ0hNdnJtWGVlUVJqdmI4OGVhTm1xVlVFSEhGRnRnYjRma2xBNWZFN1JUeApCaGxpUkRCd1o3YlVrSU5LNnlWazluNkJUbnM1bU12UkxtZ2RuSnBZdkU3S0MwMkxUYlpiM0krajhDMFpVYStOCnF5N0RBb0lCQUFpZXJpYlM3V1VjbDJhQmxrbTUrVzdxTm0vSU5tNXp2bm9TUG82VjN3YTVoczZmOStDL2tiZEYKODdqUVBBL1lGZTN1UjJzQUo3c2xYNWV1Wks4V21mcEZtZ3psdTBzRXo4MU1MUS9XeXBadFp5dHlWdFd6QjJQdQpYQ1cxdGRTSDllSTJCbWhYZ29rSE5UTTQ4TmsveE9FTnJQL3NlWHJJeDVMSzBobkRIWm90dS96NitZU2tCOWhGCmNtMmZaeWdEMWRNTFg2bGlSaW14eUZZK2RJQ0pOQjk1SmlmVExXWW5XZUdkZGt3UHRYVWVHWEUxb2x6dk5rTEQKek16RTA5dWhreC9sUkpudGVPQkVaYWY4ME9CLzA5T2k5YjkvcnhZNTlkd3NINkdheExvVGZFS3VQbnZCVk1OUgpZa1UxNFd6UUtsZUZraUJKSTlsVnZuZmdHbk9sZ2cwPQotLS0tLUVORCBQUklWQVRFIEtFWS0tLS0t - - container2: - image: byjg/static-httpserver - deploy: - replicas: 1 - labels: - easyhaproxy.http.host: host2.local - easyhaproxy.http.port: 80 - easyhaproxy.http.redirect_ssl: "true" - - easyhaproxy.https.port: 443 - easyhaproxy.https.localport: 8080 - easyhaproxy.https.host: host2.local - easyhaproxy.https.ssl: "true" - diff --git a/examples/swarm/easyhaproxy.yml b/examples/swarm/easyhaproxy.yml index 3feef60..3d088ef 100644 --- a/examples/swarm/easyhaproxy.yml +++ b/examples/swarm/easyhaproxy.yml @@ -1,4 +1,5 @@ # To Install +# docker network create --driver overlay --attachable easyhaproxy # docker stack deploy -c easyhaproxy.yml easyhaproxy @@ -6,7 +7,7 @@ version: "3" services: haproxy: - image: byjg/easy-haproxy:4.3.1-rc1 + image: byjg/easy-haproxy:4.3.1-rc2 volumes: - /var/run/docker.sock:/var/run/docker.sock - ./certs:/certs/haproxy @@ -25,6 +26,12 @@ services: - "80:80/tcp" - "443:443/tcp" - "1936:1936/tcp" + networks: + - easyhaproxy + +networks: + easyhaproxy: + external: true volumes: certs_letsencrypt: diff --git a/src/processor/__init__.py b/src/processor/__init__.py index 530d077..511089d 100644 --- a/src/processor/__init__.py +++ b/src/processor/__init__.py @@ -40,6 +40,7 @@ class ProcessorInterface: def __init__(self, filename = None): self.filename = filename + self.label = ContainerEnv.read()['lookup_label'] self.refresh() @staticmethod @@ -156,25 +157,40 @@ class Swarm(ProcessorInterface): def inspect_network(self): ha_proxy_service_name = self.client.containers.get(socket.gethostname()).name.split('.')[0] - for endpoint in self.client.services.get(ha_proxy_service_name).attrs['Endpoint']["VirtualIPs"]: - ha_proxy_network_id = endpoint["NetworkID"] - if self.client.networks.get(ha_proxy_network_id).name != 'ingress': + ha_proxy_network_id = None + swarm_ingress_id = None + + # Get the HAProxy network and the ingress network + for endpoint in self.client.services.get(ha_proxy_service_name).attrs['Endpoint']["VirtualIPs"]: + network_name = self.client.networks.get(endpoint["NetworkID"]).name + if swarm_ingress_id is None and network_name == 'ingress': + swarm_ingress_id = endpoint["NetworkID"] + if ha_proxy_network_id is None and network_name != 'ingress': + ha_proxy_network_id = endpoint["NetworkID"] + if ha_proxy_network_id is not None and swarm_ingress_id is not None: break + # Check if the service is attached to the HAProxy network self.parsed_object = {} for service in self.client.services.list(): + if not any(self.label in key for key in service.attrs["Spec"]["Labels"]): + continue + ip_address = None + network_list = [] for endpoint in service.attrs["Endpoint"]["VirtualIPs"]: if ha_proxy_network_id == endpoint["NetworkID"]: ip_address = endpoint["Addr"].split("/")[0] break + elif swarm_ingress_id != endpoint["NetworkID"]: + network_list.append(endpoint["NetworkID"]) - # add the network ha_proxy_network_id to the service object + # Attach the service to the HAProxy network if ip_address is None: - network_attachment = docker.types.NetworkAttachmentConfig(target=ha_proxy_network_id) - service.update(networks = [network_attachment]) + network_list.append(ha_proxy_network_id) + service.update(networks = network_list) continue # skip to the next service to give time to update the network - + self.parsed_object[ip_address] = service.attrs["Spec"]["Labels"] From d63aeef2066462abc1800c695e26411075637642 Mon Sep 17 00:00:00 2001 From: Joao Gilberto Magalhaes Date: Tue, 28 Feb 2023 00:04:51 -0600 Subject: [PATCH 10/33] Make documentation clear --- docs/docker.md | 8 ++++++-- docs/kubernetes.md | 2 +- docs/swarm.md | 12 +++++++----- 3 files changed, 14 insertions(+), 8 deletions(-) diff --git a/docs/docker.md b/docs/docker.md index e8d85e4..6e728c7 100644 --- a/docs/docker.md +++ b/docs/docker.md @@ -4,8 +4,12 @@ This method will use a docker standalone installation to discover the containers and configure the HAProxy. -The only requirement is that containers and EasyHAProxy must be in the same docker network. -If not, EasyHAProxy will connect the container with the EasyHAProxy network. +You cannot mix docker containers with swarm containers. + +The only request is that containers and EasyHAProxy must be in the same docker network. +If you don't add to your services the same network EasyHAProxy is connected to, EasyHAProxy will attach it network to your container. + +Also, it is highly recommended you create a network external to EasyHAProxy. e.g.: diff --git a/docs/kubernetes.md b/docs/kubernetes.md index 24743f5..5e3c410 100644 --- a/docs/kubernetes.md +++ b/docs/kubernetes.md @@ -71,7 +71,7 @@ Once the container is running, EasyHAProxy will detect automatically and start t You don't need to expose any port in your container. -Caveats: +Notes: - At this point, the implementation doesn't support all ingress properties or wildcard domains. - The ingress will publish the ports 80 and 443, plus 1936 if stats are enabled. diff --git a/docs/swarm.md b/docs/swarm.md index fc671d1..6b3469a 100644 --- a/docs/swarm.md +++ b/docs/swarm.md @@ -5,13 +5,17 @@ This method will use a docker swarm installation to discover the containers and configure the HAProxy. The advantage of this method is that you can discover containers in other nodes from the cluster. -The only requirement is that containers and EasyHAProxy must be in the same docker swarm network. -If not, EasyHAProxy will connect the service with the EasyHAProxy service network. +You cannot mix docker containers with swarm containers. + +The only request is that containers and EasyHAProxy must be in the same docker swarm network. +If you don't add to your services the same network EasyHAProxy is connected to, EasyHAProxy will attach it network to your container. + +Also, it is highly recommended you create a network external to EasyHAProxy. e.g.: ```bash -docker network create -d overlay easyhaproxy +docker network create -d overlay --attachable easyhaproxy ``` And then deploy the EasyHAProxy stack: @@ -79,8 +83,6 @@ networks: external: true ``` -Note: The services to be discovered **don't need** to be in the same network as EasyHAProxy is. - Once the container is running, EasyHAProxy will detect automatically and start to redirect all traffic from `example.org:80` to your container. You don't need to expose any port in your container. From 3ee6ee5c9987dfe483e945e1592561c09ee80801 Mon Sep 17 00:00:00 2001 From: Joao Gilberto Magalhaes Date: Tue, 28 Feb 2023 00:08:30 -0600 Subject: [PATCH 11/33] More docs --- docs/docker.md | 3 +-- docs/swarm.md | 2 +- 2 files changed, 2 insertions(+), 3 deletions(-) diff --git a/docs/docker.md b/docs/docker.md index 6e728c7..f9248d5 100644 --- a/docs/docker.md +++ b/docs/docker.md @@ -51,13 +51,12 @@ Once the container is running, EasyHAProxy will detect automatically and start t You don't need to expose any port in your container. -Please follow the [docker label configuration](container-labels.md) to see other configurations available. +Please follow the [docker label configuration](container-labels.md) to see other configurations available. ## Setup the EasyHAProxy container You can configure the behavior of the EasyHAProxy by setup specific environment variables. To get a list of the variables, please follow the [docker container environment](docker-environment.md) - ## Setup certificates with Letsencrypt Follow [this link](letsencrypt.md) diff --git a/docs/swarm.md b/docs/swarm.md index 6b3469a..9ab29c4 100644 --- a/docs/swarm.md +++ b/docs/swarm.md @@ -87,7 +87,7 @@ Once the container is running, EasyHAProxy will detect automatically and start t You don't need to expose any port in your container. -Please follow the [docker label configuration](container-labels.md) to see other configurations available. +Please follow the [docker label configuration](container-labels.md) to see other configurations available. ## Setup the EasyHAProxy container From f6dbf950b5704ec34b9636a1249c47e490393183 Mon Sep 17 00:00:00 2001 From: Joao M Date: Mon, 26 Jun 2023 14:31:45 -0500 Subject: [PATCH 12/33] Add HTTP2 support for SSL. --- src/templates/bind.j2 | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/templates/bind.j2 b/src/templates/bind.j2 index d33d4bd..e4f264e 100644 --- a/src/templates/bind.j2 +++ b/src/templates/bind.j2 @@ -1,5 +1,5 @@ {% if "ssl" in o %} - bind *:{{ o["port"] }} ssl crt /certs/letsencrypt/ alpn http/1.1 crt /certs/haproxy/ alpn http/1.1 + bind *:{{ o["port"] }} ssl crt /certs/letsencrypt/ alpn h2,http/1.1 crt /certs/haproxy/ alpn h2,http/1.1 {% elif "h2" in o and o["h2"] %} bind *:{{ o["port"] }} proto h2 option http-use-htx From dcdccb9236923a1f3d4c769ce982f4324e2ee404 Mon Sep 17 00:00:00 2001 From: Joao Gilberto Magalhaes Date: Mon, 26 Jun 2023 22:41:51 -0500 Subject: [PATCH 13/33] Fix unit tests --- src/tests/expected/docker.txt | 2 +- src/tests/expected/services-letsencrypt.txt | 2 +- src/tests/expected/services-redirect-ssl.txt | 2 +- src/tests/expected/services.txt | 2 +- src/tests/expected/static.txt | 2 +- 5 files changed, 5 insertions(+), 5 deletions(-) diff --git a/src/tests/expected/docker.txt b/src/tests/expected/docker.txt index 7bcdedc..14f0307 100644 --- a/src/tests/expected/docker.txt +++ b/src/tests/expected/docker.txt @@ -36,7 +36,7 @@ backend srv_stats server Local 127.0.0.1:1936 frontend http_in_443 - bind *:443 ssl crt /certs/letsencrypt/ alpn http/1.1 crt /certs/haproxy/ alpn http/1.1 + bind *:443 ssl crt /certs/letsencrypt/ alpn h2,http/1.1 crt /certs/haproxy/ alpn h2,http/1.1 mode http acl is_rule_hostssl_local_443_1 hdr(host) -i hostssl.local diff --git a/src/tests/expected/services-letsencrypt.txt b/src/tests/expected/services-letsencrypt.txt index 3e2eafb..be2a8f3 100644 --- a/src/tests/expected/services-letsencrypt.txt +++ b/src/tests/expected/services-letsencrypt.txt @@ -74,7 +74,7 @@ backend srv_test2_example_org_80 server srv-0 83d57d592e26:8080 check weight 1 frontend http_in_443 - bind *:443 ssl crt /certs/letsencrypt/ alpn http/1.1 crt /certs/haproxy/ alpn http/1.1 + bind *:443 ssl crt /certs/letsencrypt/ alpn h2,http/1.1 crt /certs/haproxy/ alpn h2,http/1.1 mode http acl is_rule_test_example_org_443_1 hdr(host) -i test.example.org diff --git a/src/tests/expected/services-redirect-ssl.txt b/src/tests/expected/services-redirect-ssl.txt index 077123b..48b860a 100644 --- a/src/tests/expected/services-redirect-ssl.txt +++ b/src/tests/expected/services-redirect-ssl.txt @@ -49,7 +49,7 @@ backend srv_host1_local_80 server srv-0 5b69bc7fea1b:80 check weight 1 frontend http_in_443 - bind *:443 ssl crt /certs/letsencrypt/ alpn http/1.1 crt /certs/haproxy/ alpn http/1.1 + bind *:443 ssl crt /certs/letsencrypt/ alpn h2,http/1.1 crt /certs/haproxy/ alpn h2,http/1.1 mode http acl is_rule_host2_local_443_1 hdr(host) -i host2.local diff --git a/src/tests/expected/services.txt b/src/tests/expected/services.txt index bf2cecd..2ee7296 100644 --- a/src/tests/expected/services.txt +++ b/src/tests/expected/services.txt @@ -67,7 +67,7 @@ backend srv_node-exporter_quantum_example_org_31337 server srv-0 my-stack_node-exporter:9100 check weight 1 frontend http_in_443 - bind *:443 ssl crt /certs/letsencrypt/ alpn http/1.1 crt /certs/haproxy/ alpn http/1.1 + bind *:443 ssl crt /certs/letsencrypt/ alpn h2,http/1.1 crt /certs/haproxy/ alpn h2,http/1.1 mode http redirect prefix https://www.somehost.com.br code 301 if { hdr(host) -i somehost.com.br } redirect prefix https://www.somehost.com.br code 301 if { hdr(host) -i somehost.com } diff --git a/src/tests/expected/static.txt b/src/tests/expected/static.txt index ea0362f..a4ab71a 100644 --- a/src/tests/expected/static.txt +++ b/src/tests/expected/static.txt @@ -74,7 +74,7 @@ backend srv_host2_com_br_80 server srv-0 other:3000 check weight 1 frontend http_in_443 - bind *:443 ssl crt /certs/letsencrypt/ alpn http/1.1 crt /certs/haproxy/ alpn http/1.1 + bind *:443 ssl crt /certs/letsencrypt/ alpn h2,http/1.1 crt /certs/haproxy/ alpn h2,http/1.1 mode http acl is_rule_host1_com_br_443_1 hdr(host) -i host1.com.br From 67e88ad3129e2c2bff0e043af5546f1058fb24a3 Mon Sep 17 00:00:00 2001 From: Joao Gilberto Magalhaes Date: Mon, 26 Jun 2023 22:52:11 -0500 Subject: [PATCH 14/33] Fix Build --- build/Dockerfile | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/build/Dockerfile b/build/Dockerfile index 74b2970..334f3cb 100644 --- a/build/Dockerfile +++ b/build/Dockerfile @@ -1,4 +1,4 @@ -FROM alpine:3.17 +FROM alpine:3.18 ARG RELEASE_VERSION_ARG @@ -11,7 +11,6 @@ COPY src/ /scripts/ COPY build/assets / RUN apk add --no-cache haproxy bash python3 py3-pip py-yaml certbot openssl \ - && ln -s /usr/bin/python3 /usr/bin/python \ && pip3 install --upgrade pip \ && pip install -r requirements.txt \ && pytest -s -vv tests/ \ From 2ddf276919fa0cc80c49e799572767fab03cf251 Mon Sep 17 00:00:00 2001 From: Joao Gilberto Magalhaes Date: Mon, 26 Jun 2023 23:30:34 -0500 Subject: [PATCH 15/33] Defining Log Level --- README.md | 10 ++++- docs/docker-environment.md | 30 ++++++------- docs/kubernetes.md | 19 ++++---- docs/static.md | 20 ++++++--- src/easymapping/__init__.py | 2 +- src/processor/__init__.py | 8 +++- src/templates/haproxy.cfg.j2 | 15 ++++++- src/tests/test_containerenv.py | 79 ++++++++++++++++++++++++++++++---- 8 files changed, 141 insertions(+), 42 deletions(-) diff --git a/README.md b/README.md index ac4d522..92c70fb 100644 --- a/README.md +++ b/README.md @@ -19,6 +19,14 @@ EasyHAProxy can detect and configure HAProxy automatically on the following plat - Docker Swarm - Kubernetes +## Who is using? + +EasyHAProxy is part of some projects: +- Dokku +- MicroK8s + +See detailed instructions on how to install below. + ## Features EasyHAProxy will discover the services based on the Docker Tags of the containers running on a Docker host or Docker Swarm cluster and dynamically set up the `haproxy.cfg`. Below, EasyHAProxy main features: @@ -34,7 +42,7 @@ EasyHAProxy will discover the services based on the Docker Tags of the container Also, it is possible to set up HAProxy from a simple Yaml file instead of creating `haproxy.cfg` file. -## How Does It Works? +## How Does It Work? You don't need to change your current infrastructure and don't need to learn the HAProxy configuration. diff --git a/docs/docker-environment.md b/docs/docker-environment.md index 0b9ec11..6e86477 100644 --- a/docs/docker-environment.md +++ b/docs/docker-environment.md @@ -1,20 +1,20 @@ # Docker environment variables -| Environment Variable | Description | Default | -|---------------------------------|-------------------------------------------------------------------------------------------------|------------------| -| EASYHAPROXY_DISCOVER | How the services will be discovered to create `haproxy.cfg`: `static`, `docker`, `swarm` or `kubernetes` | **required** | -| EASYHAPROXY_LABEL_PREFIX | (Optional) The key will search for matching resources. | `easyhaproxy` | -| EASYHAPROXY_LETSENCRYPT_EMAIL | (Optional) The email will be used to request the certificate to Letsencrypt | *empty* | -| EASYHAPROXY_LETSENCRYPT_SERVER | (Optional) Can be `staging` or 'schema://domain.tld'. If set, will try to connect to the Letsencrypt test server | *empty* | -| EASYHAPROXY_SSL_MODE | (Optional) `strict` supports only the most recent TLS version; `default` good SSL integration with recent browsers; `loose` supports all old SSL protocols for old browsers (not recommended). | `default`| -| EASYHAPROXY_REFRESH_CONF | (Optional) Check configuration every N seconds. | 10 | -| EASYHAPROXY_LOG_LEVEL | (Optional) The log level for EasyHAproxy messages. Available: TRACE,DEBUG,INFO,WARN,ERROR,FATAL | DEBUG | -| CERTBOT_LOG_LEVEL | (Optional) The log level for Certbot messages. Available: TRACE,DEBUG,INFO,WARN,ERROR,FATAL | DEBUG | -| HAPROXY_LOG_LEVEL | (Optional) The log level for HAProxy messages. Available: TRACE,DEBUG,INFO,WARN,ERROR,FATAL | DEBUG | -| HAPROXY_USERNAME | (Optional) The HAProxy username to the statistics. | `admin` | -| HAPROXY_PASSWORD | (Optional) The HAProxy password to the statistics. If not set, statistics will be available with no password | *empty* | -| HAPROXY_STATS_PORT | (Optional) The HAProxy port to the statistics. If set to `false`, disable statistics | `1936` | -| HAPROXY_CUSTOMERRORS | (Optional) If HAProxy will use custom HTML errors. true/false. | `false` | +| Environment Variable | Description | Default | +|---------------------------------|------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------|---------------| +| EASYHAPROXY_DISCOVER | How the services will be discovered to create `haproxy.cfg`: `static`, `docker`, `swarm` or `kubernetes` | **required** | +| EASYHAPROXY_LABEL_PREFIX | (Optional) The key will search for matching resources. | `easyhaproxy` | +| EASYHAPROXY_LETSENCRYPT_EMAIL | (Optional) The email will be used to request the certificate to Letsencrypt | *empty* | +| EASYHAPROXY_LETSENCRYPT_SERVER | (Optional) Can be `staging` or 'schema://domain.tld'. If set, will try to connect to the Letsencrypt test server | *empty* | +| EASYHAPROXY_SSL_MODE | (Optional) `strict` supports only the most recent TLS version; `default` good SSL integration with recent browsers; `loose` supports all old SSL protocols for old browsers (not recommended). | `default` | +| EASYHAPROXY_REFRESH_CONF | (Optional) Check configuration every N seconds. | 10 | +| EASYHAPROXY_LOG_LEVEL | (Optional) The log level for EasyHAproxy messages. Available: TRACE,DEBUG,INFO,WARN,ERROR,FATAL | DEBUG | +| CERTBOT_LOG_LEVEL | (Optional) The log level for Certbot messages. Available: TRACE,DEBUG,INFO,WARN,ERROR,FATAL | DEBUG | +| HAPROXY_LOG_LEVEL | (Optional) The log level for HAProxy messages. Available: TRACE,DEBUG,INFO,WARN,ERROR,FATAL | INFO | +| HAPROXY_USERNAME | (Optional) The HAProxy username to the statistics. | `admin` | +| HAPROXY_PASSWORD | (Optional) The HAProxy password to the statistics. If not set, statistics will be available with no password | *empty* | +| HAPROXY_STATS_PORT | (Optional) The HAProxy port to the statistics. If set to `false`, disable statistics | `1936` | +| HAPROXY_CUSTOMERRORS | (Optional) If HAProxy will use custom HTML errors. true/false. | `false` | diff --git a/docs/kubernetes.md b/docs/kubernetes.md index 8b65f6f..f35020d 100644 --- a/docs/kubernetes.md +++ b/docs/kubernetes.md @@ -79,14 +79,17 @@ Caveats: ## Kubernetes annotations -| annotation | Description | Default | Example | -|-----------------------------|-----------------------------------------------------------------------------------------|--------------|--------------| -| kubernetes.io/ingress.class | (required) Activate EasyHAProxy. | **required** | easyhaproxy-ingress -| easyhaproxy.redirect_ssl | (optional) Boolean. Force redirect all endpoints to HTTPS. | false | true or false -| easyhaproxy.letsencrypt | (optional) Boolean. It will request letsencrypt certificates for the ingresses domains. | false | true or false -| easyhaproxy.redirect | (optional) JSON. Key pair with a domain and its destination. | *empty* | {"domain":"redirect_url"} -| easyhaproxy.mode | (optional) Set the HTTP mode for that connection. | http | http or tcp -| easyhaproxy.listen_port | (optional) Set the an additional port for that ingress | http | http or tcp +| annotation | Description | Default | Example | +|----------------------------------|-----------------------------------------------------------------------------------------|--------------|---------------------------------------| +| kubernetes.io/ingress.class | (required) Activate EasyHAProxy. | **required** | easyhaproxy-ingress | +| easyhaproxy.redirect_ssl | (optional) Boolean. Force redirect all endpoints to HTTPS. | false | true or false | +| easyhaproxy.letsencrypt | (optional) Boolean. It will request letsencrypt certificates for the ingresses domains. | false | true or false | +| easyhaproxy.redirect | (optional) JSON. Key pair with a domain and its destination. | *empty* | {"domain":"redirect_url"} | +| easyhaproxy.mode | (optional) Set the HTTP mode for that connection. | http | http or tcp | +| easyhaproxy.listen_port | (optional) Set the an additional port for that ingress | http | http or tcp | +| easyhaproxt.logLevel.certbot | (optional) Certbot log level | DEBUG | TRACE,DEBUG,INFO,WARN,ERROR or FATAL | +| easyhaproxt.logLevel.eashhaproxy | (optional) EasyHAProxy log level | DEBUG | TRACE,DEBUG,INFO,WARN,ERROR or FATAL | +| easyhaproxt.logLevel.haproxy | (optional) HAProxy log level | INFO | TRACE,DEBUG,INFO,WARN,ERROR or FATAL | **Important**: The annotations are per ingress and applied to all hosts in that ingress configuration. diff --git a/docs/static.md b/docs/static.md index 6a35bda..47de056 100644 --- a/docs/static.md +++ b/docs/static.md @@ -20,9 +20,11 @@ customerrors: true # Optional (default false) ssl_mode: default -letsencrypt: { - "email": "acme@example.org" -} +logLevel: + haproxy: INFO + +letsencrypt: + email: "acme@example.org" easymapping: - port: 80 @@ -83,10 +85,14 @@ customerrors: true # Optional (default false) ssl_mode: default # Optional -letsencrypt: { # Optional. If you enable `letsencrypt` will need to setu0p this, - # otherwise the certificate will be issued - "email": "acme@example.org" -} +logLevel: + certbot: DEBUG # Optional (default: DEBUG). Can be: TRACE,DEBUG,INFO,WARN,ERROR,FATAL + easyhaproxy: DEBUG # Optional (default: DEBUG). Can be: TRACE,DEBUG,INFO,WARN,ERROR,FATAL + haproxy: INFO # Optional (default: INFO). Can be: TRACE,DEBUG,INFO,WARN,ERROR,FATAL + +# Optional. If you enable `letsencrypt` will need to set up this, otherwise the certificate will be issued +letsencrypt: + email": "acme@example.org" easymapping: - port: 80 # Listen port diff --git a/src/easymapping/__init__.py b/src/easymapping/__init__.py index c2626a6..a08bddc 100644 --- a/src/easymapping/__init__.py +++ b/src/easymapping/__init__.py @@ -56,7 +56,7 @@ class HaproxyConfigGenerator: self.serving_hosts = [] self.certs = {} - def generate(self, container_metadata = {}): + def generate(self, container_metadata={}): self.mapping.setdefault("easymapping", []) if container_metadata != {}: diff --git a/src/processor/__init__.py b/src/processor/__init__.py index 748ad1e..e296a1c 100644 --- a/src/processor/__init__.py +++ b/src/processor/__init__.py @@ -31,7 +31,13 @@ class ContainerEnv: "email": os.getenv("EASYHAPROXY_LETSENCRYPT_EMAIL"), "server": os.getenv("EASYHAPROXY_LETSENCRYPT_SERVER", "false").lower() in ["true", "1", "yes"] } - + + env_vars["logLevel"] = { + "easyhaproxy": os.getenv("EASYHAPROXY_LOG_LEVEL") if os.getenv("EASYHAPROXY_LOG_LEVEL") else Functions.DEBUG, + "haproxy": os.getenv("HAPROXY_LOG_LEVEL") if os.getenv("HAPROXY_LOG_LEVEL") else Functions.INFO, + "certbot": os.getenv("CERTBOT_LOG_LEVEL") if os.getenv("CERTBOT_LOG_LEVEL") else Functions.DEBUG, + } + return env_vars diff --git a/src/templates/haproxy.cfg.j2 b/src/templates/haproxy.cfg.j2 index a1842b1..9264ade 100644 --- a/src/templates/haproxy.cfg.j2 +++ b/src/templates/haproxy.cfg.j2 @@ -1,5 +1,18 @@ +{% set log_definition = data["logLevel"] | default({}) %} +{% set log_level = log_definition["haproxy"] | default("INFO") | upper %} +{% if log_level == "TRACE" or log_level == "DEBUG" %} +{% set haproxy_log_level = "debug" %} +{% elif log_level == "INFO" %} +{% set haproxy_log_level = "info" %} +{% elif log_level == "WARN" %} +{% set haproxy_log_level = "warning" %} +{% elif log_level == "ERROR" %} +{% set haproxy_log_level = "err" %} +{% elif log_level == "FATAL" %} +{% set haproxy_log_level = "crit" %} +{% endif %} global - log stdout format raw local0 info + log stdout format raw local0 {{ haproxy_log_level }} maxconn 2000 {% if data["ssl_mode"] == "strict" %} {% include "ssl_strict.j2" %} diff --git a/src/tests/test_containerenv.py b/src/tests/test_containerenv.py index c338871..5145251 100644 --- a/src/tests/test_containerenv.py +++ b/src/tests/test_containerenv.py @@ -1,12 +1,20 @@ import pytest import os + +from functions import Functions from processor import ContainerEnv + def test_container_env_empty(): assert { "customerrors": False, "ssl_mode": "default", - "lookup_label": "easyhaproxy" + "lookup_label": "easyhaproxy", + "logLevel": { + "easyhaproxy": Functions.DEBUG, + "haproxy": Functions.INFO, + "certbot": Functions.DEBUG, + }, } == ContainerEnv.read() # os.environ['CERTBOT_LOG_LEVEL'] = 'warn' @@ -17,7 +25,12 @@ def test_container_env_customerrors(): assert { "customerrors": True, "ssl_mode": "default", - "lookup_label": "easyhaproxy" + "lookup_label": "easyhaproxy", + "logLevel": { + "easyhaproxy": Functions.DEBUG, + "haproxy": Functions.INFO, + "certbot": Functions.DEBUG, + }, } == ContainerEnv.read() finally: os.environ['HAPROXY_CUSTOMERRORS'] = '' @@ -28,7 +41,12 @@ def test_container_env_sslmode(): assert { "customerrors": False, "ssl_mode": "strict", - "lookup_label": "easyhaproxy" + "lookup_label": "easyhaproxy", + "logLevel": { + "easyhaproxy": Functions.DEBUG, + "haproxy": Functions.INFO, + "certbot": Functions.DEBUG, + }, } == ContainerEnv.read() finally: os.environ['EASYHAPROXY_SSL_MODE'] = '' @@ -41,6 +59,11 @@ def test_container_env_stats(): "customerrors": False, "ssl_mode": "default", "lookup_label": "easyhaproxy", + "logLevel": { + "easyhaproxy": Functions.DEBUG, + "haproxy": Functions.INFO, + "certbot": Functions.DEBUG, + }, } == ContainerEnv.read() finally: os.environ['HAPROXY_USERNAME'] = '' @@ -58,7 +81,12 @@ def test_container_env_stats_password(): "password": "xyz", "port": "1936" - } + }, + "logLevel": { + "easyhaproxy": Functions.DEBUG, + "haproxy": Functions.INFO, + "certbot": Functions.DEBUG, + }, } == ContainerEnv.read() finally: os.environ['HAPROXY_PASSWORD'] = '' @@ -78,7 +106,12 @@ def test_container_env_stats_password(): "password": "xyz", "port": "2101" - } + }, + "logLevel": { + "easyhaproxy": Functions.DEBUG, + "haproxy": Functions.INFO, + "certbot": Functions.DEBUG, + }, } == ContainerEnv.read() finally: os.environ['HAPROXY_USERNAME'] = '' @@ -96,7 +129,12 @@ def test_container_env_stats_password(): "letsencrypt": { "email": "acme@example.org", "server": False - } + }, + "logLevel": { + "easyhaproxy": Functions.DEBUG, + "haproxy": Functions.INFO, + "certbot": Functions.DEBUG, + }, } == ContainerEnv.read() finally: os.environ['EASYHAPROXY_LETSENCRYPT_EMAIL'] = '' @@ -112,7 +150,32 @@ def test_container_env_letsencrypt(): "letsencrypt": { "email": "acme@example.org", "server": True - } + }, + "logLevel": { + "easyhaproxy": Functions.DEBUG, + "haproxy": Functions.INFO, + "certbot": Functions.DEBUG, + }, } == ContainerEnv.read() finally: - os.environ['EASYHAPROXY_LETSENCRYPT_EMAIL'] = '' \ No newline at end of file + os.environ['EASYHAPROXY_LETSENCRYPT_EMAIL'] = '' + +def test_container_log_level(): + os.environ['CERTBOT_LOG_LEVEL'] = Functions.TRACE + os.environ['EASYHAPROXY_LOG_LEVEL'] = Functions.ERROR + os.environ['HAPROXY_LOG_LEVEL'] = Functions.FATAL + try: + assert { + "customerrors": False, + "ssl_mode": "default", + "lookup_label": "easyhaproxy", + "logLevel": { + "easyhaproxy": Functions.ERROR, + "haproxy": Functions.FATAL, + "certbot": Functions.TRACE, + }, + } == ContainerEnv.read() + finally: + os.environ['CERTBOT_LOG_LEVEL'] = '' + os.environ['EASYHAPROXY_LOG_LEVEL'] = '' + os.environ['HAPROXY_LOG_LEVEL'] = '' From d43c7572d2a290375aba81c79457fc336071e9e4 Mon Sep 17 00:00:00 2001 From: Joao Gilberto Magalhaes Date: Fri, 30 Jun 2023 13:35:12 -0500 Subject: [PATCH 16/33] Working on issue #41 --- docs/letsencrypt.md | 53 +++++++++++++++++++++++++-------------------- docs/ssl.md | 17 +++++++++------ 2 files changed, 39 insertions(+), 31 deletions(-) diff --git a/docs/letsencrypt.md b/docs/letsencrypt.md index 0e8c86b..d44c87b 100644 --- a/docs/letsencrypt.md +++ b/docs/letsencrypt.md @@ -2,49 +2,54 @@ EasyHAProxy can issue a letsencrypt certificate. Follow the steps below: -Run the EasyHAProxy: +## Setting up EasyHAProxy + +Run the EasyHAProxy container: ```bash docker run \ - -e EASYHAPROXY_LETSENCRYPT_EMAIL=john@doe.com - .... \ + ... \ + -e EASYHAPROXY_LETSENCRYPT_EMAIL=john@doe.com \ + -p 80:80 \ + -p 443:443 \ + -v /path/to/guest/lestencrypt/certs:/certs/letsencrypt \ + ... \ byjg/easy-haproxy ``` -Run your container: +Notes: + +- If you don't setup `EASYHAPROXY_LETSENCRYPT_EMAIL` environment variable, EasyHAProxy will fail silently and **will not request** a certificate. +- The ports 80 and 443 needs to accessible through the internet as [Let's Encrypt requirement](https://letsencrypt.org/docs/allow-port-80/) + +Be aware of Letsencrypt issue rate limits: + +- https://letsencrypt.org/docs/duplicate-certificate-limit/ +- https://letsencrypt.org/docs/rate-limits/ + +In order to avoid several certificate issuing, +**It is required you to persist the container folder `/certs/letsencrypt` outside the container.** +You cannot delete or change it contents. +If you do not persist, or change/delete the contents, Let's encrypt will not work properly. + + +## Setting up your container to use Let's Encrypt ```bash docker run \ + ... \ --label easyhaproxy.express.port=80 \ --label easyhaproxy.express.localport=3000 \ --label easyhaproxy.express.host=example.org \ --label easyhaproxy.express.letsencrypt=true \ - .... \ + ... \ some/myimage ``` Requirements: -- Your container **must** listen to port 80. Letsencrypt will not issue the certificate if `easyhaproxy.express.port` is in another port, and EasyHAProxy will fail silently. +- Your container **must** listen to port 80. Letsencrypt will not issue the certificate if `easyhaproxy..port` is in another port, and EasyHAProxy will fail silently. - You cannot set port 443 for the container with the Letsencrypt because EasyHAProxy will create this port automatically once the certificate is issued. -- `EASYHAPROXY_LETSENCRYPT_EMAIL` environment variable is required to be set. If you don't set it up, EasyHAProxy **will not request** a certificate. - -Be aware of Letsencrypt issue limits - https://letsencrypt.org/docs/duplicate-certificate-limit/ and https://letsencrypt.org/docs/rate-limits/ - -## Persist your Letsencrypt certificates - -It is a good idea to store the letsencrypt certificate in persistent storage because of the limit on how many certificates can be issued for the same domain in a period. - -To do this, map the folder `/certs/letsencrypt` to a docker volume. - -```bash -docker volume create certs_letsencrypt - -docker run \ - /* other parameters */ - -v certs_letsencrypt:/certs/letsencrypt \ - -d byjg/easy-haproxy -``` ---- [Open source ByJG](http://opensource.byjg.com) diff --git a/docs/ssl.md b/docs/ssl.md index 4384bbf..c2f551d 100644 --- a/docs/ssl.md +++ b/docs/ssl.md @@ -4,9 +4,12 @@ You can use your certificates with EasyHAProxy. There are two ways to do that. +- [Setup certificate as a label definition in docker container](#setup-certificate-as-a-label-definition-in-docker-container) +- [Map the certificate as a docker volume](#map-the-certificate-as-a-docker-volume) + ## Setup certificate as a label definition in docker container -### Create a single PEM from the certificate and key. +1. Create a single PEM from the certificate and key. ```bash cat example.com.crt example.com.key > single.pem @@ -24,21 +27,21 @@ MIIEojCCA4qgAwIBAgIUegW2BimwuL4RzRZ2WYkHA6U5nkAwDQYJKoZIhvcNAQEL -----END PRIVATE KEY----- ``` -### Convert the `single.pem` to BASE64 in a single line: +2. Convert the `single.pem` to BASE64 in a single line: ```bash cat single.pem | base64 -w0 ``` -### Map the BASE64 to your container +3. Define a label in yout container Add the Base64 string you generated before to the label `easyhaproxy.[definition].sslcert` ## Map the certificate as a docker volume -EasyHAProxy stores the certificates inside the folder `/certs/haproxy`. +EasyHAProxy stores the certificates inside the container folder `/certs/haproxy`. -### Run EasyHAProxy with the volume for the certificates: +1. Run EasyHAProxy with the volume for the certificates: ```bash docker volume create certs_haproxy @@ -49,7 +52,7 @@ docker run \ -d byjg/easy-haproxy ``` -### Create a single PEM from the certificate and the key. +2. Create a single PEM from the certificate and the key. ```bash cat example.com.crt example.com.key > single.pem @@ -67,7 +70,7 @@ MIIEojCCA4qgAwIBAgIUegW2BimwuL4RzRZ2WYkHA6U5nkAwDQYJKoZIhvcNAQEL -----END PRIVATE KEY----- ``` -### Copy this certificate to EasyHAProxy volume +3. Copy this certificate to EasyHAProxy volume ```bash docker cp single.pem easyhaproxy:/certs/haproxy From b26d13cd755421c08a12ebe4331aa9948b0471a2 Mon Sep 17 00:00:00 2001 From: Joao Gilberto Magalhaes Date: Fri, 30 Jun 2023 13:52:58 -0500 Subject: [PATCH 17/33] Fix docker-compose-portainer.yml --- examples/docker/docker-compose-portainer.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/examples/docker/docker-compose-portainer.yml b/examples/docker/docker-compose-portainer.yml index ec9fd3a..ba4a7a9 100644 --- a/examples/docker/docker-compose-portainer.yml +++ b/examples/docker/docker-compose-portainer.yml @@ -32,7 +32,7 @@ services: portainer: image: portainer/portainer-ce:latest volumes: - - portainer_data:/data portainer + - portainer_data:/data - /var/run/docker.sock:/var/run/docker.sock labels: easyhaproxy.http.redirect_ssl: true From 55888752c99c2bc232aec394f8c5c48905157895 Mon Sep 17 00:00:00 2001 From: Joao Gilberto Magalhaes Date: Sat, 1 Jul 2023 15:30:00 -0500 Subject: [PATCH 18/33] Using Python to validate the Certbot expiration date. --- src/functions/__init__.py | 98 ++++++++++++++++++++++----------------- src/requirements.txt | 3 +- 2 files changed, 58 insertions(+), 43 deletions(-) diff --git a/src/functions/__init__.py b/src/functions/__init__.py index d174dcf..087a862 100644 --- a/src/functions/__init__.py +++ b/src/functions/__init__.py @@ -6,12 +6,14 @@ import time import os import re import time +from OpenSSL import crypto + class Functions: - HAPROXY_LOG="HAPROXY" - EASYHAPROXY_LOG="EASYHAPROXY" - CERTBOT_LOG="CERTBOT" - INIT_LOG="INIT" + HAPROXY_LOG = "HAPROXY" + EASYHAPROXY_LOG = "EASYHAPROXY" + CERTBOT_LOG = "CERTBOT" + INIT_LOG = "INIT" TRACE = "TRACE" DEBUG = "DEBUG" @@ -28,7 +30,7 @@ class Functions: level_importance = { Functions.TRACE: 0, Functions.DEBUG: 1, - Functions.INFO: 2, + Functions.INFO: 2, Functions.WARN: 3, Functions.ERROR: 4, Functions.FATAL: 5 @@ -57,7 +59,7 @@ class Functions: if not isinstance(message, (list, tuple)): message = [message] - + for line in message: log = "[%s] %s [%s]: %s" % (source, datetime.now().strftime("%x %X"), level, line.rstrip()) print(log) @@ -70,10 +72,10 @@ class Functions: command = shlex.split(command) try: - process = subprocess.Popen(command, - stdout=subprocess.PIPE, - stderr=subprocess.PIPE, - universal_newlines=True) + process = subprocess.Popen(command, + stdout=subprocess.PIPE, + stderr=subprocess.PIPE, + universal_newlines=True) output = [] @@ -94,7 +96,7 @@ class Functions: return output except Exception as e: - Functions.log(source, Functions.ERROR, "%s" % (e)) + Functions.log(source, Functions.ERROR, "%s" % e) class Consts: @@ -103,6 +105,7 @@ class Consts: certs_letsencrypt = "/certs/letsencrypt" certs_haproxy = "/certs/haproxy" + class DaemonizeHAProxy: def __init__(self): self.process = None @@ -111,10 +114,13 @@ class DaemonizeHAProxy: def haproxy(self, action): if action == "start": - self.__prepare("/usr/sbin/haproxy -W -f /etc/haproxy/haproxy.cfg -p /run/haproxy.pid -S /var/run/haproxy.sock") + self.__prepare( + "/usr/sbin/haproxy -W -f /etc/haproxy/haproxy.cfg -p /run/haproxy.pid -S /var/run/haproxy.sock") else: pid = "".join(Functions().run_bash(Functions.HAPROXY_LOG, "cat /run/haproxy.pid", log_output=False)) - self.__prepare("/usr/sbin/haproxy -W -f /etc/haproxy/haproxy.cfg -p /run/haproxy.pid -x /var/run/haproxy.sock -sf %s" % (pid)) + self.__prepare( + "/usr/sbin/haproxy -W -f /etc/haproxy/haproxy.cfg -p /run/haproxy.pid -x /var/run/haproxy.sock -sf %s" % ( + pid)) if self.process is None: return @@ -128,29 +134,28 @@ class DaemonizeHAProxy: command = shlex.split(command) try: - self.process = subprocess.Popen(command, - shell=False, - stdout=subprocess.PIPE, - stderr=subprocess.PIPE, - bufsize=-1, - universal_newlines=True) + self.process = subprocess.Popen(command, + shell=False, + stdout=subprocess.PIPE, + stderr=subprocess.PIPE, + bufsize=-1, + universal_newlines=True) except Exception as e: - Functions.log(source, Functions.ERROR, "%s" % (e)) - + Functions.log(source, Functions.ERROR, "%s" % e) def __start(self): source = Functions.HAPROXY_LOG try: with self.process.stdout: - for line in iter(self.process.stdout.readline, b''): + for line in iter(self.process.stdout.readline, b''): Functions.log(source, Functions.INFO, line) - returncode = self.process.wait() + returncode = self.process.wait() Functions.log(source, Functions.DEBUG, "Return code %s" % (returncode)) except Exception as e: - Functions.log(source, Functions.ERROR, "%s" % (e)) + Functions.log(source, Functions.ERROR, "%s" % e) def is_alive(self): return self.thread.is_alive() @@ -165,7 +170,7 @@ class DaemonizeHAProxy: def sleep(self): if self.sleep_secs is None: - try: + try: self.sleep_secs = int(os.getenv("EASYHAPROXY_REFRESH_CONF", "10")) except ValueError: self.sleep_secs = 10 @@ -197,18 +202,27 @@ class Certbot: current_time = time.time() for host in hosts: filename = "%s/%s.pem" % (self.certs, host) - host_arg = '-d %s' % (host) + host_arg = '-d %s' % host if not os.path.exists(filename): - Functions.log(Functions.CERTBOT_LOG, Functions.DEBUG, "Request new certificate for %s" % (host)) + Functions.log(Functions.CERTBOT_LOG, Functions.DEBUG, "Request new certificate for %s" % host) request_certs.append(host_arg) else: - creation_time = os.path.getctime(filename) - if (current_time - creation_time) // (24 * 3600) > 90: - Functions.log(Functions.CERTBOT_LOG, Functions.DEBUG, "Request expired certificate for %s" % (host)) - request_certs.append(host_arg) - if (current_time - creation_time) // (24 * 3600) >= 45: - Functions.log(Functions.CERTBOT_LOG, Functions.DEBUG, "Renew certificate for %s" % (host)) - renew_certs.append(host_arg) + try: + with open(filename, 'r') as file: + certificate_str = file.read() + certificate = crypto.load_certificate(crypto.FILETYPE_PEM, certificate_str) + expiration_after = datetime.strptime(certificate.get_notAfter().decode()[:-1], + '%Y%m%d%H%M%S').timestamp() + + if current_time >= expiration_after: + Functions.log(Functions.CERTBOT_LOG, Functions.DEBUG, + "Request expired certificate for %s" % host) + request_certs.append(host_arg) + if (expiration_after - current_time) // (24 * 3600) >= 15: + Functions.log(Functions.CERTBOT_LOG, Functions.DEBUG, "Renew certificate for %s" % host) + renew_certs.append(host_arg) + except Exception as e: + Functions.log(Functions.CERTBOT_LOG, Functions.ERROR, "Certificate %s error %s" % (host, e)) certbot_certonly = ('/usr/bin/certbot certonly {test_server}' ' --standalone' @@ -219,10 +233,10 @@ class Certbot: ' --no-eff-email' ' --non-interactive' ' --max-log-backups=0' - ' {certs} --email {email}'.format(certs = ' '.join(request_certs), - email = self.email, - test_server = self.test_server) - ) + ' {certs} --email {email}'.format(certs=' '.join(request_certs), + email=self.email, + test_server=self.test_server) + ) ret_reload = False if len(request_certs) > 0: @@ -230,7 +244,7 @@ class Certbot: ret_reload = True if len(renew_certs) > 0: - Functions.run_bash(Functions.CERTBOT_LOG, "/usb/bin/certbot renew", return_result=False) + Functions.run_bash(Functions.CERTBOT_LOG, "/usr/bin/certbot renew", return_result=False) ret_reload = True if ret_reload: @@ -238,12 +252,12 @@ class Certbot: return ret_reload except Exception as e: - Functions.log(Functions.CERTBOT_LOG, Functions.ERROR, "%s" % (e)) + Functions.log(Functions.CERTBOT_LOG, Functions.ERROR, "%s" % e) return False - + def merge_certificate(self, cert, key, filename): Functions.save(filename, cert + key) - + def find_live_certificates(self): letsencrypt_certs = "/etc/letsencrypt/live/" if not os.path.exists(letsencrypt_certs): diff --git a/src/requirements.txt b/src/requirements.txt index 5c4ba98..6a6cc6b 100644 --- a/src/requirements.txt +++ b/src/requirements.txt @@ -4,4 +4,5 @@ jinja2 pytest docker kubernetes -deepdiff \ No newline at end of file +deepdiff +pyopenssl \ No newline at end of file From 5dbe0a6d28a29c42919193ae1924fba3ed5ae87f Mon Sep 17 00:00:00 2001 From: Joao Gilberto Magalhaes Date: Sat, 1 Jul 2023 16:05:53 -0500 Subject: [PATCH 19/33] Renaming Letsencrypt to Certbot, since it will be more generic allowing other issuers. --- README.md | 2 +- deploy/docker/docker-compose.yml | 6 +- deploy/docker/install.sh | 2 +- docs/container-labels.md | 26 +++---- docs/docker-environment.md | 30 ++++---- docs/docker.md | 4 ++ docs/helm.md | 2 +- docs/kubernetes.md | 20 +++--- docs/letsencrypt.md | 10 +-- docs/static.md | 8 +-- .../docker-compose-portainer-app-example.yml | 2 +- examples/docker/docker-compose-portainer.yml | 10 +-- helm/easyhaproxy/templates/deployment.yaml | 6 +- helm/easyhaproxy/values.yaml | 2 +- src/easymapping/__init__.py | 20 +++--- src/functions/__init__.py | 10 +-- src/main.py | 10 +-- src/processor/__init__.py | 22 +++--- src/templates/bind.j2 | 2 +- src/templates/frontend-mode-http.j2 | 10 +-- src/templates/haproxy.cfg.j2 | 2 +- src/tests/expected/docker.txt | 8 +-- src/tests/expected/no-services.txt | 2 +- src/tests/expected/services-letsencrypt.txt | 10 +-- .../expected/services-multi-containers.txt | 2 +- .../expected/services-multiple-hosts.txt | 2 +- src/tests/expected/services-redirect-ssl.txt | 4 +- src/tests/expected/services-tcp.txt | 2 +- src/tests/expected/services.txt | 8 +-- src/tests/expected/ssl-loose.txt | 2 +- src/tests/expected/ssl-strict.txt | 2 +- src/tests/expected/static.txt | 8 +-- src/tests/fixtures/services | 2 +- src/tests/fixtures/services-changed-label | 2 +- src/tests/fixtures/services-letsencrypt | 4 +- src/tests/fixtures/static.yml | 2 +- src/tests/test_containerenv.py | 16 ++--- src/tests/test_docker.py | 12 ++-- src/tests/test_parser.py | 72 +++++++++---------- src/tests/test_static.py | 6 +- 40 files changed, 188 insertions(+), 184 deletions(-) diff --git a/README.md b/README.md index ac4d522..0c9380e 100644 --- a/README.md +++ b/README.md @@ -23,7 +23,7 @@ EasyHAProxy can detect and configure HAProxy automatically on the following plat EasyHAProxy will discover the services based on the Docker Tags of the containers running on a Docker host or Docker Swarm cluster and dynamically set up the `haproxy.cfg`. Below, EasyHAProxy main features: -- Use Letsencrypt with HAProxy. +- Automatic SSL Certificate issued by any ACME compatible service as Letsencrypt and ZeroSSL - Set your custom SSL certificates - Balance traffic between multiple replicas - Set SSL with three different levels of validations and according to the most recent definitions. diff --git a/deploy/docker/docker-compose.yml b/deploy/docker/docker-compose.yml index 2586815..2c3284f 100644 --- a/deploy/docker/docker-compose.yml +++ b/deploy/docker/docker-compose.yml @@ -5,13 +5,13 @@ services: image: byjg/easy-haproxy:4.3.1-rc1 volumes: - /var/run/docker.sock:/var/run/docker.sock - - certs_letsencrypt:/certs/letsencrypt + - certs_certbot:/certs/certbot - certs_haproxy:/certs/haproxy environment: EASYHAPROXY_DISCOVER: docker EASYHAPROXY_LABEL_PREFIX: easyhaproxy - EASYHAPROXY_LETSENCRYPT_EMAIL: changeme@example.org + EASYHAPROXY_CERTBOT_EMAIL: changeme@example.org EASYHAPROXY_SSL_MODE: "default" HAPROXY_CUSTOMERRORS: "true" HAPROXY_USERNAME: admin @@ -27,7 +27,7 @@ services: - easyhaproxy volumes: - certs_letsencrypt: + certs_certbot: external: true certs_haproxy: external: true diff --git a/deploy/docker/install.sh b/deploy/docker/install.sh index d5ab2ff..366b146 100755 --- a/deploy/docker/install.sh +++ b/deploy/docker/install.sh @@ -3,7 +3,7 @@ ASSETS_DIR="$(dirname "${BASH_SOURCE[0]}")"/../../build/assets/certs/haproxy docker network create easyhaproxy -docker volume create certs_letsencrypt +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 diff --git a/docs/container-labels.md b/docs/container-labels.md index b71cecb..56b5a1f 100644 --- a/docs/container-labels.md +++ b/docs/container-labels.md @@ -2,19 +2,19 @@ ## Container (Docker or Swarm) labels -| Tag | Description | Default | Example | -|---------------------------------------|-------------------------------------------------------------------------------------------------------|----------------|--------------| -| easyhaproxy.[definition].host | Host(s) HAProxy is listening. More than one host use comma as delimiter | **required** | somehost.com OR host1.com,host2.com | -| easyhaproxy.[definition].mode | (Optional) Is this `http` or `tcp` mode in HAProxy. | http | http or tcp | -| easyhaproxy.[definition].port | (Optional) Port HAProxy will listen for the host. | 80 | 3000 | -| easyhaproxy.[definition].localport | (Optional) Port container is listening. | 80 | 8080 | -| easyhaproxy.[definition].redirect | (Optional) JSON containing key/value pair from host/to URL redirect. | *empty* | {"foo.com":"https://bla.com", "bar.com":"https://bar.org"} | -| easyhaproxy.[definition].sslcert | (Optional) Cert PEM Base64 encoded. Do not use this if `letsencrypt` is enabled. | *empty* | base64 cert + key | -| easyhaproxy.[definition].ssl | (Optional) If `true` you need to provide certificate as a file. See below. Do not use with `sslcert`. | false | true or false | -| easyhaproxy.[definition].health-check | (Optional) `ssl`, enable health check via SSL in `mode tcp` | *empty* | ssl | -| easyhaproxy.[definition].letsencrypt | (Optional) Generate certificate with letsencrypt. Do not use with `sslcert` parameter. | false | true OR false | -| easyhaproxy.[definition].redirect_ssl | (Optional) Redirect all requests to https | false | true OR false | -| easyhaproxy.[definition].clone_to_ssl | (Optional) It copies the configuration to HTTPS(443) and disable SSL from the current config. **Do not use* this with `ssl` or `letsencrypt` parameters | false | true OR false | +| Tag | Description | Default | Example | +|---------------------------------------|-----------------------------------------------------------------------------------------------------------------------------------------------------|--------------|------------------------------------------------------------| +| easyhaproxy.[definition].host | Host(s) HAProxy is listening. More than one host use comma as delimiter | **required** | somehost.com OR host1.com,host2.com | +| easyhaproxy.[definition].mode | (Optional) Is this `http` or `tcp` mode in HAProxy. | http | http or tcp | +| easyhaproxy.[definition].port | (Optional) Port HAProxy will listen for the host. | 80 | 3000 | +| easyhaproxy.[definition].localport | (Optional) Port container is listening. | 80 | 8080 | +| easyhaproxy.[definition].redirect | (Optional) JSON containing key/value pair from host/to URL redirect. | *empty* | {"foo.com":"https://bla.com", "bar.com":"https://bar.org"} | +| easyhaproxy.[definition].sslcert | (Optional) Cert PEM Base64 encoded. Do not use this if `certbot` is enabled. | *empty* | base64 cert + key | +| easyhaproxy.[definition].ssl | (Optional) If `true` you need to provide certificate as a file. See below. Do not use with `sslcert`. | false | true or false | +| easyhaproxy.[definition].health-check | (Optional) `ssl`, enable health check via SSL in `mode tcp` | *empty* | ssl | +| easyhaproxy.[definition].certbot | (Optional) Generate certificate with certbot. Do not use with `sslcert` parameter. | false | true OR false | +| easyhaproxy.[definition].redirect_ssl | (Optional) Redirect all requests to https | false | true OR false | +| easyhaproxy.[definition].clone_to_ssl | (Optional) It copies the configuration to HTTPS(443) and disable SSL from the current config. **Do not use* this with `ssl` or `certbot` parameters | false | true OR false | The `definition` is a string that will group all configurations togethers. Different `definition` will create different configurations. diff --git a/docs/docker-environment.md b/docs/docker-environment.md index 0b9ec11..b78e829 100644 --- a/docs/docker-environment.md +++ b/docs/docker-environment.md @@ -1,20 +1,20 @@ # Docker environment variables -| Environment Variable | Description | Default | -|---------------------------------|-------------------------------------------------------------------------------------------------|------------------| -| EASYHAPROXY_DISCOVER | How the services will be discovered to create `haproxy.cfg`: `static`, `docker`, `swarm` or `kubernetes` | **required** | -| EASYHAPROXY_LABEL_PREFIX | (Optional) The key will search for matching resources. | `easyhaproxy` | -| EASYHAPROXY_LETSENCRYPT_EMAIL | (Optional) The email will be used to request the certificate to Letsencrypt | *empty* | -| EASYHAPROXY_LETSENCRYPT_SERVER | (Optional) Can be `staging` or 'schema://domain.tld'. If set, will try to connect to the Letsencrypt test server | *empty* | -| EASYHAPROXY_SSL_MODE | (Optional) `strict` supports only the most recent TLS version; `default` good SSL integration with recent browsers; `loose` supports all old SSL protocols for old browsers (not recommended). | `default`| -| EASYHAPROXY_REFRESH_CONF | (Optional) Check configuration every N seconds. | 10 | -| EASYHAPROXY_LOG_LEVEL | (Optional) The log level for EasyHAproxy messages. Available: TRACE,DEBUG,INFO,WARN,ERROR,FATAL | DEBUG | -| CERTBOT_LOG_LEVEL | (Optional) The log level for Certbot messages. Available: TRACE,DEBUG,INFO,WARN,ERROR,FATAL | DEBUG | -| HAPROXY_LOG_LEVEL | (Optional) The log level for HAProxy messages. Available: TRACE,DEBUG,INFO,WARN,ERROR,FATAL | DEBUG | -| HAPROXY_USERNAME | (Optional) The HAProxy username to the statistics. | `admin` | -| HAPROXY_PASSWORD | (Optional) The HAProxy password to the statistics. If not set, statistics will be available with no password | *empty* | -| HAPROXY_STATS_PORT | (Optional) The HAProxy port to the statistics. If set to `false`, disable statistics | `1936` | -| HAPROXY_CUSTOMERRORS | (Optional) If HAProxy will use custom HTML errors. true/false. | `false` | +| Environment Variable | Description | Default | +|------------------------------|------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------|---------------| +| EASYHAPROXY_DISCOVER | How the services will be discovered to create `haproxy.cfg`: `static`, `docker`, `swarm` or `kubernetes` | **required** | +| EASYHAPROXY_LABEL_PREFIX | (Optional) The key will search for matching resources. | `easyhaproxy` | +| EASYHAPROXY_CERTBOT_EMAIL | (Optional) The email will be used to request the certificate to Certbox | *empty* | +| EASYHAPROXY_CERTBOT_SERVER | (Optional) Can be `staging` or 'schema://domain.tld'. If set, will try to connect to the Certbot test server | *empty* | +| EASYHAPROXY_SSL_MODE | (Optional) `strict` supports only the most recent TLS version; `default` good SSL integration with recent browsers; `loose` supports all old SSL protocols for old browsers (not recommended). | `default` | +| EASYHAPROXY_REFRESH_CONF | (Optional) Check configuration every N seconds. | 10 | +| EASYHAPROXY_LOG_LEVEL | (Optional) The log level for EasyHAproxy messages. Available: TRACE,DEBUG,INFO,WARN,ERROR,FATAL | DEBUG | +| CERTBOT_LOG_LEVEL | (Optional) The log level for Certbot messages. Available: TRACE,DEBUG,INFO,WARN,ERROR,FATAL | DEBUG | +| HAPROXY_LOG_LEVEL | (Optional) The log level for HAProxy messages. Available: TRACE,DEBUG,INFO,WARN,ERROR,FATAL | DEBUG | +| HAPROXY_USERNAME | (Optional) The HAProxy username to the statistics. | `admin` | +| HAPROXY_PASSWORD | (Optional) The HAProxy password to the statistics. If not set, statistics will be available with no password | *empty* | +| HAPROXY_STATS_PORT | (Optional) The HAProxy port to the statistics. If set to `false`, disable statistics | `1936` | +| HAPROXY_CUSTOMERRORS | (Optional) If HAProxy will use custom HTML errors. true/false. | `false` | diff --git a/docs/docker.md b/docs/docker.md index e8d85e4..edf9fc6 100644 --- a/docs/docker.md +++ b/docs/docker.md @@ -58,6 +58,10 @@ You can configure the behavior of the EasyHAProxy by setup specific environment Follow [this link](letsencrypt.md) +## Setup certificates with ZeroSSL + +Follow [this link](zerossl.md) + ## Setup your own certificates Follow [this link](ssl.md) diff --git a/docs/helm.md b/docs/helm.md index 376d5ff..ae35f59 100644 --- a/docs/helm.md +++ b/docs/helm.md @@ -51,7 +51,7 @@ easyhaproxy: certbot: DEBUG easyhaproxy: DEBUG haproxy: DEBUG - letsencrypt: + certbot: email: "" service: diff --git a/docs/kubernetes.md b/docs/kubernetes.md index 8b65f6f..005687d 100644 --- a/docs/kubernetes.md +++ b/docs/kubernetes.md @@ -79,27 +79,27 @@ Caveats: ## Kubernetes annotations -| annotation | Description | Default | Example | -|-----------------------------|-----------------------------------------------------------------------------------------|--------------|--------------| -| kubernetes.io/ingress.class | (required) Activate EasyHAProxy. | **required** | easyhaproxy-ingress -| easyhaproxy.redirect_ssl | (optional) Boolean. Force redirect all endpoints to HTTPS. | false | true or false -| easyhaproxy.letsencrypt | (optional) Boolean. It will request letsencrypt certificates for the ingresses domains. | false | true or false -| easyhaproxy.redirect | (optional) JSON. Key pair with a domain and its destination. | *empty* | {"domain":"redirect_url"} -| easyhaproxy.mode | (optional) Set the HTTP mode for that connection. | http | http or tcp -| easyhaproxy.listen_port | (optional) Set the an additional port for that ingress | http | http or tcp +| annotation | Description | Default | Example | +|-----------------------------|-------------------------------------------------------------------------------------|--------------|---------------------------| +| kubernetes.io/ingress.class | (required) Activate EasyHAProxy. | **required** | easyhaproxy-ingress | +| easyhaproxy.redirect_ssl | (optional) Boolean. Force redirect all endpoints to HTTPS. | false | true or false | +| easyhaproxy.certbot | (optional) Boolean. It will request certbot certificates for the ingresses domains. | false | true or false | +| easyhaproxy.redirect | (optional) JSON. Key pair with a domain and its destination. | *empty* | {"domain":"redirect_url"} | +| easyhaproxy.mode | (optional) Set the HTTP mode for that connection. | http | http or tcp | +| easyhaproxy.listen_port | (optional) Set the an additional port for that ingress | http | http or tcp | **Important**: The annotations are per ingress and applied to all hosts in that ingress configuration. ## Letsencrypt -It is necessary add the annotation `easyhaproxy.letsencrypt` to the ingress configuration: +It is necessary add the annotation `easyhaproxy.certbot` to the ingress configuration: ```yaml kind: Ingress metadata: annotations: kubernetes.io/ingress.class: easyhaproxy-ingress - easyhaproxy.letsencrypt: 'true' + easyhaproxy.certbot: 'true' name: example-ingress namespace: example spec: diff --git a/docs/letsencrypt.md b/docs/letsencrypt.md index d44c87b..acecae0 100644 --- a/docs/letsencrypt.md +++ b/docs/letsencrypt.md @@ -9,17 +9,17 @@ Run the EasyHAProxy container: ```bash docker run \ ... \ - -e EASYHAPROXY_LETSENCRYPT_EMAIL=john@doe.com \ + -e EASYHAPROXY_CERTBOT_EMAIL=john@doe.com \ -p 80:80 \ -p 443:443 \ - -v /path/to/guest/lestencrypt/certs:/certs/letsencrypt \ + -v /path/to/guest/certbot/certs:/certs/certbot \ ... \ byjg/easy-haproxy ``` Notes: -- If you don't setup `EASYHAPROXY_LETSENCRYPT_EMAIL` environment variable, EasyHAProxy will fail silently and **will not request** a certificate. +- If you don't setup `EASYHAPROXY_CERTBOT_EMAIL` environment variable, EasyHAProxy will fail silently and **will not request** a certificate. - The ports 80 and 443 needs to accessible through the internet as [Let's Encrypt requirement](https://letsencrypt.org/docs/allow-port-80/) Be aware of Letsencrypt issue rate limits: @@ -28,7 +28,7 @@ Be aware of Letsencrypt issue rate limits: - https://letsencrypt.org/docs/rate-limits/ In order to avoid several certificate issuing, -**It is required you to persist the container folder `/certs/letsencrypt` outside the container.** +**It is required you to persist the container folder `/certs/certbot` outside the container.** You cannot delete or change it contents. If you do not persist, or change/delete the contents, Let's encrypt will not work properly. @@ -41,7 +41,7 @@ docker run \ --label easyhaproxy.express.port=80 \ --label easyhaproxy.express.localport=3000 \ --label easyhaproxy.express.host=example.org \ - --label easyhaproxy.express.letsencrypt=true \ + --label easyhaproxy.express.certbot=true \ ... \ some/myimage ``` diff --git a/docs/static.md b/docs/static.md index 6a35bda..bf7b76e 100644 --- a/docs/static.md +++ b/docs/static.md @@ -20,7 +20,7 @@ customerrors: true # Optional (default false) ssl_mode: default -letsencrypt: { +certbot: { "email": "acme@example.org" } @@ -30,7 +30,7 @@ easymapping: host1.com.br: containers: - container:5000 - letsencrypt: true + certbot: true redirect_ssl: true host2.com.br: containers: @@ -83,7 +83,7 @@ customerrors: true # Optional (default false) ssl_mode: default # Optional -letsencrypt: { # Optional. If you enable `letsencrypt` will need to setu0p this, +certbot: { # Optional. If you enable `certbot` will need to setu0p this, # otherwise the certificate will be issued "email": "acme@example.org" } @@ -95,7 +95,7 @@ easymapping: host1.com.br: # Hostname containers: - container:5000 # Endpoints of the hostname above (ip, dns, container, etc) - letsencrypt: true # Optional. it will request a letsencrypt certiticate + certbot: true # Optional. it will request a certbot certiticate redirect_ssl: true # Optional. It will redirect this site to it SSL. ssl: true # Optional. Inform this port will listen to SSL, instead of HTTP clone_to_ssl: true # Optional. Default False. You clone these hosts to its equivalent SSL. diff --git a/examples/docker/docker-compose-portainer-app-example.yml b/examples/docker/docker-compose-portainer-app-example.yml index 70f6290..99c9ed1 100644 --- a/examples/docker/docker-compose-portainer-app-example.yml +++ b/examples/docker/docker-compose-portainer-app-example.yml @@ -8,7 +8,7 @@ services: easyhaproxy.http.host: test.xpto.us easyhaproxy.http.port: 80 easyhaproxy.http.localport: 8080 - easyhaproxy.http.letsencrypt: true + easyhaproxy.http.certbot: true networks: diff --git a/examples/docker/docker-compose-portainer.yml b/examples/docker/docker-compose-portainer.yml index ba4a7a9..9263b50 100644 --- a/examples/docker/docker-compose-portainer.yml +++ b/examples/docker/docker-compose-portainer.yml @@ -1,4 +1,4 @@ -# docker volume create certs_letsencrypt +# docker volume create certs_certbot # docker volume create certs_haproxy # docker volume create portainer_data # docker network create easyhaproxy @@ -11,13 +11,13 @@ services: image: byjg/easy-haproxy volumes: - /var/run/docker.sock:/var/run/docker.sock - - certs_letsencrypt:/certs/letsencrypt + - certs_certbot:/certs/certbot # - certs_haproxy:/certs/haproxy environment: EASYHAPROXY_DISCOVER: docker EASYHAPROXY_LABEL_PREFIX: easyhaproxy - EASYHAPROXY_LETSENCRYPT_EMAIL: changeme@example.org + EASYHAPROXY_CERTBOT_EMAIL: changeme@example.org EASYHAPROXY_SSL_MODE: "default" HAPROXY_CUSTOMERRORS: "true" HAPROXY_USERNAME: admin @@ -36,14 +36,14 @@ services: - /var/run/docker.sock:/var/run/docker.sock labels: easyhaproxy.http.redirect_ssl: true - easyhaproxy.http.letsencrypt: true + easyhaproxy.http.certbot: true easyhaproxy.http.host: portainer.xpto.us easyhaproxy.http.port: 80 easyhaproxy.http.localport: 9000 volumes: - certs_letsencrypt: + certs_certbot: external: true certs_haproxy: external: true diff --git a/helm/easyhaproxy/templates/deployment.yaml b/helm/easyhaproxy/templates/deployment.yaml index ecaa538..07cae85 100644 --- a/helm/easyhaproxy/templates/deployment.yaml +++ b/helm/easyhaproxy/templates/deployment.yaml @@ -69,7 +69,7 @@ spec: value: {{ .Values.easyhaproxy.logLevel.haproxy }} - name: CERTBOT_LOG_LEVEL value: {{ .Values.easyhaproxy.logLevel.certbot }} - {{- if .Values.easyhaproxy.letsencrypt.email }} - - name: EASYHAPROXY_LETSENCRYPT_EMAIL - value: {{ .Values.easyhaproxy.letsencrypt.email }} + {{- if .Values.easyhaproxy.certbot.email }} + - name: EASYHAPROXY_CERTBOT_EMAIL + value: {{ .Values.easyhaproxy.certbot.email }} {{ end }} diff --git a/helm/easyhaproxy/values.yaml b/helm/easyhaproxy/values.yaml index c3bb6a6..2ed4dfd 100644 --- a/helm/easyhaproxy/values.yaml +++ b/helm/easyhaproxy/values.yaml @@ -69,7 +69,7 @@ easyhaproxy: certbot: DEBUG easyhaproxy: DEBUG haproxy: DEBUG - letsencrypt: + certbot: email: "" # Make sure to create this diff --git a/src/easymapping/__init__.py b/src/easymapping/__init__.py index c2626a6..e17dcf5 100644 --- a/src/easymapping/__init__.py +++ b/src/easymapping/__init__.py @@ -49,10 +49,10 @@ class HaproxyConfigGenerator: def __init__(self, mapping): self.mapping = mapping self.mapping.setdefault("ssl_mode", 'default') - self.mapping.setdefault("letsencrypt", {"email": "", "staging": False}) + self.mapping.setdefault("certbot", {"email": "", "staging": False}) self.mapping["ssl_mode"] = self.mapping["ssl_mode"].lower() self.label = DockerLabelHandler(mapping['lookup_label'] if 'lookup_label' in mapping else "easyhaproxy") - self.letsencrypt_hosts = [] + self.certbot_hosts = [] self.serving_hosts = [] self.certs = {} @@ -106,10 +106,10 @@ class HaproxyConfigGenerator: "80" ) - letsencrypt = self.label.get_bool( - self.label.create([definition, "letsencrypt"]), + certbot = self.label.get_bool( + self.label.create([definition, "certbot"]), False - ) and self.mapping["letsencrypt"]["email"] != "" + ) and self.mapping["certbot"]["email"] != "" clone_to_ssl = self.label.get_bool( self.label.create([definition, "clone_to_ssl"]) ) @@ -139,9 +139,9 @@ class HaproxyConfigGenerator: self.serving_hosts.append("%s:%s" % (hostname, port)) easymapping[port]["hosts"].setdefault(hostname, {}) easymapping[port]["hosts"][hostname].setdefault("containers", []) - easymapping[port]["hosts"][hostname].setdefault("letsencrypt", False) + easymapping[port]["hosts"][hostname].setdefault("certbot", False) easymapping[port]["hosts"][hostname]["containers"] += ["{}:{}".format(container, ct_port)] - easymapping[port]["hosts"][hostname]["letsencrypt"] = letsencrypt + easymapping[port]["hosts"][hostname]["certbot"] = certbot easymapping[port]["hosts"][hostname]["redirect_ssl"] = self.label.get_bool( self.label.create([definition, "redirect_ssl"]) ) @@ -150,7 +150,7 @@ class HaproxyConfigGenerator: self.label.create([definition, "redirect"]) ) - if letsencrypt or clone_to_ssl: + if certbot or clone_to_ssl: if "443" not in easymapping: easymapping["443"] = { "mode": "http", @@ -160,10 +160,10 @@ class HaproxyConfigGenerator: "redirect": dict(), } easymapping["443"]["hosts"][hostname] = dict(easymapping[port]["hosts"][hostname]) - easymapping["443"]["hosts"][hostname]["letsencrypt"] = False + easymapping["443"]["hosts"][hostname]["certbot"] = False easymapping["443"]["hosts"][hostname]["redirect_ssl"] = False easymapping["443"]["ssl"] = True - self.letsencrypt_hosts.append(hostname) if letsencrypt and hostname not in self.letsencrypt_hosts else self.letsencrypt_hosts + self.certbot_hosts.append(hostname) if certbot and hostname not in self.certbot_hosts else self.certbot_hosts # handle SSL diff --git a/src/functions/__init__.py b/src/functions/__init__.py index 087a862..09b37d3 100644 --- a/src/functions/__init__.py +++ b/src/functions/__init__.py @@ -102,7 +102,7 @@ class Functions: class Consts: easyhaproxy_config = "/etc/haproxy/static/config.yml" haproxy_config = "/etc/haproxy/haproxy.cfg" - certs_letsencrypt = "/certs/letsencrypt" + certs_certbot = "/certs/certbot" certs_haproxy = "/certs/haproxy" @@ -259,11 +259,11 @@ class Certbot: Functions.save(filename, cert + key) def find_live_certificates(self): - letsencrypt_certs = "/etc/letsencrypt/live/" - if not os.path.exists(letsencrypt_certs): + certbot_certs = "/etc/letsencrypt/live/" + if not os.path.exists(certbot_certs): return - for item in os.listdir(letsencrypt_certs): - path = os.path.join(letsencrypt_certs, item) + for item in os.listdir(certbot_certs): + path = os.path.join(certbot_certs, item) if os.path.isdir(path): cert = Functions.load(os.path.join(path, "cert.pem")) key = Functions.load(os.path.join(path, "privkey.pem")) diff --git a/src/main.py b/src/main.py index 34e29d8..d251843 100644 --- a/src/main.py +++ b/src/main.py @@ -8,12 +8,12 @@ def start(): if processor_obj is None: exit(1) - os.makedirs(Consts.certs_letsencrypt, exist_ok=True) + os.makedirs(Consts.certs_certbot, exist_ok=True) os.makedirs(Consts.certs_haproxy, exist_ok=True) processor_obj.save_config(Consts.haproxy_config) processor_obj.save_certs(Consts.certs_haproxy) - letsencrypt_certs_found = processor_obj.get_letsencrypt_hosts() + certbot_certs_found = processor_obj.get_certbot_hosts() Functions.log(Functions.EASYHAPROXY_LOG, Functions.DEBUG, 'Found hosts: %s' % ", ".join(processor_obj.get_hosts())) # Needs to run after save_config Functions.log(Functions.EASYHAPROXY_LOG, Functions.TRACE, 'Object Found: %s' % (processor_obj.get_parsed_object())) @@ -22,7 +22,7 @@ def start(): haproxy.haproxy("start") haproxy.sleep() - certbot = Certbot(Consts.certs_letsencrypt, os.getenv("EASYHAPROXY_LETSENCRYPT_EMAIL"), os.getenv("EASYHAPROXY_LETSENCRYPT_SERVER", "").lower()) + certbot = Certbot(Consts.certs_certbot, os.getenv("EASYHAPROXY_CERTBOT_EMAIL"), os.getenv("EASYHAPROXY_CERTBOT_SERVER", "").lower()) while True: if old_haproxy is not None: @@ -31,12 +31,12 @@ def start(): try: old_parsed = processor_obj.get_parsed_object() processor_obj.refresh() - if certbot.check_certificates(letsencrypt_certs_found) or DeepDiff(old_parsed, processor_obj.get_parsed_object()) != {} or not haproxy.is_alive(): + if certbot.check_certificates(certbot_certs_found) or DeepDiff(old_parsed, processor_obj.get_parsed_object()) != {} or not haproxy.is_alive(): Functions.log(Functions.EASYHAPROXY_LOG, Functions.DEBUG, 'New configuration found. Reloading...') Functions.log(Functions.EASYHAPROXY_LOG, Functions.TRACE, 'Object Found: %s' % (processor_obj.get_parsed_object())) processor_obj.save_config(Consts.haproxy_config) processor_obj.save_certs(Consts.certs_haproxy) - letsencrypt_certs_found = processor_obj.get_letsencrypt_hosts() + certbot_certs_found = processor_obj.get_certbot_hosts() Functions.log(Functions.EASYHAPROXY_LOG, Functions.DEBUG, 'Found hosts: %s' % ", ".join(processor_obj.get_hosts())) # Needs to after save_config old_haproxy = haproxy haproxy = DaemonizeHAProxy() diff --git a/src/processor/__init__.py b/src/processor/__init__.py index 748ad1e..96e347d 100644 --- a/src/processor/__init__.py +++ b/src/processor/__init__.py @@ -26,10 +26,10 @@ class ContainerEnv: } env_vars["lookup_label"] = os.getenv("EASYHAPROXY_LABEL_PREFIX") if os.getenv("EASYHAPROXY_LABEL_PREFIX") else "easyhaproxy" - if (os.getenv("EASYHAPROXY_LETSENCRYPT_EMAIL")): - env_vars["letsencrypt"] = { - "email": os.getenv("EASYHAPROXY_LETSENCRYPT_EMAIL"), - "server": os.getenv("EASYHAPROXY_LETSENCRYPT_SERVER", "false").lower() in ["true", "1", "yes"] + if (os.getenv("EASYHAPROXY_CERTBOT_EMAIL")): + env_vars["certbot"] = { + "email": os.getenv("EASYHAPROXY_CERTBOT_EMAIL"), + "server": os.getenv("EASYHAPROXY_CERTBOT_SERVER", "false").lower() in ["true", "1", "yes"] } return env_vars @@ -57,7 +57,7 @@ class ProcessorInterface: return None def refresh(self): - self.letsencrypt_hosts = None + self.certbot_hosts = None self.parsed_object = None self.cfg = None self.hosts = None @@ -71,8 +71,8 @@ class ProcessorInterface: def parse(self): self.cfg = HaproxyConfigGenerator(ContainerEnv.read()) - def get_letsencrypt_hosts(self): - return self.letsencrypt_hosts + def get_certbot_hosts(self): + return self.certbot_hosts def get_hosts(self): return self.hosts @@ -88,7 +88,7 @@ class ProcessorInterface: def get_haproxy_conf(self): conf = self.cfg.generate(self.parsed_object) - self.letsencrypt_hosts = self.cfg.letsencrypt_hosts + self.certbot_hosts = self.cfg.certbot_hosts self.hosts = self.cfg.serving_hosts return conf @@ -206,7 +206,7 @@ class Kubernetes(ProcessorInterface): ssl_hosts = [] - letsencrypt = self._check_annotation(ingress.metadata.annotations, "easyhaproxy.letsencrypt") + certbot = self._check_annotation(ingress.metadata.annotations, "easyhaproxy.certbot") redirect_ssl = self._check_annotation(ingress.metadata.annotations, "easyhaproxy.redirect_ssl") redirect = self._check_annotation(ingress.metadata.annotations, "easyhaproxy.redirect") mode = self._check_annotation(ingress.metadata.annotations, "easyhaproxy.mode") @@ -252,8 +252,8 @@ class Kubernetes(ProcessorInterface): rule_data["%s.clone_to_ssl" % (definition)] = 'true' if redirect_ssl is not None: rule_data["%s.redirect_ssl" % (definition)] = redirect_ssl - if letsencrypt is not None: - rule_data["%s.letsencrypt" % (definition)] = letsencrypt + if certbot is not None: + rule_data["%s.certbot" % (definition)] = certbot if redirect is not None: rule_data["%s.redirect" % (definition)] = redirect if mode is not None: diff --git a/src/templates/bind.j2 b/src/templates/bind.j2 index d33d4bd..ba947f4 100644 --- a/src/templates/bind.j2 +++ b/src/templates/bind.j2 @@ -1,5 +1,5 @@ {% if "ssl" in o %} - bind *:{{ o["port"] }} ssl crt /certs/letsencrypt/ alpn http/1.1 crt /certs/haproxy/ alpn http/1.1 + bind *:{{ o["port"] }} ssl crt /certs/certbot/ alpn http/1.1 crt /certs/haproxy/ alpn http/1.1 {% elif "h2" in o and o["h2"] %} bind *:{{ o["port"] }} proto h2 option http-use-htx diff --git a/src/templates/frontend-mode-http.j2 b/src/templates/frontend-mode-http.j2 index a3ed56d..5f9b0b0 100644 --- a/src/templates/frontend-mode-http.j2 +++ b/src/templates/frontend-mode-http.j2 @@ -5,16 +5,16 @@ {%- for k in o["hosts"] %} {% set host = k.replace(".", "_") + "_{0}".format(o["port"]) %} - {% set letsencrypt = o["hosts"][k]["letsencrypt"] %} + {% set certbot = o["hosts"][k]["certbot"] %} acl is_rule_{{ host }}_1 hdr(host) -i {{ k }} acl is_rule_{{ host }}_2 hdr(host) -i {{ k }}:{{ o["port"] }} - {% if letsencrypt %} - acl is_letsencrypt_{{ host }} path_beg /.well-known/acme-challenge/ - use_backend letsencrypt_backend if is_letsencrypt_{{ host }} is_rule_{{ host }}_1 OR is_letsencrypt_{{ host }} is_rule_{{ host }}_2 + {% 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 letsencrypt %}!is_letsencrypt_{{ host }} {% endif %}is_rule_{{ host }}_1 OR {% if letsencrypt %}!is_letsencrypt_{{ 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 %} use_backend srv_{{ host }} if is_rule_{{ host }}_1 OR is_rule_{{ host }}_2 {% endif %} diff --git a/src/templates/haproxy.cfg.j2 b/src/templates/haproxy.cfg.j2 index a1842b1..5639b25 100644 --- a/src/templates/haproxy.cfg.j2 +++ b/src/templates/haproxy.cfg.j2 @@ -75,7 +75,7 @@ backend srv_{{ host }} {% endfor %} {% endfor %} -backend letsencrypt_backend +backend certbot_backend mode http server certbot 127.0.0.1:2080 diff --git a/src/tests/expected/docker.txt b/src/tests/expected/docker.txt index 7bcdedc..0775f93 100644 --- a/src/tests/expected/docker.txt +++ b/src/tests/expected/docker.txt @@ -36,7 +36,7 @@ backend srv_stats server Local 127.0.0.1:1936 frontend http_in_443 - bind *:443 ssl crt /certs/letsencrypt/ alpn http/1.1 crt /certs/haproxy/ alpn http/1.1 + bind *:443 ssl crt /certs/certbot/ alpn http/1.1 crt /certs/haproxy/ alpn http/1.1 mode http acl is_rule_hostssl_local_443_1 hdr(host) -i hostssl.local @@ -84,8 +84,8 @@ frontend http_in_90 acl is_rule_host2_local_90_1 hdr(host) -i host2.local acl is_rule_host2_local_90_2 hdr(host) -i host2.local:90 - acl is_letsencrypt_host2_local_90 path_beg /.well-known/acme-challenge/ - use_backend letsencrypt_backend if is_letsencrypt_host2_local_90 is_rule_host2_local_90_1 OR is_letsencrypt_host2_local_90 is_rule_host2_local_90_2 + acl is_certbot_host2_local_90 path_beg /.well-known/acme-challenge/ + use_backend certbot_backend if is_certbot_host2_local_90 is_rule_host2_local_90_1 OR is_certbot_host2_local_90 is_rule_host2_local_90_2 use_backend srv_host2_local_90 if is_rule_host2_local_90_1 OR is_rule_host2_local_90_2 backend srv_host2_local_90 @@ -96,6 +96,6 @@ backend srv_host2_local_90 http-request add-header X-Forwarded-Proto https if { ssl_fc } server srv-0 test_processor_docker:9000 check weight 1 -backend letsencrypt_backend +backend certbot_backend mode http server certbot 127.0.0.1:2080 diff --git a/src/tests/expected/no-services.txt b/src/tests/expected/no-services.txt index a95c434..7624237 100644 --- a/src/tests/expected/no-services.txt +++ b/src/tests/expected/no-services.txt @@ -23,6 +23,6 @@ defaults timeout server 10m -backend letsencrypt_backend +backend certbot_backend mode http server certbot 127.0.0.1:2080 diff --git a/src/tests/expected/services-letsencrypt.txt b/src/tests/expected/services-letsencrypt.txt index 3e2eafb..c72e401 100644 --- a/src/tests/expected/services-letsencrypt.txt +++ b/src/tests/expected/services-letsencrypt.txt @@ -49,9 +49,9 @@ 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_letsencrypt_test_example_org_80 path_beg /.well-known/acme-challenge/ - use_backend letsencrypt_backend if is_letsencrypt_test_example_org_80 is_rule_test_example_org_80_1 OR is_letsencrypt_test_example_org_80 is_rule_test_example_org_80_2 - http-request redirect scheme https code 301 if !is_letsencrypt_test_example_org_80 is_rule_test_example_org_80_1 OR !is_letsencrypt_test_example_org_80 is_rule_test_example_org_80_2 + 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 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 @@ -74,7 +74,7 @@ backend srv_test2_example_org_80 server srv-0 83d57d592e26:8080 check weight 1 frontend http_in_443 - bind *:443 ssl crt /certs/letsencrypt/ alpn http/1.1 crt /certs/haproxy/ alpn http/1.1 + bind *:443 ssl crt /certs/certbot/ alpn http/1.1 crt /certs/haproxy/ alpn http/1.1 mode http acl is_rule_test_example_org_443_1 hdr(host) -i test.example.org @@ -90,6 +90,6 @@ backend srv_test_example_org_443 server srv-0 f5c645a0dfc6:80 check weight 1 verify none server srv-1 b63438410b6a:80 check weight 1 verify none -backend letsencrypt_backend +backend certbot_backend mode http server certbot 127.0.0.1:2080 diff --git a/src/tests/expected/services-multi-containers.txt b/src/tests/expected/services-multi-containers.txt index 5910691..3b1644d 100644 --- a/src/tests/expected/services-multi-containers.txt +++ b/src/tests/expected/services-multi-containers.txt @@ -40,6 +40,6 @@ backend srv_www_helloworld_com_19901 server srv-0 test_nginx.2.t5r94mjlced7m3t5orfjbowmm:80 check weight 1 server srv-1 test_nginx.1.p552hqxkdx88narjrp5kouwb2:80 check weight 1 -backend letsencrypt_backend +backend certbot_backend mode http server certbot 127.0.0.1:2080 diff --git a/src/tests/expected/services-multiple-hosts.txt b/src/tests/expected/services-multiple-hosts.txt index 954dde1..e5caf4b 100644 --- a/src/tests/expected/services-multiple-hosts.txt +++ b/src/tests/expected/services-multiple-hosts.txt @@ -73,6 +73,6 @@ backend srv_hello_com_19901 server srv-0 3e63154954b0:80 check weight 1 server srv-1 eb294c110eb1:80 check weight 1 -backend letsencrypt_backend +backend certbot_backend mode http server certbot 127.0.0.1:2080 diff --git a/src/tests/expected/services-redirect-ssl.txt b/src/tests/expected/services-redirect-ssl.txt index 077123b..ce59c19 100644 --- a/src/tests/expected/services-redirect-ssl.txt +++ b/src/tests/expected/services-redirect-ssl.txt @@ -49,7 +49,7 @@ backend srv_host1_local_80 server srv-0 5b69bc7fea1b:80 check weight 1 frontend http_in_443 - bind *:443 ssl crt /certs/letsencrypt/ alpn http/1.1 crt /certs/haproxy/ alpn http/1.1 + bind *:443 ssl crt /certs/certbot/ alpn http/1.1 crt /certs/haproxy/ alpn http/1.1 mode http acl is_rule_host2_local_443_1 hdr(host) -i host2.local @@ -75,6 +75,6 @@ backend srv_host1_local_443 http-request add-header X-Forwarded-Proto https if { ssl_fc } server srv-0 5b69bc7fea1b:8080 check weight 1 -backend letsencrypt_backend +backend certbot_backend mode http server certbot 127.0.0.1:2080 diff --git a/src/tests/expected/services-tcp.txt b/src/tests/expected/services-tcp.txt index fa8fd7a..2c19128 100644 --- a/src/tests/expected/services-tcp.txt +++ b/src/tests/expected/services-tcp.txt @@ -37,6 +37,6 @@ backend srv_agent_quantum_local_31339 tcp-check connect ssl server srv-0 test_agent:9001 check weight 1 verify none -backend letsencrypt_backend +backend certbot_backend mode http server certbot 127.0.0.1:2080 diff --git a/src/tests/expected/services.txt b/src/tests/expected/services.txt index bf2cecd..81a4c99 100644 --- a/src/tests/expected/services.txt +++ b/src/tests/expected/services.txt @@ -47,8 +47,8 @@ frontend http_in_31337 acl is_rule_node-exporter_quantum_example_org_31337_1 hdr(host) -i node-exporter.quantum.example.org acl is_rule_node-exporter_quantum_example_org_31337_2 hdr(host) -i node-exporter.quantum.example.org:31337 - acl is_letsencrypt_node-exporter_quantum_example_org_31337 path_beg /.well-known/acme-challenge/ - use_backend letsencrypt_backend if is_letsencrypt_node-exporter_quantum_example_org_31337 is_rule_node-exporter_quantum_example_org_31337_1 OR is_letsencrypt_node-exporter_quantum_example_org_31337 is_rule_node-exporter_quantum_example_org_31337_2 + acl is_certbot_node-exporter_quantum_example_org_31337 path_beg /.well-known/acme-challenge/ + use_backend certbot_backend if is_certbot_node-exporter_quantum_example_org_31337 is_rule_node-exporter_quantum_example_org_31337_1 OR is_certbot_node-exporter_quantum_example_org_31337 is_rule_node-exporter_quantum_example_org_31337_2 use_backend srv_node-exporter_quantum_example_org_31337 if is_rule_node-exporter_quantum_example_org_31337_1 OR is_rule_node-exporter_quantum_example_org_31337_2 backend srv_cadvisor_quantum_example_org_31337 @@ -67,7 +67,7 @@ backend srv_node-exporter_quantum_example_org_31337 server srv-0 my-stack_node-exporter:9100 check weight 1 frontend http_in_443 - bind *:443 ssl crt /certs/letsencrypt/ alpn http/1.1 crt /certs/haproxy/ alpn http/1.1 + bind *:443 ssl crt /certs/certbot/ alpn http/1.1 crt /certs/haproxy/ alpn http/1.1 mode http redirect prefix https://www.somehost.com.br code 301 if { hdr(host) -i somehost.com.br } redirect prefix https://www.somehost.com.br code 301 if { hdr(host) -i somehost.com } @@ -119,6 +119,6 @@ backend srv_www_somehost_com_br_80 http-request add-header X-Forwarded-Proto https if { ssl_fc } server srv-0 some-service:80 check weight 1 -backend letsencrypt_backend +backend certbot_backend mode http server certbot 127.0.0.1:2080 diff --git a/src/tests/expected/ssl-loose.txt b/src/tests/expected/ssl-loose.txt index 45ac853..e4a6322 100644 --- a/src/tests/expected/ssl-loose.txt +++ b/src/tests/expected/ssl-loose.txt @@ -33,6 +33,6 @@ backend srv_stats mode http server Local 127.0.0.1:1936 -backend letsencrypt_backend +backend certbot_backend mode http server certbot 127.0.0.1:2080 diff --git a/src/tests/expected/ssl-strict.txt b/src/tests/expected/ssl-strict.txt index f4e00b4..7c3306f 100644 --- a/src/tests/expected/ssl-strict.txt +++ b/src/tests/expected/ssl-strict.txt @@ -18,6 +18,6 @@ defaults timeout server 10m -backend letsencrypt_backend +backend certbot_backend mode http server certbot 127.0.0.1:2080 diff --git a/src/tests/expected/static.txt b/src/tests/expected/static.txt index ea0362f..c582286 100644 --- a/src/tests/expected/static.txt +++ b/src/tests/expected/static.txt @@ -50,8 +50,8 @@ frontend http_in_80 acl is_rule_host1_com_br_80_1 hdr(host) -i host1.com.br acl is_rule_host1_com_br_80_2 hdr(host) -i host1.com.br:80 - acl is_letsencrypt_host1_com_br_80 path_beg /.well-known/acme-challenge/ - use_backend letsencrypt_backend if is_letsencrypt_host1_com_br_80 is_rule_host1_com_br_80_1 OR is_letsencrypt_host1_com_br_80 is_rule_host1_com_br_80_2 + acl is_certbot_host1_com_br_80 path_beg /.well-known/acme-challenge/ + use_backend certbot_backend if is_certbot_host1_com_br_80 is_rule_host1_com_br_80_1 OR is_certbot_host1_com_br_80 is_rule_host1_com_br_80_2 use_backend srv_host1_com_br_80 if is_rule_host1_com_br_80_1 OR is_rule_host1_com_br_80_2 acl is_rule_host2_com_br_80_1 hdr(host) -i host2.com.br @@ -74,7 +74,7 @@ backend srv_host2_com_br_80 server srv-0 other:3000 check weight 1 frontend http_in_443 - bind *:443 ssl crt /certs/letsencrypt/ alpn http/1.1 crt /certs/haproxy/ alpn http/1.1 + bind *:443 ssl crt /certs/certbot/ alpn http/1.1 crt /certs/haproxy/ alpn http/1.1 mode http acl is_rule_host1_com_br_443_1 hdr(host) -i host1.com.br @@ -105,6 +105,6 @@ backend srv_host3_com_br_8080 http-request add-header X-Forwarded-Proto https if { ssl_fc } server srv-0 domain:8181 check weight 1 -backend letsencrypt_backend +backend certbot_backend mode http server certbot 127.0.0.1:2080 diff --git a/src/tests/fixtures/services b/src/tests/fixtures/services index 56ef60a..5c0aa68 100644 --- a/src/tests/fixtures/services +++ b/src/tests/fixtures/services @@ -1,6 +1,6 @@ {"portainer-agent_agent": {"com.docker.stack.image":"portainer/agent:1.5.1","com.docker.stack.namespace":"portainer-agent"}, "my-stack_agent": {"easyhaproxy.agent.host":"agent.quantum.example.org","easyhaproxy.agent.localport":"9001","easyhaproxy.agent.mode":"tcp","easyhaproxy.agent.port":"31339","com.docker.stack.image":"portainer/agent:1.5.1","com.docker.stack.namespace":"my-stack","com.planetary-quantum":"monitoring"}, "my-stack_cadvisor": {"easyhaproxy.cadvisor.host":"cadvisor.quantum.example.org","easyhaproxy.cadvisor.localport":"8080","easyhaproxy.cadvisor.port":"31337","com.docker.stack.image":"gcr.io/google-containers/cadvisor:v0.34.0","com.docker.stack.namespace":"my-stack","com.planetary-quantum":"monitoring"}, -"my-stack_node-exporter": {"easyhaproxy.exp.host":"node-exporter.quantum.example.org","easyhaproxy.exp.localport":"9100","easyhaproxy.exp.port":"31337","com.docker.stack.image":"stefanprodan/swarmprom-node-exporter:v0.16.0","com.docker.stack.namespace":"my-stack","com.planetary-quantum":"monitoring","easyhaproxy.exp.letsencrypt":"true"}, +"my-stack_node-exporter": {"easyhaproxy.exp.host":"node-exporter.quantum.example.org","easyhaproxy.exp.localport":"9100","easyhaproxy.exp.port":"31337","com.docker.stack.image":"stefanprodan/swarmprom-node-exporter:v0.16.0","com.docker.stack.namespace":"my-stack","com.planetary-quantum":"monitoring","easyhaproxy.exp.certbot":"true"}, "my-stack_reverse-proxy": {"com.docker.stack.image":"quay.io/pngmbh/easy-haproxy:tcp-mode","com.docker.stack.namespace":"my-stack","com.planetary-quantum":"monitoring"}, "some-service": {"easyhaproxy.http.port":"80","easyhaproxy.http.host":"www.somehost.com.br","easyhaproxy.http.localport":"80","easyhaproxy.http.redirect":"{\"somehost.com.br\":\"https://www.somehost.com.br\",\"somehost.com\":\"https://www.somehost.com.br\",\"www.somehost.com\":\"https://www.somehost.com.br\",\"byjg.ca\":\"https://www.somehost.com.br\",\"www.byjg.ca\":\"https://www.somehost.com.br\"}","easyhaproxy.https.port":"443","easyhaproxy.https.host":"www.somehost.com.br","easyhaproxy.https.localport":"80","easyhaproxy.https.redirect":"{\"somehost.com.br\":\"https://www.somehost.com.br\",\"somehost.com\":\"https://www.somehost.com.br\",\"www.somehost.com\":\"https://www.somehost.com.br\",\"byjg.ca\":\"https://www.somehost.com.br\",\"www.byjg.ca\":\"https://www.somehost.com.br\"}","easyhaproxy.https.sslcert":"U29tZSBQRU0gQ2VydGlmaWNhdGU="}} \ No newline at end of file diff --git a/src/tests/fixtures/services-changed-label b/src/tests/fixtures/services-changed-label index 2a23c24..af934de 100644 --- a/src/tests/fixtures/services-changed-label +++ b/src/tests/fixtures/services-changed-label @@ -1,6 +1,6 @@ {"portainer-agent_agent": {"com.docker.stack.image":"portainer/agent:1.5.1","com.docker.stack.namespace":"portainer-agent"}, "my-stack_agent": {"haproxy.agent.host":"agent.quantum.example.org","haproxy.agent.localport":"9001","haproxy.agent.mode":"tcp","haproxy.agent.port":"31339","com.docker.stack.image":"portainer/agent:1.5.1","com.docker.stack.namespace":"my-stack","com.planetary-quantum":"monitoring"}, "my-stack_cadvisor": {"haproxy.cadvisor.host":"cadvisor.quantum.example.org","haproxy.cadvisor.localport":"8080","haproxy.cadvisor.port":"31337","com.docker.stack.image":"gcr.io/google-containers/cadvisor:v0.34.0","com.docker.stack.namespace":"my-stack","com.planetary-quantum":"monitoring"}, -"my-stack_node-exporter": {"haproxy.exp.host":"node-exporter.quantum.example.org","haproxy.exp.localport":"9100","haproxy.exp.port":"31337","com.docker.stack.image":"stefanprodan/swarmprom-node-exporter:v0.16.0","com.docker.stack.namespace":"my-stack","com.planetary-quantum":"monitoring","haproxy.exp.letsencrypt":"yes"}, +"my-stack_node-exporter": {"haproxy.exp.host":"node-exporter.quantum.example.org","haproxy.exp.localport":"9100","haproxy.exp.port":"31337","com.docker.stack.image":"stefanprodan/swarmprom-node-exporter:v0.16.0","com.docker.stack.namespace":"my-stack","com.planetary-quantum":"monitoring","haproxy.exp.certbot":"yes"}, "my-stack_reverse-proxy": {"com.docker.stack.image":"quay.io/pngmbh/easy-haproxy:tcp-mode","com.docker.stack.namespace":"my-stack","com.planetary-quantum":"monitoring"}, "some-service": {"haproxy.http.port":"80","haproxy.http.host":"www.somehost.com.br","haproxy.http.localport":"80","haproxy.http.redirect":"{\"somehost.com.br\":\"https://www.somehost.com.br\",\"somehost.com\":\"https://www.somehost.com.br\",\"www.somehost.com\":\"https://www.somehost.com.br\",\"byjg.ca\":\"https://www.somehost.com.br\",\"www.byjg.ca\":\"https://www.somehost.com.br\"}","haproxy.https.port":"443","haproxy.https.host":"www.somehost.com.br","haproxy.https.localport":"80","haproxy.https.redirect":"{\"somehost.com.br\":\"https://www.somehost.com.br\",\"somehost.com\":\"https://www.somehost.com.br\",\"www.somehost.com\":\"https://www.somehost.com.br\",\"byjg.ca\":\"https://www.somehost.com.br\",\"www.byjg.ca\":\"https://www.somehost.com.br\"}","haproxy.https.sslcert":"U29tZSBQRU0gQ2VydGlmaWNhdGU="}} \ No newline at end of file diff --git a/src/tests/fixtures/services-letsencrypt b/src/tests/fixtures/services-letsencrypt index 9c83781..ca1d03a 100644 --- a/src/tests/fixtures/services-letsencrypt +++ b/src/tests/fixtures/services-letsencrypt @@ -1,4 +1,4 @@ -{"f5c645a0dfc6": {"com.docker.compose.config-hash":"b95ebc27d0e61caa418cdfa632e05a656da9bbc3ea0d4603651971015f10a1f0","com.docker.compose.container-number":"1","com.docker.compose.depends_on":"","com.docker.compose.image":"sha256:bea3509d6fdc8d7f9ec95563a5a226dc977ee74fb3e980e0de70e892c2d38dde","com.docker.compose.oneoff":"False","com.docker.compose.project":"docker","com.docker.compose.project.config_files":"/workspace/docker-easy-haproxy/examples/docker/docker-compose-test.yml","com.docker.compose.project.working_dir":"/workspace/docker-easy-haproxy/examples/docker","com.docker.compose.service":"nginx","com.docker.compose.version":"2.8.0","easyhaproxy.http.host":"test.example.org","easyhaproxy.http.letsencrypt":"true","easyhaproxy.http.localport":"80","easyhaproxy.http.port":"80","easyhaproxy.http.redirect":"{\"google.helloworld.com\": \"www.google.com\"}","easyhaproxy.http.redirect_ssl":"true"}, +{"f5c645a0dfc6": {"com.docker.compose.config-hash":"b95ebc27d0e61caa418cdfa632e05a656da9bbc3ea0d4603651971015f10a1f0","com.docker.compose.container-number":"1","com.docker.compose.depends_on":"","com.docker.compose.image":"sha256:bea3509d6fdc8d7f9ec95563a5a226dc977ee74fb3e980e0de70e892c2d38dde","com.docker.compose.oneoff":"False","com.docker.compose.project":"docker","com.docker.compose.project.config_files":"/workspace/docker-easy-haproxy/examples/docker/docker-compose-test.yml","com.docker.compose.project.working_dir":"/workspace/docker-easy-haproxy/examples/docker","com.docker.compose.service":"nginx","com.docker.compose.version":"2.8.0","easyhaproxy.http.host":"test.example.org","easyhaproxy.http.certbot":"true","easyhaproxy.http.localport":"80","easyhaproxy.http.port":"80","easyhaproxy.http.redirect":"{\"google.helloworld.com\": \"www.google.com\"}","easyhaproxy.http.redirect_ssl":"true"}, "bbd4d1854155": {"com.docker.compose.config-hash":"3dc790bf2bea944359c75a40c45655bd868f1d85beb599d1ca797e8ea2c95ee4","com.docker.compose.container-number":"1","com.docker.compose.depends_on":"","com.docker.compose.image":"sha256:0fd95b1512c207048ab3fcc74032354f38143fbb8235ac2a47da903c98a58205","com.docker.compose.oneoff":"False","com.docker.compose.project":"docker","com.docker.compose.project.config_files":"/workspace/docker-easy-haproxy/examples/docker/docker-compose-test.yml","com.docker.compose.project.working_dir":"/workspace/docker-easy-haproxy/examples/docker","com.docker.compose.service":"haproxy","com.docker.compose.version":"2.8.0"}, -"b63438410b6a": {"com.docker.compose.config-hash":"b95ebc27d0e61caa418cdfa632e05a656da9bbc3ea0d4603651971015f10a1f0","com.docker.compose.container-number":"2","com.docker.compose.depends_on":"","com.docker.compose.image":"sha256:bea3509d6fdc8d7f9ec95563a5a226dc977ee74fb3e980e0de70e892c2d38dde","com.docker.compose.oneoff":"False","com.docker.compose.project":"docker","com.docker.compose.project.config_files":"/workspace/docker-easy-haproxy/examples/docker/docker-compose-test.yml","com.docker.compose.project.working_dir":"/workspace/docker-easy-haproxy/examples/docker","com.docker.compose.service":"nginx","com.docker.compose.version":"2.8.0","easyhaproxy.http.host":"test.example.org","easyhaproxy.http.letsencrypt":"true","easyhaproxy.http.localport":"80","easyhaproxy.http.port":"80","easyhaproxy.http.redirect":"{\"google.helloworld.com\": \"www.google.com\"}","easyhaproxy.http.redirect_ssl":"true"}, +"b63438410b6a": {"com.docker.compose.config-hash":"b95ebc27d0e61caa418cdfa632e05a656da9bbc3ea0d4603651971015f10a1f0","com.docker.compose.container-number":"2","com.docker.compose.depends_on":"","com.docker.compose.image":"sha256:bea3509d6fdc8d7f9ec95563a5a226dc977ee74fb3e980e0de70e892c2d38dde","com.docker.compose.oneoff":"False","com.docker.compose.project":"docker","com.docker.compose.project.config_files":"/workspace/docker-easy-haproxy/examples/docker/docker-compose-test.yml","com.docker.compose.project.working_dir":"/workspace/docker-easy-haproxy/examples/docker","com.docker.compose.service":"nginx","com.docker.compose.version":"2.8.0","easyhaproxy.http.host":"test.example.org","easyhaproxy.http.certbot":"true","easyhaproxy.http.localport":"80","easyhaproxy.http.port":"80","easyhaproxy.http.redirect":"{\"google.helloworld.com\": \"www.google.com\"}","easyhaproxy.http.redirect_ssl":"true"}, "83d57d592e26": {"com.docker.compose.config-hash":"8c5871144f1e8a3aeca037207c02f011ab2c6e6c311a3773602b63541762dab5","com.docker.compose.container-number":"1","com.docker.compose.depends_on":"","com.docker.compose.image":"sha256:c4232396c715f3d568816c666e6d9b4a68ef6c36f6243b4007c4ee1d8335fd65","com.docker.compose.oneoff":"False","com.docker.compose.project":"docker","com.docker.compose.project.config_files":"/workspace/docker-easy-haproxy/examples/docker/docker-compose-test.yml","com.docker.compose.project.working_dir":"/workspace/docker-easy-haproxy/examples/docker","com.docker.compose.service":"static","com.docker.compose.version":"2.8.0","easyhaproxy.http.host":"test2.example.org","easyhaproxy.http.localport":"8080","easyhaproxy.http.port":"80","io.buildah.version":"1.21.0"}} \ No newline at end of file diff --git a/src/tests/fixtures/static.yml b/src/tests/fixtures/static.yml index c83d61c..53d3705 100644 --- a/src/tests/fixtures/static.yml +++ b/src/tests/fixtures/static.yml @@ -11,7 +11,7 @@ easymapping: host1.com.br: containers: - container:5000 - letsencrypt: true + certbot: true host2.com.br: containers: - other:3000 diff --git a/src/tests/test_containerenv.py b/src/tests/test_containerenv.py index c338871..b583198 100644 --- a/src/tests/test_containerenv.py +++ b/src/tests/test_containerenv.py @@ -87,32 +87,32 @@ def test_container_env_stats_password(): def test_container_env_stats_password(): - os.environ['EASYHAPROXY_LETSENCRYPT_EMAIL'] = 'acme@example.org' + os.environ['EASYHAPROXY_CERTBOT_EMAIL'] = 'acme@example.org' try: assert { "customerrors": False, "ssl_mode": "default", "lookup_label": "easyhaproxy", - "letsencrypt": { + "certbot": { "email": "acme@example.org", "server": False } } == ContainerEnv.read() finally: - os.environ['EASYHAPROXY_LETSENCRYPT_EMAIL'] = '' + os.environ['EASYHAPROXY_CERTBOT_EMAIL'] = '' -def test_container_env_letsencrypt(): - os.environ['EASYHAPROXY_LETSENCRYPT_EMAIL'] = 'acme@example.org' - os.environ['EASYHAPROXY_LETSENCRYPT_SERVER'] = 'true' +def test_container_env_certbot(): + os.environ['EASYHAPROXY_CERTBOT_EMAIL'] = 'acme@example.org' + os.environ['EASYHAPROXY_CERTBOT_SERVER'] = 'true' try: assert { "customerrors": False, "ssl_mode": "default", "lookup_label": "easyhaproxy", - "letsencrypt": { + "certbot": { "email": "acme@example.org", "server": True } } == ContainerEnv.read() finally: - os.environ['EASYHAPROXY_LETSENCRYPT_EMAIL'] = '' \ No newline at end of file + os.environ['EASYHAPROXY_CERTBOT_EMAIL'] = '' \ No newline at end of file diff --git a/src/tests/test_docker.py b/src/tests/test_docker.py index da0c60d..6aaaf61 100644 --- a/src/tests/test_docker.py +++ b/src/tests/test_docker.py @@ -46,7 +46,7 @@ def test_processor_docker(): "easyhaproxy.http2.port": "90", "easyhaproxy.http2.localport": "9000", "easyhaproxy.http2.host": "host2.local", - "easyhaproxy.http2.letsencrypt": "true", + "easyhaproxy.http2.certbot": "true", }) container2 = client.containers.run("byjg/static-httpserver", name="test2_processor_docker", @@ -62,10 +62,10 @@ def test_processor_docker(): try: time.sleep(1) - os.environ['EASYHAPROXY_LETSENCRYPT_EMAIL'] = 'docker@example.org' + os.environ['EASYHAPROXY_CERTBOT_EMAIL'] = 'docker@example.org' static = ProcessorInterface.factory("docker") - assert static.get_letsencrypt_hosts() is None + assert static.get_certbot_hosts() is None assert { 'easyhaproxy.http.host': 'host1.local', @@ -74,7 +74,7 @@ def test_processor_docker(): 'easyhaproxy.http2.host': 'host2.local', 'easyhaproxy.http2.localport': '9000', 'easyhaproxy.http2.port': '90', - 'easyhaproxy.http2.letsencrypt': 'true', + 'easyhaproxy.http2.certbot': 'true', } == _get_hydrated_object(static.get_parsed_object(), "easyhaproxy.http") assert { 'easyhaproxy.ssl.host': 'hostssl.local', @@ -90,7 +90,7 @@ def test_processor_docker(): assert haproxy_cfg == Functions.load(os.path.join(os.path.dirname(os.path.realpath(__file__)), "./expected/docker.txt")).replace("test_processor_docker", _get_ip_host( static.get_parsed_object(), "easyhaproxy.http")).replace("test2_processor_docker", _get_ip_host(static.get_parsed_object(), "easyhaproxy.ssl")) - assert static.get_letsencrypt_hosts() == ['host2.local'] + assert static.get_certbot_hosts() == ['host2.local'] assert static.get_hosts() == [ 'hostssl.local:443', 'host1.local:80', @@ -100,7 +100,7 @@ def test_processor_docker(): 'hostssl.local.pem': 'Some PEM Certificate' } finally: - os.environ['EASYHAPROXY_LETSENCRYPT_EMAIL'] = '' + os.environ['EASYHAPROXY_CERTBOT_EMAIL'] = '' container.stop() container2.stop() diff --git a/src/tests/test_parser.py b/src/tests/test_parser.py index a3d35f0..c5ef3df 100644 --- a/src/tests/test_parser.py +++ b/src/tests/test_parser.py @@ -6,7 +6,7 @@ import json CERTS_FOLDER="/tmp/certs" CERT_FILE="/tmp/certs/haproxy/www.somehost.com.br.pem" -LETSENCRYPT_EMAIL="some@email.com" +CERTBOT_EMAIL="some@email.com" def load_fixture(file): path = os.path.dirname(os.path.realpath(__file__)) @@ -33,15 +33,15 @@ def test_parser_doesnt_crash(): path = os.path.dirname(os.path.realpath(__file__)) with open(path + "/expected/no-services.txt", 'r') as expected_file: assert expected_file.read() == haproxy_config - assert [] == cfg.letsencrypt_hosts + assert [] == cfg.certbot_hosts def test_parser_finds_services(): line_list = load_fixture("services") result = { "customerrors": False, - "letsencrypt": { - "email": LETSENCRYPT_EMAIL + "certbot": { + "email": CERTBOT_EMAIL }, "stats": { "port": 0 @@ -58,7 +58,7 @@ def test_parser_finds_services(): assert {"www.somehost.com.br.pem":"Some PEM Certificate"} == cfg.certs - assert ['node-exporter.quantum.example.org'] == cfg.letsencrypt_hosts + assert ['node-exporter.quantum.example.org'] == cfg.certbot_hosts def test_parser_finds_services_changed_label(): line_list = load_fixture("services-changed-label") @@ -66,8 +66,8 @@ def test_parser_finds_services_changed_label(): result = { "customerrors": False, "lookup_label": "haproxy", - "letsencrypt": { - "email": LETSENCRYPT_EMAIL + "certbot": { + "email": CERTBOT_EMAIL }, "stats": { "port": 0 @@ -87,15 +87,15 @@ def test_parser_finds_services_changed_label(): assert {"www.somehost.com.br.pem":"Some PEM Certificate"} == cfg.certs - assert ['node-exporter.quantum.example.org'] == cfg.letsencrypt_hosts + assert ['node-exporter.quantum.example.org'] == cfg.certbot_hosts def test_parser_finds_services_raw(): line_list = load_fixture("services") result = { "customerrors": False, - "letsencrypt": { - "email": LETSENCRYPT_EMAIL + "certbot": { + "email": CERTBOT_EMAIL }, "stats": { "port": 0 @@ -117,7 +117,7 @@ def test_parser_finds_services_raw(): "containers": [ "my-stack_agent:9001" ], - "letsencrypt": False, + "certbot": False, "redirect_ssl": False } }, @@ -134,14 +134,14 @@ def test_parser_finds_services_raw(): "containers": [ "my-stack_cadvisor:8080" ], - "letsencrypt": False, + "certbot": False, "redirect_ssl": False }, "node-exporter.quantum.example.org":{ "containers": [ "my-stack_node-exporter:9100" ], - "letsencrypt": True, + "certbot": True, "redirect_ssl": False } }, @@ -158,14 +158,14 @@ def test_parser_finds_services_raw(): "containers": [ "my-stack_node-exporter:9100" ], - "letsencrypt": False, + "certbot": False, "redirect_ssl": False }, "www.somehost.com.br":{ "containers": [ "some-service:80" ], - "letsencrypt": False, + "certbot": False, "redirect_ssl": False } }, @@ -187,7 +187,7 @@ def test_parser_finds_services_raw(): "containers": [ "some-service:80" ], - "letsencrypt": False, + "certbot": False, "redirect_ssl": False } }, @@ -204,7 +204,7 @@ def test_parser_finds_services_raw(): processed = list(cfg.parse(line_list)) assert parsed_object == processed - assert ['node-exporter.quantum.example.org'] == cfg.letsencrypt_hosts + assert ['node-exporter.quantum.example.org'] == cfg.certbot_hosts @@ -219,7 +219,7 @@ def test_parser_static(): with open(path + "/expected/static.txt", 'r') as expected_file: assert expected_file.read() == haproxy_config - assert [] == cfg.letsencrypt_hosts + assert [] == cfg.certbot_hosts def test_parser_static_raw(): path = os.path.dirname(os.path.realpath(__file__)) @@ -241,7 +241,7 @@ def test_parser_static_raw(): "containers": [ "container:5000" ], - "letsencrypt": True + "certbot": True }, "host2.com.br": { "containers": [ @@ -299,7 +299,7 @@ def test_parser_tcp(): path = os.path.dirname(os.path.realpath(__file__)) with open(path + "/expected/services-tcp.txt", 'r') as expected_file: assert expected_file.read() == haproxy_config - assert [] == cfg.letsencrypt_hosts + assert [] == cfg.certbot_hosts def test_parser_multi_containers(): line_list = load_fixture("services-multi-containers") @@ -318,7 +318,7 @@ def test_parser_multi_containers(): path = os.path.dirname(os.path.realpath(__file__)) with open(path + "/expected/services-multi-containers.txt", 'r') as expected_file: assert expected_file.read() == haproxy_config - assert [] == cfg.letsencrypt_hosts + assert [] == cfg.certbot_hosts def test_parser_multiple_hosts(): @@ -340,7 +340,7 @@ def test_parser_multiple_hosts(): path = os.path.dirname(os.path.realpath(__file__)) with open(path + "/expected/services-multiple-hosts.txt", 'r') as expected_file: assert expected_file.read() == haproxy_config - assert [] == cfg.letsencrypt_hosts + assert [] == cfg.certbot_hosts def test_parser_redirect_ssl(): @@ -361,7 +361,7 @@ def test_parser_redirect_ssl(): path = os.path.dirname(os.path.realpath(__file__)) with open(path + "/expected/services-redirect-ssl.txt", 'r') as expected_file: assert expected_file.read() == haproxy_config - assert [] == cfg.letsencrypt_hosts + assert [] == cfg.certbot_hosts def test_parser_ssl_strict(): @@ -382,7 +382,7 @@ def test_parser_ssl_strict(): path = os.path.dirname(os.path.realpath(__file__)) with open(path + "/expected/ssl-strict.txt", 'r') as expected_file: assert expected_file.read() == haproxy_config - assert [] == cfg.letsencrypt_hosts + assert [] == cfg.certbot_hosts def test_parser_ssl_loose(): line_list = load_fixture("no-services") @@ -399,7 +399,7 @@ def test_parser_ssl_loose(): path = os.path.dirname(os.path.realpath(__file__)) with open(path + "/expected/ssl-loose.txt", 'r') as expected_file: assert expected_file.read() == haproxy_config - assert [] == cfg.letsencrypt_hosts + assert [] == cfg.certbot_hosts def test_parser_ssl_letsencrypt(): line_list = load_fixture("services-letsencrypt") @@ -409,8 +409,8 @@ def test_parser_ssl_letsencrypt(): "stats": { "password": "password" }, - "letsencrypt": { - "email": LETSENCRYPT_EMAIL + "certbot": { + "email": CERTBOT_EMAIL } } @@ -421,7 +421,7 @@ def test_parser_ssl_letsencrypt(): path = os.path.dirname(os.path.realpath(__file__)) with open(path + "/expected/services-letsencrypt.txt", 'r') as expected_file: assert expected_file.read() == haproxy_config - assert ["test.example.org"] == cfg.letsencrypt_hosts + assert ["test.example.org"] == cfg.certbot_hosts def test_parser_finds_services_clone_to_ssl_raw(): @@ -429,8 +429,8 @@ def test_parser_finds_services_clone_to_ssl_raw(): result = { "customerrors": False, - "letsencrypt": { - "email": LETSENCRYPT_EMAIL + "certbot": { + "email": CERTBOT_EMAIL }, "stats": { "port": 0 @@ -450,21 +450,21 @@ def test_parser_finds_services_clone_to_ssl_raw(): "containers":[ "10.152.183.215:8080" ], - "letsencrypt": False, + "certbot": False, "redirect_ssl": False }, "valida.me":{ "containers":[ "10.152.183.62:8080" ], - "letsencrypt": False, + "certbot": False, "redirect_ssl": False }, "www.valida.me":{ "containers":[ "10.152.183.62:8080" ], - "letsencrypt": False, + "certbot": False, "redirect_ssl": False } }, @@ -481,7 +481,7 @@ def test_parser_finds_services_clone_to_ssl_raw(): "containers":[ "10.152.183.215:8080" ], - "letsencrypt": False, + "certbot": False, "redirect_ssl": False } }, @@ -496,12 +496,12 @@ def test_parser_finds_services_clone_to_ssl_raw(): processed = list(cfg.parse(line_list)) assert parsed_object == processed - assert [] == cfg.letsencrypt_hosts + assert [] == cfg.certbot_hosts #test_parser_finds_services_raw() #test_parser_tcp() #test_parser_multiple_hosts() -#test_parser_ssl_letsencrypt() +#test_parser_ssl_certbot() #test_parser_finds_services() \ No newline at end of file diff --git a/src/tests/test_static.py b/src/tests/test_static.py index c21593c..f8800ac 100644 --- a/src/tests/test_static.py +++ b/src/tests/test_static.py @@ -15,7 +15,7 @@ def test_processor_static(): "containers":[ "container:5000" ], - "letsencrypt": True + "certbot": True }, "host2.com.br":{ "containers":[ @@ -57,7 +57,7 @@ def test_processor_static(): 'host3.com.br:8080' ] - assert static.get_letsencrypt_hosts() is None + assert static.get_certbot_hosts() is None assert static.get_parsed_object() == parsed_object assert static.get_hosts() == hosts @@ -66,7 +66,7 @@ def test_processor_static(): assert haproxy_cfg == Functions.load(os.path.join(os.path.dirname(os.path.realpath(__file__)), "./expected/static.txt")) # @todo: Static doesnt populate this fields - assert static.get_letsencrypt_hosts() == [] + assert static.get_certbot_hosts() == [] assert static.get_parsed_object() == parsed_object assert static.get_hosts() == hosts From 078cb31eb7d38aa7969c746a3dfa651c76612077 Mon Sep 17 00:00:00 2001 From: Joao Gilberto Magalhaes Date: Sat, 1 Jul 2023 17:26:46 -0500 Subject: [PATCH 20/33] Add parameter eab_kid and eab_hmac_key --- docs/docker-environment.md | 32 +++++++++++----------- src/easymapping/__init__.py | 2 +- src/functions/__init__.py | 41 ++++++++++++++++++++-------- src/main.py | 2 +- src/processor/__init__.py | 14 +++++----- src/tests/test_containerenv.py | 49 +++++++++++++++++++++++++++------- 6 files changed, 96 insertions(+), 44 deletions(-) diff --git a/docs/docker-environment.md b/docs/docker-environment.md index b78e829..54cb3e9 100644 --- a/docs/docker-environment.md +++ b/docs/docker-environment.md @@ -1,20 +1,22 @@ # Docker environment variables -| Environment Variable | Description | Default | -|------------------------------|------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------|---------------| -| EASYHAPROXY_DISCOVER | How the services will be discovered to create `haproxy.cfg`: `static`, `docker`, `swarm` or `kubernetes` | **required** | -| EASYHAPROXY_LABEL_PREFIX | (Optional) The key will search for matching resources. | `easyhaproxy` | -| EASYHAPROXY_CERTBOT_EMAIL | (Optional) The email will be used to request the certificate to Certbox | *empty* | -| EASYHAPROXY_CERTBOT_SERVER | (Optional) Can be `staging` or 'schema://domain.tld'. If set, will try to connect to the Certbot test server | *empty* | -| EASYHAPROXY_SSL_MODE | (Optional) `strict` supports only the most recent TLS version; `default` good SSL integration with recent browsers; `loose` supports all old SSL protocols for old browsers (not recommended). | `default` | -| EASYHAPROXY_REFRESH_CONF | (Optional) Check configuration every N seconds. | 10 | -| EASYHAPROXY_LOG_LEVEL | (Optional) The log level for EasyHAproxy messages. Available: TRACE,DEBUG,INFO,WARN,ERROR,FATAL | DEBUG | -| CERTBOT_LOG_LEVEL | (Optional) The log level for Certbot messages. Available: TRACE,DEBUG,INFO,WARN,ERROR,FATAL | DEBUG | -| HAPROXY_LOG_LEVEL | (Optional) The log level for HAProxy messages. Available: TRACE,DEBUG,INFO,WARN,ERROR,FATAL | DEBUG | -| HAPROXY_USERNAME | (Optional) The HAProxy username to the statistics. | `admin` | -| HAPROXY_PASSWORD | (Optional) The HAProxy password to the statistics. If not set, statistics will be available with no password | *empty* | -| HAPROXY_STATS_PORT | (Optional) The HAProxy port to the statistics. If set to `false`, disable statistics | `1936` | -| HAPROXY_CUSTOMERRORS | (Optional) If HAProxy will use custom HTML errors. true/false. | `false` | +| Environment Variable | Description | Default | +|----------------------------------|------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------|---------------| +| EASYHAPROXY_DISCOVER | How the services will be discovered to create `haproxy.cfg`: `static`, `docker`, `swarm` or `kubernetes` | **required** | +| EASYHAPROXY_LABEL_PREFIX | (Optional) The key will search for matching resources. | `easyhaproxy` | +| EASYHAPROXY_CERTBOT_EMAIL | (Optional) The email will be used to request the certificate to Certbot | *empty* | +| EASYHAPROXY_CERTBOT_SERVER | (Optional) Can be `staging` or 'schema://domain.tld', if using other service than Letsencrypt. Might be necessary set EASYHAPROXY_CERTBOT_EAB_KID and EASYHAPROXY_CERTBOT_EAB_HMAC_KEY | *empty* | +| EASYHAPROXY_CERTBOT_EAB_KID | (Optional) eab-kid configuration when required. | *empty* | +| EASYHAPROXY_CERTBOT_EAB_HMAC_KEY | (Optional) eab-kid-hmac-key configuration when required. | *empty* | +| EASYHAPROXY_SSL_MODE | (Optional) `strict` supports only the most recent TLS version; `default` good SSL integration with recent browsers; `loose` supports all old SSL protocols for old browsers (not recommended). | `default` | +| EASYHAPROXY_REFRESH_CONF | (Optional) Check configuration every N seconds. | 10 | +| EASYHAPROXY_LOG_LEVEL | (Optional) The log level for EasyHAproxy messages. Available: TRACE,DEBUG,INFO,WARN,ERROR,FATAL | DEBUG | +| CERTBOT_LOG_LEVEL | (Optional) The log level for Certbot messages. Available: TRACE,DEBUG,INFO,WARN,ERROR,FATAL | DEBUG | +| HAPROXY_LOG_LEVEL | (Optional) The log level for HAProxy messages. Available: TRACE,DEBUG,INFO,WARN,ERROR,FATAL | DEBUG | +| HAPROXY_USERNAME | (Optional) The HAProxy username to the statistics. | `admin` | +| HAPROXY_PASSWORD | (Optional) The HAProxy password to the statistics. If not set, statistics will be available with no password | *empty* | +| HAPROXY_STATS_PORT | (Optional) The HAProxy port to the statistics. If set to `false`, disable statistics | `1936` | +| HAPROXY_CUSTOMERRORS | (Optional) If HAProxy will use custom HTML errors. true/false. | `false` | diff --git a/src/easymapping/__init__.py b/src/easymapping/__init__.py index e17dcf5..c0db5f7 100644 --- a/src/easymapping/__init__.py +++ b/src/easymapping/__init__.py @@ -49,7 +49,7 @@ class HaproxyConfigGenerator: def __init__(self, mapping): self.mapping = mapping self.mapping.setdefault("ssl_mode", 'default') - self.mapping.setdefault("certbot", {"email": "", "staging": False}) + self.mapping.setdefault("certbot", {"email": "", "server": False, "eab_kid": False, "eab_hmac_key": False}) self.mapping["ssl_mode"] = self.mapping["ssl_mode"].lower() self.label = DockerLabelHandler(mapping['lookup_label'] if 'lookup_label' in mapping else "easyhaproxy") self.certbot_hosts = [] diff --git a/src/functions/__init__.py b/src/functions/__init__.py index 09b37d3..9713843 100644 --- a/src/functions/__init__.py +++ b/src/functions/__init__.py @@ -179,16 +179,32 @@ class DaemonizeHAProxy: class Certbot: - def __init__(self, certs, email, test_server): - self.certs = certs - self.email = email - self.test_server = self.set_test_server(test_server) + def __init__(self, certs): + env = ContainerEnv.read() - def set_test_server(self, test_server): - if test_server.lower() == "staging": + self.certs = certs + self.email = env["certbot"]["email"] + self.acme_server = self.set_acme_server(env["certbot"]["server"]) + self.eab_kid = self.set_eab_kid(env["certbot"]["eab_kid"]) + self.eab_hmac_key = self.set_eab_hmac_key(env["certbot"]["eab_hmac_key"]) + + def set_acme_server(self, acme_server): + if acme_server.lower() == "staging": return "--staging" - elif test_server.lower().startswith("http"): - return "--server " + test_server + elif acme_server.lower().startswith("http"): + return "--server " + acme_server + else: + return "" + + def set_eab_kid(self, eab_kid): + if eab_kid != "": + return "--eab-kid \"%s\"" % eab_kid + else: + return "" + + def set_eab_hmac_key(self, eab_hmac_key): + if eab_hmac_key != "": + return "--eab-hmac-key \"%s\"" % eab_hmac_key else: return "" @@ -224,7 +240,7 @@ class Certbot: except Exception as e: Functions.log(Functions.CERTBOT_LOG, Functions.ERROR, "Certificate %s error %s" % (host, e)) - certbot_certonly = ('/usr/bin/certbot certonly {test_server}' + certbot_certonly = ('/usr/bin/certbot certonly {acme_server}' ' --standalone' ' --preferred-challenges http' ' --http-01-port 2080' @@ -233,9 +249,12 @@ class Certbot: ' --no-eff-email' ' --non-interactive' ' --max-log-backups=0' - ' {certs} --email {email}'.format(certs=' '.join(request_certs), + ' {eab_kid} {eab_hmac_key}' + ' {certs} --email {email}'.format(eab_kid=self.eab_kid, + eab_hmac_key=self.eab_hmac_key, + certs=' '.join(request_certs), email=self.email, - test_server=self.test_server) + acme_server=self.acme_server) ) ret_reload = False diff --git a/src/main.py b/src/main.py index d251843..c50e0d2 100644 --- a/src/main.py +++ b/src/main.py @@ -22,7 +22,7 @@ def start(): haproxy.haproxy("start") haproxy.sleep() - certbot = Certbot(Consts.certs_certbot, os.getenv("EASYHAPROXY_CERTBOT_EMAIL"), os.getenv("EASYHAPROXY_CERTBOT_SERVER", "").lower()) + certbot = Certbot(Consts.certs_certbot) while True: if old_haproxy is not None: diff --git a/src/processor/__init__.py b/src/processor/__init__.py index 96e347d..9583c32 100644 --- a/src/processor/__init__.py +++ b/src/processor/__init__.py @@ -26,12 +26,14 @@ class ContainerEnv: } env_vars["lookup_label"] = os.getenv("EASYHAPROXY_LABEL_PREFIX") if os.getenv("EASYHAPROXY_LABEL_PREFIX") else "easyhaproxy" - if (os.getenv("EASYHAPROXY_CERTBOT_EMAIL")): - env_vars["certbot"] = { - "email": os.getenv("EASYHAPROXY_CERTBOT_EMAIL"), - "server": os.getenv("EASYHAPROXY_CERTBOT_SERVER", "false").lower() in ["true", "1", "yes"] - } - + + env_vars["certbot"] = { + "email": os.getenv("EASYHAPROXY_CERTBOT_EMAIL", ""), + "server": os.getenv("EASYHAPROXY_CERTBOT_SERVER", False), + "eab_kid": os.getenv("EASYHAPROXY_CERTBOT_EAB_KID", ""), + "eab_hmac_key": os.getenv("EASYHAPROXY_CERTBOT_EAB_HMAC_KEY", ""), + } + return env_vars diff --git a/src/tests/test_containerenv.py b/src/tests/test_containerenv.py index b583198..4b84810 100644 --- a/src/tests/test_containerenv.py +++ b/src/tests/test_containerenv.py @@ -6,7 +6,11 @@ def test_container_env_empty(): assert { "customerrors": False, "ssl_mode": "default", - "lookup_label": "easyhaproxy" + "lookup_label": "easyhaproxy", + "certbot": {"eab_hmac_key": "", + "eab_kid": "", + "email": "", + "server": False} } == ContainerEnv.read() # os.environ['CERTBOT_LOG_LEVEL'] = 'warn' @@ -17,7 +21,11 @@ def test_container_env_customerrors(): assert { "customerrors": True, "ssl_mode": "default", - "lookup_label": "easyhaproxy" + "lookup_label": "easyhaproxy", + "certbot": {"eab_hmac_key": "", + "eab_kid": "", + "email": "", + "server": False} } == ContainerEnv.read() finally: os.environ['HAPROXY_CUSTOMERRORS'] = '' @@ -28,7 +36,11 @@ def test_container_env_sslmode(): assert { "customerrors": False, "ssl_mode": "strict", - "lookup_label": "easyhaproxy" + "lookup_label": "easyhaproxy", + "certbot": {"eab_hmac_key": "", + "eab_kid": "", + "email": "", + "server": False} } == ContainerEnv.read() finally: os.environ['EASYHAPROXY_SSL_MODE'] = '' @@ -41,6 +53,10 @@ def test_container_env_stats(): "customerrors": False, "ssl_mode": "default", "lookup_label": "easyhaproxy", + "certbot": {"eab_hmac_key": "", + "eab_kid": "", + "email": "", + "server": False} } == ContainerEnv.read() finally: os.environ['HAPROXY_USERNAME'] = '' @@ -58,7 +74,11 @@ def test_container_env_stats_password(): "password": "xyz", "port": "1936" - } + }, + "certbot": {"eab_hmac_key": "", + "eab_kid": "", + "email": "", + "server": False} } == ContainerEnv.read() finally: os.environ['HAPROXY_PASSWORD'] = '' @@ -77,8 +97,11 @@ def test_container_env_stats_password(): "username": "abc", "password": "xyz", "port": "2101" - - } + }, + "certbot": {"eab_hmac_key": "", + "eab_kid": "", + "email": "", + "server": False} } == ContainerEnv.read() finally: os.environ['HAPROXY_USERNAME'] = '' @@ -86,7 +109,7 @@ def test_container_env_stats_password(): os.environ['HAPROXY_PASSWORD'] = '' -def test_container_env_stats_password(): +def test_container_env_certbot_email(): os.environ['EASYHAPROXY_CERTBOT_EMAIL'] = 'acme@example.org' try: assert { @@ -94,6 +117,8 @@ def test_container_env_stats_password(): "ssl_mode": "default", "lookup_label": "easyhaproxy", "certbot": { + 'eab_hmac_key': "", + 'eab_kid': "", "email": "acme@example.org", "server": False } @@ -101,9 +126,11 @@ def test_container_env_stats_password(): finally: os.environ['EASYHAPROXY_CERTBOT_EMAIL'] = '' -def test_container_env_certbot(): +def test_container_env_certbot_full(): os.environ['EASYHAPROXY_CERTBOT_EMAIL'] = 'acme@example.org' - os.environ['EASYHAPROXY_CERTBOT_SERVER'] = 'true' + os.environ['EASYHAPROXY_CERTBOT_SERVER'] = 'schema://url/a' + os.environ['EASYHAPROXY_CERTBOT_EAB_KID'] = 'eab_kid' + os.environ['EASYHAPROXY_CERTBOT_EAB_HMAC_KEY'] = 'eab_hmac_key' try: assert { "customerrors": False, @@ -111,7 +138,9 @@ def test_container_env_certbot(): "lookup_label": "easyhaproxy", "certbot": { "email": "acme@example.org", - "server": True + "server": "schema://url/a", + 'eab_hmac_key': 'eab_hmac_key', + 'eab_kid': 'eab_kid', } } == ContainerEnv.read() finally: From 3b2e9a43fdbd12bcedcb11113325f3eae96fd91e Mon Sep 17 00:00:00 2001 From: Joao Gilberto Magalhaes Date: Sat, 1 Jul 2023 17:55:25 -0500 Subject: [PATCH 21/33] Fixing errors / reformating code --- src/easymapping/__init__.py | 27 +++--- src/functions/__init__.py | 59 +++++++++--- src/main.py | 24 +++-- src/processor/__init__.py | 148 ++++++++++++++-------------- src/tests/context.py | 4 +- src/tests/test_containerenv.py | 170 +++++++++++++++++---------------- src/tests/test_docker.py | 6 +- src/tests/test_functions.py | 25 +++-- src/tests/test_labels.py | 11 ++- src/tests/test_parser.py | 161 ++++++++++++++++--------------- src/tests/test_static.py | 49 +++++----- 11 files changed, 368 insertions(+), 316 deletions(-) diff --git a/src/easymapping/__init__.py b/src/easymapping/__init__.py index c0db5f7..34fa05c 100644 --- a/src/easymapping/__init__.py +++ b/src/easymapping/__init__.py @@ -1,12 +1,13 @@ import base64 -import hashlib -from jinja2 import Environment, FileSystemLoader import json -import os import re +from jinja2 import Environment, FileSystemLoader + + class DockerLabelHandler: def __init__(self, label): + self.__data = None self.__label_base = label def get_lookup_label(self): @@ -18,19 +19,17 @@ class DockerLabelHandler: return "{}.{}".format(self.__label_base, ".".join(key)) - - def get(self, label, default_value = ""): + def get(self, label, default_value=""): if self.has_label(label): return self.__data[label] return default_value - - def get_bool(self, label, default_value = False): + def get_bool(self, label, default_value=False): if self.has_label(label): return self.__data[label].lower() in ["true", "1", "yes"] return default_value - def get_json(self, label, default_value = {}): + def get_json(self, label, default_value={}): if self.has_label(label): return json.loads(self.__data[label]) return default_value @@ -38,7 +37,6 @@ class DockerLabelHandler: def set_data(self, data): self.__data = data - def has_label(self, label): if label in self.__data: return True @@ -55,10 +53,10 @@ class HaproxyConfigGenerator: self.certbot_hosts = [] self.serving_hosts = [] self.certs = {} - - def generate(self, container_metadata = {}): + + def generate(self, container_metadata={}): self.mapping.setdefault("easymapping", []) - + if container_metadata != {}: self.mapping["easymapping"] = self.parse(container_metadata) @@ -70,7 +68,6 @@ class HaproxyConfigGenerator: template = env.get_template('haproxy.cfg.j2') return template.render(data=self.mapping) - def parse(self, container_metadata): easymapping = dict() @@ -163,8 +160,8 @@ class HaproxyConfigGenerator: easymapping["443"]["hosts"][hostname]["certbot"] = False easymapping["443"]["hosts"][hostname]["redirect_ssl"] = False easymapping["443"]["ssl"] = True - self.certbot_hosts.append(hostname) if certbot and hostname not in self.certbot_hosts else self.certbot_hosts - + self.certbot_hosts.append( + hostname) if certbot and hostname not in self.certbot_hosts else self.certbot_hosts # handle SSL ssl_label = self.label.create([definition, "sslcert"]) diff --git a/src/functions/__init__.py b/src/functions/__init__.py index 9713843..e704cb7 100644 --- a/src/functions/__init__.py +++ b/src/functions/__init__.py @@ -1,14 +1,41 @@ -from datetime import datetime -from multiprocessing import Process, Lock -import subprocess -import shlex -import time import os -import re +import shlex +import subprocess import time +from datetime import datetime +from multiprocessing import Process + from OpenSSL import crypto +class ContainerEnv: + @staticmethod + def read(): + env_vars = { + "customerrors": True if os.getenv("HAPROXY_CUSTOMERRORS") == "true" else False, + "ssl_mode": os.getenv("EASYHAPROXY_SSL_MODE").lower() if os.getenv("EASYHAPROXY_SSL_MODE") else 'default' + } + + if os.getenv("HAPROXY_PASSWORD"): + env_vars["stats"] = { + "username": os.getenv("HAPROXY_USERNAME") if os.getenv("HAPROXY_USERNAME") else "admin", + "password": os.getenv("HAPROXY_PASSWORD"), + "port": os.getenv("HAPROXY_STATS_PORT") if os.getenv("HAPROXY_STATS_PORT") else "1936", + } + + env_vars["lookup_label"] = os.getenv("EASYHAPROXY_LABEL_PREFIX") if os.getenv( + "EASYHAPROXY_LABEL_PREFIX") else "easyhaproxy" + + env_vars["certbot"] = { + "email": os.getenv("EASYHAPROXY_CERTBOT_EMAIL", ""), + "server": os.getenv("EASYHAPROXY_CERTBOT_SERVER", False), + "eab_kid": os.getenv("EASYHAPROXY_CERTBOT_EAB_KID", ""), + "eab_hmac_key": os.getenv("EASYHAPROXY_CERTBOT_EAB_HMAC_KEY", ""), + } + + return env_vars + + class Functions: HAPROXY_LOG = "HAPROXY" EASYHAPROXY_LOG = "EASYHAPROXY" @@ -119,8 +146,8 @@ class DaemonizeHAProxy: else: pid = "".join(Functions().run_bash(Functions.HAPROXY_LOG, "cat /run/haproxy.pid", log_output=False)) self.__prepare( - "/usr/sbin/haproxy -W -f /etc/haproxy/haproxy.cfg -p /run/haproxy.pid -x /var/run/haproxy.sock -sf %s" % ( - pid)) + "/usr/sbin/haproxy -W -f /etc/haproxy/haproxy.cfg -p /run/haproxy.pid -x /var/run/haproxy.sock -sf %s" % + pid) if self.process is None: return @@ -151,8 +178,8 @@ class DaemonizeHAProxy: for line in iter(self.process.stdout.readline, b''): Functions.log(source, Functions.INFO, line) - returncode = self.process.wait() - Functions.log(source, Functions.DEBUG, "Return code %s" % (returncode)) + return_code = self.process.wait() + Functions.log(source, Functions.DEBUG, "Return code %s" % return_code) except Exception as e: Functions.log(source, Functions.ERROR, "%s" % e) @@ -188,7 +215,8 @@ class Certbot: self.eab_kid = self.set_eab_kid(env["certbot"]["eab_kid"]) self.eab_hmac_key = self.set_eab_hmac_key(env["certbot"]["eab_hmac_key"]) - def set_acme_server(self, acme_server): + @staticmethod + def set_acme_server(acme_server): if acme_server.lower() == "staging": return "--staging" elif acme_server.lower().startswith("http"): @@ -196,13 +224,15 @@ class Certbot: else: return "" - def set_eab_kid(self, eab_kid): + @staticmethod + def set_eab_kid(eab_kid): if eab_kid != "": return "--eab-kid \"%s\"" % eab_kid else: return "" - def set_eab_hmac_key(self, eab_hmac_key): + @staticmethod + def set_eab_hmac_key(eab_hmac_key): if eab_hmac_key != "": return "--eab-hmac-key \"%s\"" % eab_hmac_key else: @@ -274,7 +304,8 @@ class Certbot: Functions.log(Functions.CERTBOT_LOG, Functions.ERROR, "%s" % e) return False - def merge_certificate(self, cert, key, filename): + @staticmethod + def merge_certificate(cert, key, filename): Functions.save(filename, cert + key) def find_live_certificates(self): diff --git a/src/main.py b/src/main.py index c50e0d2..d55c148 100644 --- a/src/main.py +++ b/src/main.py @@ -1,7 +1,10 @@ +import os + +from deepdiff import DeepDiff + from functions import Functions, DaemonizeHAProxy, Certbot, Consts from processor import ProcessorInterface -import os -from deepdiff import DeepDiff + def start(): processor_obj = ProcessorInterface.factory(os.getenv("EASYHAPROXY_DISCOVER")) @@ -14,7 +17,8 @@ def start(): processor_obj.save_config(Consts.haproxy_config) processor_obj.save_certs(Consts.certs_haproxy) certbot_certs_found = processor_obj.get_certbot_hosts() - Functions.log(Functions.EASYHAPROXY_LOG, Functions.DEBUG, 'Found hosts: %s' % ", ".join(processor_obj.get_hosts())) # Needs to run after save_config + Functions.log(Functions.EASYHAPROXY_LOG, Functions.DEBUG, + 'Found hosts: %s' % ", ".join(processor_obj.get_hosts())) # Needs to run after save_config Functions.log(Functions.EASYHAPROXY_LOG, Functions.TRACE, 'Object Found: %s' % (processor_obj.get_parsed_object())) old_haproxy = None @@ -31,27 +35,28 @@ def start(): try: old_parsed = processor_obj.get_parsed_object() processor_obj.refresh() - if certbot.check_certificates(certbot_certs_found) or DeepDiff(old_parsed, processor_obj.get_parsed_object()) != {} or not haproxy.is_alive(): + if certbot.check_certificates(certbot_certs_found) or DeepDiff(old_parsed, + processor_obj.get_parsed_object()) != {} or not haproxy.is_alive(): Functions.log(Functions.EASYHAPROXY_LOG, Functions.DEBUG, 'New configuration found. Reloading...') - Functions.log(Functions.EASYHAPROXY_LOG, Functions.TRACE, 'Object Found: %s' % (processor_obj.get_parsed_object())) + Functions.log(Functions.EASYHAPROXY_LOG, Functions.TRACE, + 'Object Found: %s' % (processor_obj.get_parsed_object())) processor_obj.save_config(Consts.haproxy_config) processor_obj.save_certs(Consts.certs_haproxy) certbot_certs_found = processor_obj.get_certbot_hosts() - Functions.log(Functions.EASYHAPROXY_LOG, Functions.DEBUG, 'Found hosts: %s' % ", ".join(processor_obj.get_hosts())) # Needs to after save_config + Functions.log(Functions.EASYHAPROXY_LOG, Functions.DEBUG, + 'Found hosts: %s' % ", ".join(processor_obj.get_hosts())) # Needs to after save_config old_haproxy = haproxy haproxy = DaemonizeHAProxy() haproxy.haproxy("reload") old_haproxy.terminate() except Exception as e: - Functions.log(Functions.EASYHAPROXY_LOG, Functions.FATAL, "Err: %s" % (e)) + Functions.log(Functions.EASYHAPROXY_LOG, Functions.FATAL, "Err: %s" % e) Functions.log(Functions.EASYHAPROXY_LOG, Functions.DEBUG, 'Heartbeat') haproxy.sleep() - - def main(): Functions.run_bash(Functions.INIT_LOG, '/usr/sbin/haproxy -v') @@ -69,5 +74,6 @@ def main(): start() + if __name__ == '__main__': main() diff --git a/src/processor/__init__.py b/src/processor/__init__.py index 9583c32..68d55c6 100644 --- a/src/processor/__init__.py +++ b/src/processor/__init__.py @@ -1,46 +1,26 @@ -from easymapping import HaproxyConfigGenerator -from functions import Functions, Consts -import yaml -import sys -import os -import json import base64 -import docker import socket + +import docker +import yaml from kubernetes import client, config from kubernetes.client.rest import ApiException -class ContainerEnv: - @staticmethod - def read(): - env_vars = { - "customerrors": True if os.getenv("HAPROXY_CUSTOMERRORS") == "true" else False, - "ssl_mode": os.getenv("EASYHAPROXY_SSL_MODE").lower() if os.getenv("EASYHAPROXY_SSL_MODE") else 'default' - } - - if os.getenv("HAPROXY_PASSWORD"): - env_vars["stats"] = { - "username": os.getenv("HAPROXY_USERNAME") if os.getenv("HAPROXY_USERNAME") else "admin", - "password": os.getenv("HAPROXY_PASSWORD"), - "port": os.getenv("HAPROXY_STATS_PORT") if os.getenv("HAPROXY_STATS_PORT") else "1936", - } - - env_vars["lookup_label"] = os.getenv("EASYHAPROXY_LABEL_PREFIX") if os.getenv("EASYHAPROXY_LABEL_PREFIX") else "easyhaproxy" - - env_vars["certbot"] = { - "email": os.getenv("EASYHAPROXY_CERTBOT_EMAIL", ""), - "server": os.getenv("EASYHAPROXY_CERTBOT_SERVER", False), - "eab_kid": os.getenv("EASYHAPROXY_CERTBOT_EAB_KID", ""), - "eab_hmac_key": os.getenv("EASYHAPROXY_CERTBOT_EAB_HMAC_KEY", ""), - } - - return env_vars +from easymapping import HaproxyConfigGenerator +from functions import Functions, Consts, ContainerEnv class ProcessorInterface: static_file = Consts.easyhaproxy_config - def __init__(self, filename = None): + def __init__(self, filename=None): + self.certbot_hosts = None + self.parsed_object = None + self.cfg = None + self.hosts = None + self.cfg = None + self.certbot_hosts = None + self.hosts = None self.filename = filename self.refresh() @@ -55,7 +35,8 @@ class ProcessorInterface: elif mode == "kubernetes": return Kubernetes() else: - Functions.log("EASYHAPROXY", Functions.FATAL, "Expected mode to be 'static', 'docker', 'swarm' or 'kubernetes'. I got '%s'" % (mode)) + Functions.log("EASYHAPROXY", Functions.FATAL, + "Expected mode to be 'static', 'docker', 'swarm' or 'kubernetes'. I got '%s'" % mode) return None def refresh(self): @@ -67,7 +48,7 @@ class ProcessorInterface: self.parse() def inspect_network(self): - #Abstract + # Abstract pass def parse(self): @@ -82,7 +63,7 @@ class ProcessorInterface: def get_parsed_object(self): return self.parsed_object - def get_certs(self, key = None): + def get_certs(self, key=None): if key is None: return self.cfg.certs else: @@ -103,20 +84,27 @@ class ProcessorInterface: class Static(ProcessorInterface): + def __init__(self, filename=None): + self.parsed_object = None + self.static_content = None + self.static_content = None + self.cfg = None + super().__init__(filename) + def inspect_network(self): self.parsed_object = {} self.static_content = None - + def get_parsed_object(self): return self.static_content["easymapping"] if "easymapping" in self.static_content else [] def get_hosts(self): hosts = [] - for object in self.get_parsed_object(): - if "hosts" not in object: + for obj in self.get_parsed_object(): + if "hosts" not in obj: continue - for host in object["hosts"].keys(): - hosts.append("%s:%s" % (host, object["port"])) + for host in obj["hosts"].keys(): + hosts.append("%s:%s" % (host, obj["port"])) return hosts def parse(self): @@ -125,18 +113,21 @@ class Static(ProcessorInterface): class Docker(ProcessorInterface): - def __init__(self, filename = None): + def __init__(self, filename=None): + self.parsed_object = None self.client = docker.from_env() super().__init__() - + def inspect_network(self): try: - ha_proxy_network_name = next(iter(self.client.containers.get(socket.gethostname()).attrs["NetworkSettings"]["Networks"])) + ha_proxy_network_name = next( + iter(self.client.containers.get(socket.gethostname()).attrs["NetworkSettings"]["Networks"])) except: # HAProxy is not running in a container, get first container network if len(self.client.containers.list()) == 0: return - ha_proxy_network_name = next(iter(self.client.containers.get(self.client.containers.list()[0].name).attrs["NetworkSettings"]["Networks"])) + ha_proxy_network_name = next(iter( + self.client.containers.get(self.client.containers.list()[0].name).attrs["NetworkSettings"]["Networks"])) ha_proxy_network = self.client.networks.get(ha_proxy_network_name) @@ -152,17 +143,22 @@ class Docker(ProcessorInterface): class Swarm(ProcessorInterface): - def __init__(self, filename = None): + def __init__(self, filename=None): + self.parsed_object = None self.client = docker.from_env() super().__init__() def inspect_network(self): ha_proxy_service_name = self.client.containers.get(socket.gethostname()).name.split('.')[0] - for endpoint in self.client.services.get(ha_proxy_service_name).attrs['Endpoint']["VirtualIPs"]: + ha_proxy_network_id = None + for endpoint in self.client.services.get(ha_proxy_service_name).attrs['Endpoint']["VirtualIPs"]: ha_proxy_network_id = endpoint["NetworkID"] if self.client.networks.get(ha_proxy_network_id).name != 'ingress': break + if ha_proxy_network_id is None: + raise "Could not find ingress network" + self.parsed_object = {} for service in self.client.services.list(): ip_address = None @@ -172,17 +168,18 @@ class Swarm(ProcessorInterface): ip_address = endpoint["Addr"].split("/")[0] break network_list.append(endpoint["NetworkID"]) - + if ip_address is None: network_list.append(ha_proxy_network_id) - service.update(networks = network_list) - continue # skip to the next service to give time to update the network - + service.update(networks=network_list) + continue # skip to the next service to give time to update the network + self.parsed_object[ip_address] = service.attrs["Spec"]["Labels"] class Kubernetes(ProcessorInterface): - def __init__(self, filename = None): + def __init__(self, filename=None): + self.parsed_object = None config.load_incluster_config() config.verify_ssl = False self.api_instance = client.CoreV1Api() @@ -190,13 +187,14 @@ class Kubernetes(ProcessorInterface): self.cert_cache = {} super().__init__() - def _check_annotation(self, annotations, key): + @staticmethod + def _check_annotation(annotations, key): if key not in annotations: return None return annotations[key] def inspect_network(self): - + ret = self.v1.list_ingress_for_all_namespaces(watch=False) self.parsed_object = {} @@ -216,10 +214,8 @@ class Kubernetes(ProcessorInterface): if listen_port is None: listen_port = 80 - data = {} - data["creation_timestamp"] = ingress.metadata.creation_timestamp.strftime("%x %X") - data["resource_version"] = ingress.metadata.resource_version - data["namespace"] = ingress.metadata.namespace + data = {"creation_timestamp": ingress.metadata.creation_timestamp.strftime("%x %X"), + "resource_version": ingress.metadata.resource_version, "namespace": ingress.metadata.namespace} ingress_name = ingress.metadata.namespace @@ -233,33 +229,36 @@ class Kubernetes(ProcessorInterface): if tls.secret_name not in self.cert_cache or self.cert_cache[tls.secret_name] != secret.data: self.cert_cache[tls.secret_name] = secret.data Functions.save( - "{0}/{1}.pem".format(Consts.certs_haproxy, tls.secret_name), - base64.b64decode(secret.data["tls.crt"]).decode('ascii') + "\n" + base64.b64decode(secret.data["tls.key"]).decode('ascii') + "{0}/{1}.pem".format(Consts.certs_haproxy, tls.secret_name), + base64.b64decode(secret.data["tls.crt"]).decode('ascii') + "\n" + base64.b64decode( + secret.data["tls.key"]).decode('ascii') ) ssl_hosts.extend(tls.hosts) except Exception as e: - Functions.log("EASYHAPROXY", Functions.WARN, "Ingress %s - Get secret failed: '%s'" % (ingress_name, e)) + Functions.log("EASYHAPROXY", Functions.WARN, + "Ingress %s - Get secret failed: '%s'" % (ingress_name, e)) - Functions.log("EASYHAPROXY", Functions.TRACE, "Ingress %s - SSL Hosts found '%s'" % (ingress_name, ssl_hosts)) + Functions.log("EASYHAPROXY", Functions.TRACE, + "Ingress %s - SSL Hosts found '%s'" % (ingress_name, ssl_hosts)) for rule in ingress.spec.rules: rule_data = {} port_number = rule.http.paths[0].backend.service.port.number definition = "easyhaproxy.%s_%s" % (rule.host.replace(".", "-"), port_number) - rule_data["%s.host" % (definition)] = rule.host - rule_data["%s.port" % (definition)] = listen_port - rule_data["%s.localport" % (definition)] = port_number + rule_data["%s.host" % definition] = rule.host + rule_data["%s.port" % definition] = listen_port + rule_data["%s.localport" % definition] = port_number if rule.host in ssl_hosts: - rule_data["%s.clone_to_ssl" % (definition)] = 'true' + rule_data["%s.clone_to_ssl" % definition] = 'true' if redirect_ssl is not None: - rule_data["%s.redirect_ssl" % (definition)] = redirect_ssl + rule_data["%s.redirect_ssl" % definition] = redirect_ssl if certbot is not None: - rule_data["%s.certbot" % (definition)] = certbot + rule_data["%s.certbot" % definition] = certbot if redirect is not None: - rule_data["%s.redirect" % (definition)] = redirect + rule_data["%s.redirect" % definition] = redirect if mode is not None: - rule_data["%s.mode" % (definition)] = mode + rule_data["%s.mode" % definition] = mode service_name = rule.http.paths[0].backend.service.name try: @@ -267,13 +266,10 @@ class Kubernetes(ProcessorInterface): cluster_ip = api_response.spec.cluster_ip except ApiException as e: cluster_ip = None - Functions.log("EASYHAPROXY", Functions.WARN, "Ingress %s - Service %s - Failed: '%s'" % (ingress_name, service_name, e)) - + Functions.log("EASYHAPROXY", Functions.WARN, + "Ingress %s - Service %s - Failed: '%s'" % (ingress_name, service_name, e)) + if cluster_ip is not None: if cluster_ip not in self.parsed_object.keys(): self.parsed_object[cluster_ip] = data self.parsed_object[cluster_ip].update(rule_data) - - - - diff --git a/src/tests/context.py b/src/tests/context.py index 67dda65..66ad651 100644 --- a/src/tests/context.py +++ b/src/tests/context.py @@ -1,5 +1,5 @@ import os import sys -sys.path.insert(0, os.path.abspath(os.path.join(os.path.dirname(__file__), '..'))) -import easymapping + +sys.path.insert(0, os.path.abspath(os.path.join(os.path.dirname(__file__), '..'))) diff --git a/src/tests/test_containerenv.py b/src/tests/test_containerenv.py index 4b84810..0347788 100644 --- a/src/tests/test_containerenv.py +++ b/src/tests/test_containerenv.py @@ -1,108 +1,113 @@ -import pytest import os -from processor import ContainerEnv + +from functions import ContainerEnv + def test_container_env_empty(): assert { - "customerrors": False, - "ssl_mode": "default", - "lookup_label": "easyhaproxy", - "certbot": {"eab_hmac_key": "", - "eab_kid": "", - "email": "", - "server": False} - } == ContainerEnv.read() + "customerrors": False, + "ssl_mode": "default", + "lookup_label": "easyhaproxy", + "certbot": {"eab_hmac_key": "", + "eab_kid": "", + "email": "", + "server": False} + } == ContainerEnv.read() # os.environ['CERTBOT_LOG_LEVEL'] = 'warn' + def test_container_env_customerrors(): os.environ['HAPROXY_CUSTOMERRORS'] = 'true' try: assert { - "customerrors": True, - "ssl_mode": "default", - "lookup_label": "easyhaproxy", - "certbot": {"eab_hmac_key": "", - "eab_kid": "", - "email": "", - "server": False} - } == ContainerEnv.read() + "customerrors": True, + "ssl_mode": "default", + "lookup_label": "easyhaproxy", + "certbot": {"eab_hmac_key": "", + "eab_kid": "", + "email": "", + "server": False} + } == ContainerEnv.read() finally: os.environ['HAPROXY_CUSTOMERRORS'] = '' + def test_container_env_sslmode(): os.environ['EASYHAPROXY_SSL_MODE'] = 'STRICT' try: assert { - "customerrors": False, - "ssl_mode": "strict", - "lookup_label": "easyhaproxy", - "certbot": {"eab_hmac_key": "", - "eab_kid": "", - "email": "", - "server": False} - } == ContainerEnv.read() + "customerrors": False, + "ssl_mode": "strict", + "lookup_label": "easyhaproxy", + "certbot": {"eab_hmac_key": "", + "eab_kid": "", + "email": "", + "server": False} + } == ContainerEnv.read() finally: os.environ['EASYHAPROXY_SSL_MODE'] = '' + def test_container_env_stats(): os.environ['HAPROXY_USERNAME'] = 'abc' os.environ['HAPROXY_STATS_PORT'] = '2101' try: assert { - "customerrors": False, - "ssl_mode": "default", - "lookup_label": "easyhaproxy", - "certbot": {"eab_hmac_key": "", - "eab_kid": "", - "email": "", - "server": False} - } == ContainerEnv.read() + "customerrors": False, + "ssl_mode": "default", + "lookup_label": "easyhaproxy", + "certbot": {"eab_hmac_key": "", + "eab_kid": "", + "email": "", + "server": False} + } == ContainerEnv.read() finally: os.environ['HAPROXY_USERNAME'] = '' os.environ['HAPROXY_STATS_PORT'] = '' + def test_container_env_stats_password(): os.environ['HAPROXY_PASSWORD'] = 'xyz' try: assert { - "customerrors": False, - "ssl_mode": "default", - "lookup_label": "easyhaproxy", - "stats": { - "username": "admin", - "password": "xyz", - "port": "1936" + "customerrors": False, + "ssl_mode": "default", + "lookup_label": "easyhaproxy", + "stats": { + "username": "admin", + "password": "xyz", + "port": "1936" - }, - "certbot": {"eab_hmac_key": "", - "eab_kid": "", - "email": "", - "server": False} - } == ContainerEnv.read() + }, + "certbot": {"eab_hmac_key": "", + "eab_kid": "", + "email": "", + "server": False} + } == ContainerEnv.read() finally: os.environ['HAPROXY_PASSWORD'] = '' -def test_container_env_stats_password(): +def test_container_env_stats_password_2(): os.environ['HAPROXY_USERNAME'] = 'abc' os.environ['HAPROXY_STATS_PORT'] = '2101' os.environ['HAPROXY_PASSWORD'] = 'xyz' try: assert { - "customerrors": False, - "ssl_mode": "default", - "lookup_label": "easyhaproxy", - "stats": { - "username": "abc", - "password": "xyz", - "port": "2101" - }, - "certbot": {"eab_hmac_key": "", - "eab_kid": "", - "email": "", - "server": False} - } == ContainerEnv.read() + "customerrors": False, + "ssl_mode": "default", + "lookup_label": "easyhaproxy", + "stats": { + "username": "abc", + "password": "xyz", + "port": "2101" + }, + "certbot": {"eab_hmac_key": "", + "eab_kid": "", + "email": "", + "server": False} + } == ContainerEnv.read() finally: os.environ['HAPROXY_USERNAME'] = '' os.environ['HAPROXY_STATS_PORT'] = '' @@ -113,19 +118,20 @@ def test_container_env_certbot_email(): os.environ['EASYHAPROXY_CERTBOT_EMAIL'] = 'acme@example.org' try: assert { - "customerrors": False, - "ssl_mode": "default", - "lookup_label": "easyhaproxy", - "certbot": { - 'eab_hmac_key': "", - 'eab_kid': "", - "email": "acme@example.org", - "server": False - } - } == ContainerEnv.read() + "customerrors": False, + "ssl_mode": "default", + "lookup_label": "easyhaproxy", + "certbot": { + 'eab_hmac_key': "", + 'eab_kid': "", + "email": "acme@example.org", + "server": False + } + } == ContainerEnv.read() finally: os.environ['EASYHAPROXY_CERTBOT_EMAIL'] = '' + def test_container_env_certbot_full(): os.environ['EASYHAPROXY_CERTBOT_EMAIL'] = 'acme@example.org' os.environ['EASYHAPROXY_CERTBOT_SERVER'] = 'schema://url/a' @@ -133,15 +139,15 @@ def test_container_env_certbot_full(): os.environ['EASYHAPROXY_CERTBOT_EAB_HMAC_KEY'] = 'eab_hmac_key' try: assert { - "customerrors": False, - "ssl_mode": "default", - "lookup_label": "easyhaproxy", - "certbot": { - "email": "acme@example.org", - "server": "schema://url/a", - 'eab_hmac_key': 'eab_hmac_key', - 'eab_kid': 'eab_kid', - } - } == ContainerEnv.read() + "customerrors": False, + "ssl_mode": "default", + "lookup_label": "easyhaproxy", + "certbot": { + "email": "acme@example.org", + "server": "schema://url/a", + 'eab_hmac_key': 'eab_hmac_key', + 'eab_kid': 'eab_kid', + } + } == ContainerEnv.read() finally: - os.environ['EASYHAPROXY_CERTBOT_EMAIL'] = '' \ No newline at end of file + os.environ['EASYHAPROXY_CERTBOT_EMAIL'] = '' diff --git a/src/tests/test_docker.py b/src/tests/test_docker.py index 6aaaf61..b6b95e1 100644 --- a/src/tests/test_docker.py +++ b/src/tests/test_docker.py @@ -1,10 +1,11 @@ -import pytest import os import time + import docker +import pytest + from functions import Functions from processor import ProcessorInterface -from processor import Docker def _get_hydrated_object(parsed_objects, lookup_key): @@ -17,7 +18,6 @@ def _get_hydrated_object(parsed_objects, lookup_key): def _get_ip_host(parsed_objects, lookup_key): - hydrated_object = {} for key in parsed_objects: for keys in parsed_objects[key]: if lookup_key in keys: diff --git a/src/tests/test_functions.py b/src/tests/test_functions.py index 5f40267..861e8d1 100644 --- a/src/tests/test_functions.py +++ b/src/tests/test_functions.py @@ -1,11 +1,11 @@ -import json -import pytest import os -import re import random +import re import string + from functions import Functions + def test_functions_check_local_level(): assert Functions.skip_log('CERTBOT', Functions.INFO) == False assert Functions.skip_log('HAPOROXY', Functions.INFO) == False @@ -23,6 +23,7 @@ def test_functions_check_local_level(): assert Functions.skip_log('EASYHAPROXY', Functions.INFO) == True os.environ['EASYHAPROXY_LOG_LEVEL'] = '' + def test_function_load_and_save(): filename = '/tmp/x.txt' try: @@ -34,6 +35,7 @@ def test_function_load_and_save(): finally: os.unlink(filename) + def test_functions_check_log_sanity(): print() Functions.log(Functions.EASYHAPROXY_LOG, Functions.INFO, "Test 1") @@ -51,44 +53,51 @@ def test_functions_check_log_sanity(): assert re.match("\[EASYHAPROXY\] .* \[INFO\]: Test 3", Functions.debug_log[1]) os.environ['EASYHAPROXY_LOG_LEVEL'] = 'warn' - Functions.log(Functions.EASYHAPROXY_LOG, Functions.INFO, "Test 4") # Should not log to debug + Functions.log(Functions.EASYHAPROXY_LOG, Functions.INFO, "Test 4") # Should not log to debug assert len(Functions.debug_log) == 2 finally: os.environ['EASYHAPROXY_LOG_LEVEL'] = '' Functions.debug_log = None + def test_functions_run_bash_log_output(): print() Functions.debug_log = [] try: - result = Functions.run_bash(Functions.EASYHAPROXY_LOG, "echo 'test run 1'", log_output=True, return_result=False) + result = Functions.run_bash(Functions.EASYHAPROXY_LOG, "echo 'test run 1'", log_output=True, + return_result=False) assert result == [] assert len(Functions.debug_log) == 1 assert re.match("\[EASYHAPROXY\] .* \[INFO\]: test run 1", Functions.debug_log[0]) finally: Functions.debug_log = None + def test_functions_run_bash_no_log_output(): print() Functions.debug_log = [] try: - result = Functions.run_bash(Functions.EASYHAPROXY_LOG, "echo 'test run 2'", log_output=False, return_result=False) + result = Functions.run_bash(Functions.EASYHAPROXY_LOG, "echo 'test run 2'", log_output=False, + return_result=False) assert result == [] assert len(Functions.debug_log) == 0 finally: Functions.debug_log = None + def test_functions_run_bash_return(): print() Functions.debug_log = [] try: - result = Functions.run_bash(Functions.EASYHAPROXY_LOG, "echo 'test run 3'", log_output=False, return_result=True) + result = Functions.run_bash(Functions.EASYHAPROXY_LOG, "echo 'test run 3'", log_output=False, + return_result=True) assert len(Functions.debug_log) == 0 assert "".join(result) == 'test run 3' finally: Functions.debug_log = None + def test_functions_run_bash_log_and_return_output(): print() Functions.debug_log = [] @@ -98,4 +107,4 @@ def test_functions_run_bash_log_and_return_output(): assert len(Functions.debug_log) == 1 assert re.match("\[EASYHAPROXY\] .* \[INFO\]: test run 4", Functions.debug_log[0]) finally: - Functions.debug_log = None \ No newline at end of file + Functions.debug_log = None diff --git a/src/tests/test_labels.py b/src/tests/test_labels.py index f56c212..14ce279 100644 --- a/src/tests/test_labels.py +++ b/src/tests/test_labels.py @@ -1,16 +1,17 @@ -from .context import easymapping import json -import pytest + +from easymapping import DockerLabelHandler + def test_label_generation(): - label = easymapping.DockerLabelHandler("foo") + label = DockerLabelHandler("foo") assert label.create("bar") == "foo.bar" assert label.create(["bar", "foobar"]) == "foo.bar.foobar" def test_label_data(): - label = easymapping.DockerLabelHandler("base") + label = DockerLabelHandler("base") label.set_data(json.loads('{"base.definitions":"h2"}')) label_name = label.create("definitions") @@ -20,7 +21,7 @@ def test_label_data(): def test_label_complex_key(): - label = easymapping.DockerLabelHandler("till") + label = DockerLabelHandler("till") data = dict() data["till.definitions"] = "h2" diff --git a/src/tests/test_parser.py b/src/tests/test_parser.py index c5ef3df..6ab72cb 100644 --- a/src/tests/test_parser.py +++ b/src/tests/test_parser.py @@ -1,12 +1,14 @@ -import easymapping -import pytest -import os -import yaml import json +import os + +import yaml + +import easymapping + +CERTS_FOLDER = "/tmp/certs" +CERT_FILE = "/tmp/certs/haproxy/www.somehost.com.br.pem" +CERTBOT_EMAIL = "some@email.com" -CERTS_FOLDER="/tmp/certs" -CERT_FILE="/tmp/certs/haproxy/www.somehost.com.br.pem" -CERTBOT_EMAIL="some@email.com" def load_fixture(file): path = os.path.dirname(os.path.realpath(__file__)) @@ -35,6 +37,7 @@ def test_parser_doesnt_crash(): assert expected_file.read() == haproxy_config assert [] == cfg.certbot_hosts + def test_parser_finds_services(): line_list = load_fixture("services") @@ -56,10 +59,11 @@ def test_parser_finds_services(): with open(path + "/expected/services.txt", 'r') as expected_file: assert expected_file.read() == haproxy_config - assert {"www.somehost.com.br.pem":"Some PEM Certificate"} == cfg.certs + assert {"www.somehost.com.br.pem": "Some PEM Certificate"} == cfg.certs assert ['node-exporter.quantum.example.org'] == cfg.certbot_hosts + def test_parser_finds_services_changed_label(): line_list = load_fixture("services-changed-label") @@ -85,10 +89,11 @@ def test_parser_finds_services_changed_label(): with open(path + "/expected/services.txt", 'r') as expected_file: assert expected_file.read() == haproxy_config - assert {"www.somehost.com.br.pem":"Some PEM Certificate"} == cfg.certs + assert {"www.somehost.com.br.pem": "Some PEM Certificate"} == cfg.certs assert ['node-exporter.quantum.example.org'] == cfg.certbot_hosts + def test_parser_finds_services_raw(): line_list = load_fixture("services") @@ -109,10 +114,10 @@ def test_parser_finds_services_raw(): parsed_object = [ { - "mode":"tcp", - "health-check":"", - "port":"31339", - "hosts":{ + "mode": "tcp", + "health-check": "", + "port": "31339", + "hosts": { "agent.quantum.example.org": { "containers": [ "my-stack_agent:9001" @@ -121,23 +126,23 @@ def test_parser_finds_services_raw(): "redirect_ssl": False } }, - "redirect":{ - + "redirect": { + } }, { - "mode":"http", - "health-check":"", - "port":"31337", - "hosts":{ - "cadvisor.quantum.example.org":{ + "mode": "http", + "health-check": "", + "port": "31337", + "hosts": { + "cadvisor.quantum.example.org": { "containers": [ "my-stack_cadvisor:8080" ], "certbot": False, "redirect_ssl": False }, - "node-exporter.quantum.example.org":{ + "node-exporter.quantum.example.org": { "containers": [ "my-stack_node-exporter:9100" ], @@ -145,15 +150,15 @@ def test_parser_finds_services_raw(): "redirect_ssl": False } }, - "redirect":{ - + "redirect": { + }, }, { - "mode":"http", - "health-check":"", - "port":"443", - "hosts":{ + "mode": "http", + "health-check": "", + "port": "443", + "hosts": { "node-exporter.quantum.example.org": { "containers": [ "my-stack_node-exporter:9100" @@ -161,7 +166,7 @@ def test_parser_finds_services_raw(): "certbot": False, "redirect_ssl": False }, - "www.somehost.com.br":{ + "www.somehost.com.br": { "containers": [ "some-service:80" ], @@ -169,21 +174,21 @@ def test_parser_finds_services_raw(): "redirect_ssl": False } }, - "redirect":{ - "somehost.com.br":"https://www.somehost.com.br", - "somehost.com":"https://www.somehost.com.br", - "www.somehost.com":"https://www.somehost.com.br", - "byjg.ca":"https://www.somehost.com.br", - "www.byjg.ca":"https://www.somehost.com.br" + "redirect": { + "somehost.com.br": "https://www.somehost.com.br", + "somehost.com": "https://www.somehost.com.br", + "www.somehost.com": "https://www.somehost.com.br", + "byjg.ca": "https://www.somehost.com.br", + "www.byjg.ca": "https://www.somehost.com.br" }, "ssl": True }, { - "mode":"http", - "health-check":"", - "port":"80", - "hosts":{ - "www.somehost.com.br":{ + "mode": "http", + "health-check": "", + "port": "80", + "hosts": { + "www.somehost.com.br": { "containers": [ "some-service:80" ], @@ -191,12 +196,12 @@ def test_parser_finds_services_raw(): "redirect_ssl": False } }, - "redirect":{ - "somehost.com.br":"https://www.somehost.com.br", - "somehost.com":"https://www.somehost.com.br", - "www.somehost.com":"https://www.somehost.com.br", - "byjg.ca":"https://www.somehost.com.br", - "www.byjg.ca":"https://www.somehost.com.br" + "redirect": { + "somehost.com.br": "https://www.somehost.com.br", + "somehost.com": "https://www.somehost.com.br", + "www.somehost.com": "https://www.somehost.com.br", + "byjg.ca": "https://www.somehost.com.br", + "www.byjg.ca": "https://www.somehost.com.br" }, } ] @@ -207,7 +212,6 @@ def test_parser_finds_services_raw(): assert ['node-exporter.quantum.example.org'] == cfg.certbot_hosts - def test_parser_static(): path = os.path.dirname(os.path.realpath(__file__)) with open(path + "/fixtures/static.yml", 'r') as content_file: @@ -221,6 +225,7 @@ def test_parser_static(): assert expected_file.read() == haproxy_config assert [] == cfg.certbot_hosts + def test_parser_static_raw(): path = os.path.dirname(os.path.realpath(__file__)) with open(path + "/fixtures/static.yml", 'r') as content_file: @@ -280,7 +285,6 @@ def test_parser_static_raw(): assert expected == parsed - def test_parser_tcp(): line_list = load_fixture("services-tcp") @@ -301,6 +305,7 @@ def test_parser_tcp(): assert expected_file.read() == haproxy_config assert [] == cfg.certbot_hosts + def test_parser_multi_containers(): line_list = load_fixture("services-multi-containers") @@ -384,6 +389,7 @@ def test_parser_ssl_strict(): assert expected_file.read() == haproxy_config assert [] == cfg.certbot_hosts + def test_parser_ssl_loose(): line_list = load_fixture("no-services") @@ -401,6 +407,7 @@ def test_parser_ssl_loose(): assert expected_file.read() == haproxy_config assert [] == cfg.certbot_hosts + def test_parser_ssl_letsencrypt(): line_list = load_fixture("services-letsencrypt") @@ -444,51 +451,51 @@ def test_parser_finds_services_clone_to_ssl_raw(): parsed_object = [ { - "health-check":"", - "hosts":{ - "host2.local":{ - "containers":[ - "10.152.183.215:8080" + "health-check": "", + "hosts": { + "host2.local": { + "containers": [ + "10.152.183.215:8080" ], "certbot": False, "redirect_ssl": False }, - "valida.me":{ - "containers":[ - "10.152.183.62:8080" + "valida.me": { + "containers": [ + "10.152.183.62:8080" ], "certbot": False, "redirect_ssl": False }, - "www.valida.me":{ - "containers":[ - "10.152.183.62:8080" + "www.valida.me": { + "containers": [ + "10.152.183.62:8080" ], "certbot": False, "redirect_ssl": False } }, - "mode":"http", - "port":"80", - "redirect":{ - + "mode": "http", + "port": "80", + "redirect": { + } }, { - "health-check":"ssl", - "hosts":{ - "host2.local":{ - "containers":[ - "10.152.183.215:8080" + "health-check": "ssl", + "hosts": { + "host2.local": { + "containers": [ + "10.152.183.215:8080" ], "certbot": False, "redirect_ssl": False } }, - "mode":"http", - "port":"443", - "redirect":{ - + "mode": "http", + "port": "443", + "redirect": { + }, "ssl": True } @@ -498,10 +505,8 @@ def test_parser_finds_services_clone_to_ssl_raw(): assert parsed_object == processed assert [] == cfg.certbot_hosts - - -#test_parser_finds_services_raw() -#test_parser_tcp() -#test_parser_multiple_hosts() -#test_parser_ssl_certbot() -#test_parser_finds_services() \ No newline at end of file +# test_parser_finds_services_raw() +# test_parser_tcp() +# test_parser_multiple_hosts() +# test_parser_ssl_certbot() +# test_parser_finds_services() diff --git a/src/tests/test_static.py b/src/tests/test_static.py index f8800ac..7e6998c 100644 --- a/src/tests/test_static.py +++ b/src/tests/test_static.py @@ -1,8 +1,8 @@ -import pytest import os + from functions import Functions from processor import ProcessorInterface -from processor import Static + def test_processor_static(): ProcessorInterface.static_file = os.path.join(os.path.dirname(os.path.realpath(__file__)), "./fixtures/static.yml") @@ -10,44 +10,44 @@ def test_processor_static(): parsed_object = [ { - "hosts":{ - "host1.com.br":{ - "containers":[ - "container:5000" + "hosts": { + "host1.com.br": { + "containers": [ + "container:5000" ], "certbot": True }, - "host2.com.br":{ - "containers":[ - "other:3000" + "host2.com.br": { + "containers": [ + "other:3000" ] } }, - "port":80, - "redirect":{ - "www.host1.com.br":"http://host1.com.br" + "port": 80, + "redirect": { + "www.host1.com.br": "http://host1.com.br" } }, { - "hosts":{ - "host1.com.br":{ - "containers":[ - "container:80" + "hosts": { + "host1.com.br": { + "containers": [ + "container:80" ] } }, - "port":443, + "port": 443, "ssl": True }, { - "hosts":{ - "host3.com.br":{ - "containers":[ - "domain:8181" + "hosts": { + "host3.com.br": { + "containers": [ + "domain:8181" ] } }, - "port":8080 + "port": 8080 } ] hosts = [ @@ -63,11 +63,12 @@ def test_processor_static(): haproxy_cfg = static.get_haproxy_conf() - assert haproxy_cfg == Functions.load(os.path.join(os.path.dirname(os.path.realpath(__file__)), "./expected/static.txt")) + assert haproxy_cfg == Functions.load( + os.path.join(os.path.dirname(os.path.realpath(__file__)), "./expected/static.txt")) # @todo: Static doesnt populate this fields assert static.get_certbot_hosts() == [] assert static.get_parsed_object() == parsed_object assert static.get_hosts() == hosts -# test_processor_static() \ No newline at end of file +# test_processor_static() From 7f377e263fd88cd8824a73ae61832f44b1c4c00d Mon Sep 17 00:00:00 2001 From: Joao Gilberto Date: Sun, 2 Jul 2023 00:01:58 +0000 Subject: [PATCH 22/33] Fix runtime errors --- src/functions/__init__.py | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/src/functions/__init__.py b/src/functions/__init__.py index e704cb7..58bacf9 100644 --- a/src/functions/__init__.py +++ b/src/functions/__init__.py @@ -217,6 +217,8 @@ class Certbot: @staticmethod def set_acme_server(acme_server): + if not acme_server: + return "" if acme_server.lower() == "staging": return "--staging" elif acme_server.lower().startswith("http"): @@ -254,7 +256,7 @@ class Certbot: request_certs.append(host_arg) else: try: - with open(filename, 'r') as file: + with open(filename, 'rb') as file: certificate_str = file.read() certificate = crypto.load_certificate(crypto.FILETYPE_PEM, certificate_str) expiration_after = datetime.strptime(certificate.get_notAfter().decode()[:-1], @@ -264,7 +266,7 @@ class Certbot: Functions.log(Functions.CERTBOT_LOG, Functions.DEBUG, "Request expired certificate for %s" % host) request_certs.append(host_arg) - if (expiration_after - current_time) // (24 * 3600) >= 15: + if (expiration_after - current_time) // (24 * 3600) <= 15: Functions.log(Functions.CERTBOT_LOG, Functions.DEBUG, "Renew certificate for %s" % host) renew_certs.append(host_arg) except Exception as e: From 667861a8e9fa721b525fd58e53ce5f6f5439f744 Mon Sep 17 00:00:00 2001 From: Joao Gilberto Magalhaes Date: Sat, 1 Jul 2023 19:27:05 -0500 Subject: [PATCH 23/33] Added documentation for ZeroSSL. --- docs/zerossl.md | 70 +++++++++++++++++++++++++++++++++++++++++++++ docs/zerossl_1.png | Bin 0 -> 99363 bytes docs/zerossl_2.png | Bin 0 -> 41082 bytes 3 files changed, 70 insertions(+) create mode 100644 docs/zerossl.md create mode 100644 docs/zerossl_1.png create mode 100644 docs/zerossl_2.png diff --git a/docs/zerossl.md b/docs/zerossl.md new file mode 100644 index 0000000..cd7ff27 --- /dev/null +++ b/docs/zerossl.md @@ -0,0 +1,70 @@ +# ZeroSSL + +EasyHAProxy can issue a ZeroSSL certificate. Follow the steps below: + +## Getting the Keys from ZeroSSL + +- Login to [ZeroSSL](https://zerossl.com?fpr=byjg) +- If you don't have an account you can use our referral link: [https://zerossl.com?fpr=byjg](https://zerossl.com?fpr=byjg) +- If you created recently and would like to support us open an issue, and I'll try to add you as our referred. +- In the ZeroSSL dashboard click in 'Developer' and the Generate EAB Keys + +![zerossl_1.png](zerossl_1.png) + +- Once you generate the keys, please **take note**. We will need this later, and it isn't saved anywhere. + +![zerossl_2.png](zerossl_2.png) + +```text +Important: if you to this process again the old values will be lost and you need to setup EasyHAProxy again. +``` + +## Setting up EasyHAProxy + +Run the EasyHAProxy container with the following parameters: + +```bash +docker run \ + ... \ + -e EASYHAPROXY_CERTBOT_EMAIL="your zerossl email" \ + -e EASYHAPROXY_CERTBOT_EAB_KID="the eab_kid from the previous step" \ + -e EASYHAPROXY_CERTBOT_EAB_HMAC_KEY="the eab_hmac_key from the previous step" \ + -e EASYHAPROXY_CERTBOT_SERVER="https://acme.zerossl.com/v2/DV90" \ + -p 80:80 \ + -p 443:443 \ + -v /path/to/guest/certbot/certs:/certs/certbot \ + ... \ + byjg/easy-haproxy +``` + +Notes: + +- If you don't setup all `EASYHAPROXY_CERTBOT_*` environment variables with the proper values, EasyHAProxy will fail silently and **will not request** a certificate. +- The ports 80 and 443 needs to accessible through the internet as [Let's Encrypt requirement](https://letsencrypt.org/docs/allow-port-80/) + +**It is required you to persist the container folder `/certs/certbot` outside the container.** + +You cannot delete or change it contents. +If you do not persist, or change/delete the contents, Let's encrypt will not work properly. + + +## Setting up your container to use ZeroSSL + +```bash +docker run \ + ... \ + --label easyhaproxy.express.port=80 \ + --label easyhaproxy.express.localport=3000 \ + --label easyhaproxy.express.host=example.org \ + --label easyhaproxy.express.certbot=true \ + ... \ + some/myimage +``` + +Requirements: + +- Your container **must** listen to port 80. Letsencrypt will not issue the certificate if `easyhaproxy..port` is in another port, and EasyHAProxy will fail silently. +- You cannot set port 443 for the container with the Letsencrypt because EasyHAProxy will create this port automatically once the certificate is issued. + +---- +[Open source ByJG](http://opensource.byjg.com) diff --git a/docs/zerossl_1.png b/docs/zerossl_1.png new file mode 100644 index 0000000000000000000000000000000000000000..06810d10aaba4e2b05ed210255032b422acf8bee GIT binary patch literal 99363 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 literal 0 HcmV?d00001 diff --git a/docs/zerossl_2.png b/docs/zerossl_2.png new file mode 100644 index 0000000000000000000000000000000000000000..aeb584134f0459262fd64f33d23dd11687354183 GIT binary patch literal 41082 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�UQ$; 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�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 U(%0;P?-_u=)78&qol`;+012D26#xJL literal 0 HcmV?d00001 From 014f83332cd21b388d0acad5919cfd710489a516 Mon Sep 17 00:00:00 2001 From: Joao Gilberto Magalhaes Date: Sun, 2 Jul 2023 00:15:38 -0500 Subject: [PATCH 24/33] Using Certbot auto-config --- src/functions/__init__.py | 43 ++++++++++++++++++++++++++++++++++ src/tests/test_containerenv.py | 22 +++++++++++------ 2 files changed, 58 insertions(+), 7 deletions(-) diff --git a/src/functions/__init__.py b/src/functions/__init__.py index 58bacf9..e595dd4 100644 --- a/src/functions/__init__.py +++ b/src/functions/__init__.py @@ -5,6 +5,7 @@ import time from datetime import datetime from multiprocessing import Process +import requests from OpenSSL import crypto @@ -27,12 +28,54 @@ class ContainerEnv: "EASYHAPROXY_LABEL_PREFIX") else "easyhaproxy" env_vars["certbot"] = { + "autoconfig": os.getenv("EASYHAPROXY_CERTBOT_AUTOCONFIG", "letsencrypt"), "email": os.getenv("EASYHAPROXY_CERTBOT_EMAIL", ""), "server": os.getenv("EASYHAPROXY_CERTBOT_SERVER", False), "eab_kid": os.getenv("EASYHAPROXY_CERTBOT_EAB_KID", ""), "eab_hmac_key": os.getenv("EASYHAPROXY_CERTBOT_EAB_HMAC_KEY", ""), } + if env_vars["certbot"]["autoconfig"] != "" and not env_vars["certbot"]["server"] and env_vars["certbot"]["email"] != "": + if env_vars["certbot"]["autoconfig"] == "letsencrypt": + env_vars["certbot"]["server"] = "https://acme-v02.api.letsencrypt.org/directory" + + if env_vars["certbot"]["autoconfig"] == "letsencrypt_test": + env_vars["certbot"]["server"] = "https://acme-staging-v02.api.letsencrypt.org/directory" + + if env_vars["certbot"]["autoconfig"] == "buypass": + env_vars["certbot"]["server"] = "https://api.buypass.com/acme/directory" + + if env_vars["certbot"]["autoconfig"] == "buypass_test": + env_vars["certbot"]["server"] = "https://api.test4.buypass.no/acme/directory" + + if env_vars["certbot"]["autoconfig"] == "sslcom_rca": + env_vars["certbot"]["server"] = "https://acme.ssl.com/sslcom-dv-rsa" + + if env_vars["certbot"]["autoconfig"] == "sslcom_ecc": + env_vars["certbot"]["server"] = "https://acme.ssl.com/sslcom-dv-ecc" + + if env_vars["certbot"]["autoconfig"] == "google": + env_vars["certbot"]["server"] = "https://dv.acme-v02.api.pki.goog/directory" + + if env_vars["certbot"]["autoconfig"] == "google_test": + env_vars["certbot"]["server"] = "https://dv.acme-v02.test-api.pki.goog/directory" + + if env_vars["certbot"]["autoconfig"] == "zerossl": + url = "https://api.zerossl.com/acme/eab-credentials-email" + headers = {"Content-Type": "application/x-www-form-urlencoded"} + data = "email=" + env_vars["certbot"]["email"] + resp = requests.post(url, headers=headers, data=data).json() + + if resp["success"]: + env_vars["certbot"]["server"] = "https://acme.zerossl.com/v2/DV90" + env_vars["certbot"]["eab_kid"] = os.environ['EASYHAPROXY_CERTBOT_EAB_KID'] = resp["eab_kid"] + env_vars["certbot"]["eab_hmac_key"] = os.environ['EASYHAPROXY_CERTBOT_EAB_HMAC_KEY'] = resp["eab_hmac_key"] + else: + os.environ["EASYHAPROXY_CERTBOT_EMAIL"] = "" + Functions.log(Functions.CERTBOT_LOG, Functions.ERROR, "Could not obtain ZeroSSL credentials " + resp["error"]["type"]) + + os.environ['EASYHAPROXY_CERTBOT_SERVER'] = env_vars["certbot"]["server"] + return env_vars diff --git a/src/tests/test_containerenv.py b/src/tests/test_containerenv.py index 0347788..3c60928 100644 --- a/src/tests/test_containerenv.py +++ b/src/tests/test_containerenv.py @@ -8,7 +8,8 @@ def test_container_env_empty(): "customerrors": False, "ssl_mode": "default", "lookup_label": "easyhaproxy", - "certbot": {"eab_hmac_key": "", + "certbot": {"autoconfig": "letsencrypt", + "eab_hmac_key": "", "eab_kid": "", "email": "", "server": False} @@ -24,7 +25,8 @@ def test_container_env_customerrors(): "customerrors": True, "ssl_mode": "default", "lookup_label": "easyhaproxy", - "certbot": {"eab_hmac_key": "", + "certbot": {"autoconfig": "letsencrypt", + "eab_hmac_key": "", "eab_kid": "", "email": "", "server": False} @@ -40,7 +42,8 @@ def test_container_env_sslmode(): "customerrors": False, "ssl_mode": "strict", "lookup_label": "easyhaproxy", - "certbot": {"eab_hmac_key": "", + "certbot": {"autoconfig": "letsencrypt", + "eab_hmac_key": "", "eab_kid": "", "email": "", "server": False} @@ -57,7 +60,8 @@ def test_container_env_stats(): "customerrors": False, "ssl_mode": "default", "lookup_label": "easyhaproxy", - "certbot": {"eab_hmac_key": "", + "certbot": {"autoconfig": "letsencrypt", + "eab_hmac_key": "", "eab_kid": "", "email": "", "server": False} @@ -80,7 +84,8 @@ def test_container_env_stats_password(): "port": "1936" }, - "certbot": {"eab_hmac_key": "", + "certbot": {"autoconfig": "letsencrypt", + "eab_hmac_key": "", "eab_kid": "", "email": "", "server": False} @@ -103,7 +108,8 @@ def test_container_env_stats_password_2(): "password": "xyz", "port": "2101" }, - "certbot": {"eab_hmac_key": "", + "certbot": {"autoconfig": "letsencrypt", + "eab_hmac_key": "", "eab_kid": "", "email": "", "server": False} @@ -122,10 +128,11 @@ def test_container_env_certbot_email(): "ssl_mode": "default", "lookup_label": "easyhaproxy", "certbot": { + "autoconfig": "letsencrypt", 'eab_hmac_key': "", 'eab_kid': "", "email": "acme@example.org", - "server": False + "server": 'https://acme-v02.api.letsencrypt.org/directory' } } == ContainerEnv.read() finally: @@ -143,6 +150,7 @@ def test_container_env_certbot_full(): "ssl_mode": "default", "lookup_label": "easyhaproxy", "certbot": { + "autoconfig": "letsencrypt", "email": "acme@example.org", "server": "schema://url/a", 'eab_hmac_key': 'eab_hmac_key', From 9db4d763ec68c5729a4bea5183968a095330861a Mon Sep 17 00:00:00 2001 From: Joao Gilberto Magalhaes Date: Sun, 2 Jul 2023 02:07:37 -0500 Subject: [PATCH 25/33] ACME Docs and changes in Functions.run_bash --- README.md | 13 +++++- docs/acme.md | 36 +++++++++++++++++ docs/container-labels.md | 2 +- docs/docker.md | 8 +--- docs/kubernetes.md | 6 ++- docs/ssl.md | 2 +- docs/zerossl.md | 70 --------------------------------- docs/zerossl_1.png | Bin 99363 -> 0 bytes docs/zerossl_2.png | Bin 41082 -> 0 bytes src/functions/__init__.py | 5 ++- src/tests/fixtures/run_bash.sh | 7 ++++ src/tests/test_containerenv.py | 18 ++++----- src/tests/test_functions.py | 59 +++++++++++++++++++++++---- 13 files changed, 127 insertions(+), 99 deletions(-) create mode 100644 docs/acme.md delete mode 100644 docs/zerossl.md delete mode 100644 docs/zerossl_1.png delete mode 100644 docs/zerossl_2.png create mode 100755 src/tests/fixtures/run_bash.sh diff --git a/README.md b/README.md index 0c9380e..d9824d1 100644 --- a/README.md +++ b/README.md @@ -19,11 +19,15 @@ EasyHAProxy can detect and configure HAProxy automatically on the following plat - Docker Swarm - Kubernetes +## EasyHAProxy Mission + +Easy to set up and low configuration to numerous features. + ## Features EasyHAProxy will discover the services based on the Docker Tags of the containers running on a Docker host or Docker Swarm cluster and dynamically set up the `haproxy.cfg`. Below, EasyHAProxy main features: -- Automatic SSL Certificate issued by any ACME compatible service as Letsencrypt and ZeroSSL +- Support Automatic Certificate Management Environment (ACME) protocol compatible with Let's encrypt and other issuers. - Set your custom SSL certificates - Balance traffic between multiple replicas - Set SSL with three different levels of validations and according to the most recent definitions. @@ -59,6 +63,13 @@ Or you can install using tools: [![MicroK8s](docs/easyhaproxy_microk8s.png)](docs/microk8s.md) [![Dokku](docs/easyhaproxy_dokku.png)](docs/dokku.md) +## Special Topics + +If you already set up the EasyHAProxy, is time to go deeper: + +- [Custom SSL](docs/ssl.md) +- Automatic Certificate Issuing (e.g. Let's Encrypt) + ## See EasyHAProxy in action Click on the image to see the videos (use HD for better visualization) diff --git a/docs/acme.md b/docs/acme.md new file mode 100644 index 0000000..9de7088 --- /dev/null +++ b/docs/acme.md @@ -0,0 +1,36 @@ +# SSL - Automatic Certificate Management Environment (ACME) + +The Automatic Certificate Management Environment (ACME) protocol +allow automating interactions between certificate authorities and their users' servers, +allowing the automated deployment of public key infrastructure. + +Most of the issuers offers Automatic Issuing free of cost. + +To enable the ACME protocol we need to enable Certbot in EasyHAProxy by setting up to 4 environment variables: + +- EASYHAPROXY_CERTBOT_AUTOCONFIG (optional): Will use pre-sets for your Certificate Authority (CA). See table below. +- EASYHAPROXY_CERTBOT_EMAIL (required): Your email in the certificate authority. +- EASYHAPROXY_CERTBOT_SERVER (optional): The ACME Endpoint of your certificate authority. If you use AUTOCONFIG, it is set automatically. See table below. +- EASYHAPROXY_CERTBOT_EAB_KID (optional): External Account Binding (EAB) Key Identifier (KID) provided by your certificate authority. Some CA require it. See table below. +- EASYHAPROXY_CERTBOT_EAB_HMAC_KEY (optional): External Account Binding (EAB) HMAC Key provided by your certificate authority. Some CA require it. See table below. + +Here are detailed instructions per Certificate Authority (CA). If anyone is missing, please let's know. + +| CA | Auto Config | Free? | Account Required? | EAB KID? | EAB HMAC Key? | More Info | +|----------------------|------------------|-------|--------------------|----------|---------------|--------------------------------------------------------------------------------------------------------------------------------------------------------------------| +| Let's Encrypt | - | Yes | No | No | No | [Link](letsencrypt.md) | +| Let's Encrypt (Test) | letsencrypt_test | Yes | No | No | No | [Link](letsencrypt.md) | +| ZeroSSL | zerossl | Yes | No | No | No | [Link](https://zerossl.com/documentation/acme/) | +| BuyPass | buypass | Yes | No | No | No | [Link](https://community.buypass.com/t/63d4ay/buypass-go-ssl-endpoints-updated-14-05-2020) | +| BuyPass (test) | buypass_test | Yes | No | No | No | [Link](https://community.buypass.com/t/63d4ay/buypass-go-ssl-endpoints-updated-14-05-2020) | +| Google | google | Yes | Yes | Yes | Yes | [Link](https://cloud.google.com/blog/products/identity-security/automate-public-certificate-lifecycle-management-via--acme-client-api) | +| Google Test | google_test | Yes | Yes | Yes | Yes | [Link](https://cloud.google.com/blog/products/identity-security/automate-public-certificate-lifecycle-management-via--acme-client-api) | +| SSLCOM RCA | sslcom_rca | Yes | No. Keys by email. | Yes | Yes | [Link](https://www.ssl.com/blogs/sslcom-supports-acme-protocol-ssl-tls-certificate-automation/) | +| SSLCOM ECC | sslcom_ecc | Yes | No. Keys by email. | Yes | Yes | [Link](https://www.ssl.com/blogs/sslcom-supports-acme-protocol-ssl-tls-certificate-automation/) | +| Digicert | - | No | Yes | Yes | Yes | [Link](https://docs.digicert.com/en/certcentral/certificate-tools/certificate-lifecycle-automation-guides/use-a-third-party-acme-client-for-host-automations.html) | +| Entrust | - | No | Yes | Yes | Yes | [Link](https://www.entrust.com/knowledgebase/ssl/how-to-use-acme-to-install-ssl-tls-certificates-in-entrust-certificate-services-apache) | +| Sectigo | - | No | Yes | Yes | Yes | [Link](https://www.sectigo.com/resource-library/sectigos-acme-automation) | + +This configuration is global. After set up ACME properly, is necessary enable for each domain the certificate request. + +To do that add the label: `easyhaproxy..certbot=true`. See the method of installation you are using to learn how to set up properly. diff --git a/docs/container-labels.md b/docs/container-labels.md index 56b5a1f..41e89b4 100644 --- a/docs/container-labels.md +++ b/docs/container-labels.md @@ -12,7 +12,7 @@ | easyhaproxy.[definition].sslcert | (Optional) Cert PEM Base64 encoded. Do not use this if `certbot` is enabled. | *empty* | base64 cert + key | | easyhaproxy.[definition].ssl | (Optional) If `true` you need to provide certificate as a file. See below. Do not use with `sslcert`. | false | true or false | | easyhaproxy.[definition].health-check | (Optional) `ssl`, enable health check via SSL in `mode tcp` | *empty* | ssl | -| easyhaproxy.[definition].certbot | (Optional) Generate certificate with certbot. Do not use with `sslcert` parameter. | false | true OR false | +| easyhaproxy.[definition].certbot | (Optional) Generate certificate with certbot. Do not use with `sslcert` parameter. More info [here](acme.md). | false | true OR false | | easyhaproxy.[definition].redirect_ssl | (Optional) Redirect all requests to https | false | true OR false | | easyhaproxy.[definition].clone_to_ssl | (Optional) It copies the configuration to HTTPS(443) and disable SSL from the current config. **Do not use* this with `ssl` or `certbot` parameters | false | true OR false | diff --git a/docs/docker.md b/docs/docker.md index edf9fc6..643ad48 100644 --- a/docs/docker.md +++ b/docs/docker.md @@ -54,13 +54,9 @@ Please follow the [docker label configuration](container-labels.md) to see other You can configure the behavior of the EasyHAProxy by setup specific environment variables. To get a list of the variables, please follow the [docker container environment](docker-environment.md) -## Setup certificates with Letsencrypt +## Setup certificates with ACME (e.g. Letsencrypt) -Follow [this link](letsencrypt.md) - -## Setup certificates with ZeroSSL - -Follow [this link](zerossl.md) +Follow [this link](acme.md) ## Setup your own certificates diff --git a/docs/kubernetes.md b/docs/kubernetes.md index 005687d..12d5c5d 100644 --- a/docs/kubernetes.md +++ b/docs/kubernetes.md @@ -90,7 +90,7 @@ Caveats: **Important**: The annotations are per ingress and applied to all hosts in that ingress configuration. -## Letsencrypt +## Certbot / ACME / Letsencrypt It is necessary add the annotation `easyhaproxy.certbot` to the ingress configuration: @@ -106,7 +106,9 @@ spec: .... ``` -Make sure your cluster is accessible both through ports 80 and 443. +More info [here](acme.md). + +Make sure your cluster is accessible both through ports 80 and 443. ## Custom SSL Certificates diff --git a/docs/ssl.md b/docs/ssl.md index c2f551d..9b49e0c 100644 --- a/docs/ssl.md +++ b/docs/ssl.md @@ -1,6 +1,6 @@ # Setup custom certificates -You can use your certificates with EasyHAProxy. +You can use your own certificates with EasyHAProxy. You just need to let EasyHAProxy know that certificate. There are two ways to do that. diff --git a/docs/zerossl.md b/docs/zerossl.md deleted file mode 100644 index cd7ff27..0000000 --- a/docs/zerossl.md +++ /dev/null @@ -1,70 +0,0 @@ -# ZeroSSL - -EasyHAProxy can issue a ZeroSSL certificate. Follow the steps below: - -## Getting the Keys from ZeroSSL - -- Login to [ZeroSSL](https://zerossl.com?fpr=byjg) -- If you don't have an account you can use our referral link: [https://zerossl.com?fpr=byjg](https://zerossl.com?fpr=byjg) -- If you created recently and would like to support us open an issue, and I'll try to add you as our referred. -- In the ZeroSSL dashboard click in 'Developer' and the Generate EAB Keys - -![zerossl_1.png](zerossl_1.png) - -- Once you generate the keys, please **take note**. We will need this later, and it isn't saved anywhere. - -![zerossl_2.png](zerossl_2.png) - -```text -Important: if you to this process again the old values will be lost and you need to setup EasyHAProxy again. -``` - -## Setting up EasyHAProxy - -Run the EasyHAProxy container with the following parameters: - -```bash -docker run \ - ... \ - -e EASYHAPROXY_CERTBOT_EMAIL="your zerossl email" \ - -e EASYHAPROXY_CERTBOT_EAB_KID="the eab_kid from the previous step" \ - -e EASYHAPROXY_CERTBOT_EAB_HMAC_KEY="the eab_hmac_key from the previous step" \ - -e EASYHAPROXY_CERTBOT_SERVER="https://acme.zerossl.com/v2/DV90" \ - -p 80:80 \ - -p 443:443 \ - -v /path/to/guest/certbot/certs:/certs/certbot \ - ... \ - byjg/easy-haproxy -``` - -Notes: - -- If you don't setup all `EASYHAPROXY_CERTBOT_*` environment variables with the proper values, EasyHAProxy will fail silently and **will not request** a certificate. -- The ports 80 and 443 needs to accessible through the internet as [Let's Encrypt requirement](https://letsencrypt.org/docs/allow-port-80/) - -**It is required you to persist the container folder `/certs/certbot` outside the container.** - -You cannot delete or change it contents. -If you do not persist, or change/delete the contents, Let's encrypt will not work properly. - - -## Setting up your container to use ZeroSSL - -```bash -docker run \ - ... \ - --label easyhaproxy.express.port=80 \ - --label easyhaproxy.express.localport=3000 \ - --label easyhaproxy.express.host=example.org \ - --label easyhaproxy.express.certbot=true \ - ... \ - some/myimage -``` - -Requirements: - -- Your container **must** listen to port 80. Letsencrypt will not issue the certificate if `easyhaproxy..port` is in another port, and EasyHAProxy will fail silently. -- You cannot set port 443 for the container with the Letsencrypt because EasyHAProxy will create this port automatically once the certificate is issued. - ----- -[Open source ByJG](http://opensource.byjg.com) diff --git a/docs/zerossl_1.png b/docs/zerossl_1.png deleted file mode 100644 index 06810d10aaba4e2b05ed210255032b422acf8bee..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 99363 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 diff --git a/docs/zerossl_2.png b/docs/zerossl_2.png deleted file mode 100644 index aeb584134f0459262fd64f33d23dd11687354183..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 41082 zcmcG#byOT%mp69rVnjnxk##iu+w;tflIG}0lF~aDti~Tqw9#m? zJ(W!7QSbz^YmsC`w2^o&7f~a(&z)Y~dmRVl=NL6EL7NA6qRSn(N)tX32LYRI6L$Qj zpONlwsQ_$-O*G&UjE_cL(%7#gv7hyuPnyBa&}R7Y`)e{la`DXXK!v|fSpId8-)Xt% zmR|DZNGjY~u+<#-`#(dLm*Gyt3;Qdx_ld|TA=Jpp;WB^NQh8rXy->gc8XVl0(+n6< ziPXp;oSzf`FBIUNH0^8T5Nah-T$(?uGyenDbg5_%;!ej#=(jYIu~%azXOSgJ+qXoz zy}9Z1&<)gUQLpVh!LBi;dgk1$2%#~y3iY}V1nrOS$eQEgdTUvCuC9ldy6|B!$1gYR z2-H`KUXN%8kYS`h#Rn#}C3z}8-^xle6xFk!@SGrmHe57!pgTK+nDX`V^W5{7;8?8> z3sB?uF1vu2M=rRlv|yYIx(FyJ%2|Y}pgqSw@88B}U3apg%f6OU)OxHBwUw@aI2Xk+ zth;)C8r!KVvDb&Aw|`@*?S;*cvS}x4b9w9s$t~w;6@CcLF$@ejE*vwnGDf9B-^k~f z{NT7dN2(lWmI(mm_H0-(qBsa`~=bCR0ng=tMU%BJOIUnhV)K z2JrAxElNc$JnvPZj6Sh(HuB_{G&Gv9#!(cvt;u`Wh*fd!!A<(fAUzhD2P}lE^!I5$Q^!e)hzOeG-ckfE596#0gP0i!;wVv|qVT0Qe{sxvqMtg(+%eUY-WfHBx zCZV+=1{yjqvfr}a+f(M~_eU0U6?(V}3-sz$a`65qoTzru*KjWmM2p2tLfG_5h~pig zDT%v-Z6s~=hx(S}hmn=|rwhHFIyr$iZ3SQvF`NzH-Q4Rj7rW122$H;VfA{51*83A#ySQ)Iyc+9v za#bKPe?3*8cN_kggKe+FHU0RFD^A_rs*dEIYXit*@EkIGB;P)o;l*}39XO%?q0<*` zwD;k1)=-D@r!Sg=zdW7b$2Sa>B4J9*ICbf_4-@ldP)9WP^CS3+r>D>S)Mo2{vxm8V#LwI*3k;kt9gcyo)ATW>CKsI~9KafnGq2aAQ11KS! zvYVd&F?XtnY1lSfTX<};(#Kew56R)|jk~R0lZ&e@8AG7roU>l=Y`W!#6%q6}`?1Rr zFHPsKs^vJjX|nY7S!vmAV;0+N%iL_HiynooH{N^*G$yrYvv#E4aB0e;vbzAWYX+BW z_Rg7%V7QrJdc8Q%OEHpj_3@Gi;FM5!Nw8%UCK3qntGBU_FqbFmS zKDH5eC7L*{gx|HTlLK6szYU?Gt(U44H@r$`5~}#VQq5lOvCJzwwc(zrAeO(Qmx)Dc zkdgme)h)^8k(AK0(Xy_-|01U9aO@VB)-YwE6vw#NJmP%~Cr3mMt?K^7NnaF!v(Gv% zp|-wq>S8@FEpJdEMN!VEA<=cG84;et{!XSXG!`#+C`{H!tp|webh}zss*Xnck%)yS zuoSqk2)DQKDQ;tTk@iTGFADe7Yl4?5MKw3PR2OCh5pB@9Ah`P$7H-=0;>#mT|0>GF#8%K7taHB#1o9FDsI zB_9B8b2|cZfHe8q?q@9CfNBQ&(eHqUZv=1H#?2;vJ%7(czOyF6q8n)wJJzkY>f(#g z?#G&9wilTAwHox2Ap2vM7@2NJqPsXyqfG$131%|MUpSj8LJ|y~V;j%v>*as?66moN zh^~yyDY4Q4D_A2qe_7Zu{J7n!H7XnMLi2+o>s?vH)o!aADmOc(p}}O+H=HhRBp6c+ zz?hURul?u~_?zMWfU|HI|4o(_Xs5T?RAnonbvJ;wt+Ix03 zy!Eh2-!=&El_b7c-YYk!@61*tgl)(?w@ABqWNvx_=-rsx@sn<>ZMmf7mF+9Gt#ary zwC&!`qEELh0OFkR|SN)xaVx>wU&M z5U44|7gX)PgWD*-s%!fQ>+ks~XmGIw#U5YQ8=eS!-L=sd-$TWn`@Iiq_N${fEpiAG zX~FB%i#GGGW;ju5=fU?GO9WGq1SJo0aUJPoeD2Nlh$;`5W4Z>Wz~NP&06_JEe9T09 z^(EHB!VTG6<&dpy1$9v6sCnks0Nx$M8ztMgGM$Gw-a_?`>03ytG*Z3o4Z^WDTf$^$ z!pNFs+F)S--t=1hJm7s-R8bK6=S9;X^wYCgoj3`=D)%)-N{VLQR3|qyPS$4*I*lRb z*Yd-%gS6zM?)zptcO-ua=26?QJ+&rFf!|<^&x_s0hjTj?{zu6_2*2dgwg*z!`7MML zrXjHKp29R4uWw@mp@RKqEwOi5EI$oAKegWu>WvxHz?cPg?LHpwfuh4gka>vmM-4CX zeGWQ7l>Df@4{IQ854%Px@j=e{apj#(hL`m*aFu0rvxsGq{=1;~dWf`Jt=E=_d~sVD zMjFkoTQgQD{AOG+_o!KO2>*hVPawiFN>_bvy$BKj(l})W={%hXw)^6fXYjqfzM?l9 zMt#QJE8EGc7N&JxM_@ozC*S$)l~^l4n%xx`+{E%NEbbfMdtHA#swR@p%Sa6;L1dp$ zwjsM<0kw$%(n1!#ZdK_;i<49*H7Oh0c7H1(Ahc07U%*~{Wd(J?l4bjhBQWCPO6%It z6rGSJ$exZ+T%sMm?F+wsMp=H+0Rsw-t9o_tV#2k0X+2-Xdu^$8s{l^7MQ%aupGid9 z-(D-ZLZ~)MfsM;Ea60r3mjjFxTR(pnjP0_#c&=>BF@YU7?p)qCn&(Q0kr`9LUsdtW z=Kt>n@c*KACjI#fulP?Xk6jc^lAQ(nS5?pQ|F~fPZ|95Pdal&1RE&z8-2^vD-^n4Y zk%+U79SZg6uN67M-Hgcu9_c&v^tOm$N?>=9VWQmft*ib@e(`&Q#!ET|9EDQtoYUn<8~lUUQ1i5s0Xs z%g1kUn^5*^Mi%b69KY(jh@V}?Dt7U)@B>8LQ}RdEcQU%R=0OP{>L)s~LX z6feo8`$p*y^xNQTo_|lL7!xKTn^4NqN?|QB=r!^qi!9hh#!TpfetjT`Qb-vgUMOa0 z_wPtdt5%zt*tM%z6D0N9k)syBCyMnbhe@F9+KH6pE7-}^L#;i1T#~3F^#>Nw8a_;z zD#-#qHFw_FdEcIg^RA<@hBBZ24lCA+r`g%Nq()WrU8Zy0&F+!}1SKMy;L6egDUDn- z8h^a&ZQwHjjuz5;_IdTF4I=9K9c#gY75ZBcE_!EcxppQB-;E^`aNY`-J-06qFBS{8 zXR%lebrL-xhj;PZJ#c1<^Xx8>u4IAhfsDKGv#?!c}Z+L0OG-xW9WDD`^J**@k|4mWI;A8t?FQLXj8a3&)J@> zgukK0=-Dy5#_ql;eg5oJ>uN2Pja`Se?VpKOz|QFGc^#TdqUXj>OZZx34Q2H~CdBkpZ7>e(AT^Vl zd-ZW+)?Sf|O8SoLqtPjsSqRM$_&eko1YS;9D4 z`;oPKbQ4P#;sPiQS~mhY@g{6;W1(j3XRM92k(X0iXVA< zyCU!3&1;+$n>}UK1UOiudnMxKnppy{L>#Nh!M)@;2B5PIoI%R6+x2BVBqfreCx(f6 z&ao!G1$u^(+wv$4UfRxZ>-P%AJiM5+Ty2i}C)QYyvLx3G=M1 zs;ogLL2pZ{^Yx|BlVZ9W;3B0lWk*uav-6JY&)aPULA+pTHl;L*&+B9nw4OyGH{cKx zZRaJRlPUbG76IVckYi+&NyI?~!}qbG%hQclto#!)Bb{7&Tm{0SP~oj#MxNbPPJ>lj zwjk$<7!ovjTILC|=|DdH;2)Zi>n5?nN+9^4oolPmv#2@i^Jhy}W6LvAm0~ zKrWS`?|2?+83SLLhuHPxpOER*2&A#?Qr9i@i2M{`{Td?b%koCr z!W@UfpDd{MO$W@@xY^P{1lyxzq4A}ix=x{NA!2$xrCepB;!&deKNO zs=jTlf-Ph?sKN@A;knAY*vjvk`ZnxrN!BCndaArxE!i_TE2HpWTy-rcflX3 zBqj$(YkgrvI^iOAT8k0`Jm)YZF>g|F+u(&x*WO_UWf*;2=>Lr%xzx5yr5G{yad2i( zFX;GnEKY8dG({}wZ(%v~lh2mwfr2!~1){rrJ52VG?()EeQo(Fq!g4s1Q28dRB=;<; zjEaHIOOZyl8)VG0PzM(}9ICU=ORqQR^B%IUYAu?Pzr{~y`k@R(ovl40Kk6f6o@d4Na!FXchGrzh8kZ8=7^0<0$yk&o^O8DhQqM*4A1MaDbbD#C^kcAX!#+)q=lXp%1?^ipU$ z$Tfc`$S~dD_(sGJ!Ygk>L>s8&gmeaev%Nco5sv?Qbeb?5LqOi5?5hDbMH>IyxRh4a z(K@WEqJv!|sQuRTm%S)q*CIYS<%DvL5ZwjVXoj^9tc2%s_*$os@mqASlK8_8M$pa- zw&#}`*3MElw}ZQf-!On~s%1I!n99K$J5oXFqC&x*c$o8TT)jkDdsoVsfO3ri#IaugqE zRvyHxtrmlv!Ptct_{dSqA5+sHg*)1J3kTa0awe zl6k{k@WBw(uRgf*G(uK^Zf{jH1Ar2pza*+B((HFb-!qY*TPPf?R#@=Fb6&nA-2hRq&9os;KNyiq z^zMWGvl*9bHITBaBuqJyRVTjTl(|_$e*1Xmtyi5`aApk)nvT4=NyDN2$XBqPT)dsV zAv~1aQ4HS(DhvH4uQ@;XnMec?@fxTB>*i>Zp7;C(pO$0M$D3T@7D8|bD0%#pzNZ{QIsiPKA)tm zItx8nzY?75d+4J}muU8+WW(_?71zKVmvwDt+()ms2HHnF>i$Oa)ZQMf!Y_81rsNg2 zLB*_w2^#;Ft(h^*-X1!2D-xh9djt_;f+vo+^5OlU7slLRcw zyEtm|yb}H%QEUi5mP!^+;L*2H@S#2)xjdN%wirZH+Sq{s{8W} z^*p4>=tKOyZ5sn2)tDV((D%6{qlT_ZZ1a6Ln8tKH|(eAb+bv8>BVg;<*2mUxtjKai8bc~ z6^f?Rjy7mj#fDm8or{Sy%?vJhY7=`nk_m%rJHYx ze`moC#`n-_3CrE_hDUNjVI~~idn;%#xbki@h-dN<{g+z~33%a}++<6%*g@bEY0pbA zNn;7S@m!ZrRqN0fyxd?*z$})+jDu7Vc5qAUIzr3W2&d-0PJWzdIe=@xOQtYG5kV>R zyx($3<~_Tc0eiPr^T4&P&`9mT_y;bbNcY3Z$24wDkpp~YL2xfG=%a+YGiiDs_v>&3 z9CH@@Cs89Sk-+MMDS()rt-Ki{dzSqX7waK^N zgK4!~-~^K8EykC^iN^qk0!#1*TFS?`v(^_qRX9rGkLyXfbUBQqcO|Gd<|5&MYkn&u z_v$S)Y?>~blkVnYkU3uw50qjVVTMdE)m^ItW4MZ#AzQ=%r>u{I4d|C)s1T_}$Ln%u zKv~_L9$c}%kYV)e+%U#Y_hxHgpWkP8JttOZ;)Bi4-)N?V7wCeXuVqW_mGE82;L6gZR@!PF!;PYv8VvA$hb zXt7h&nUh}VPThN~!`!Qo&0;-Sh_{nGx*3uJaPg!_!dO({zI<5vV|?KL=xKGMn}iaxzzW>7zlHH#tivBPyRe+9$(QCbA> z3aQ1)D4|N4*aWLwuT?DiT8ZhT29n{8a1MW?6eakR83!!g*xj$VRmBZx5qW?e9A98| zH6@E2471h#MMEQ~AKGYgLy$v+ zpW;OJ$I~iiiGH|ugZJ$F7ib#oDV1w&cVC?<>-8=0bRp;DxXaf_%L0@z2E*KH1qq;J zQbs;G-+oJGyV(gZs2qh=W)yEhdX^QU?@6C9`EatlWtN=YEs2zIi$2-MH~&%|dLPx@ z`ojR^%A^25aB71Hurw!{h3zyp5+AFf(Ci|a>pn2`p$LeUoKwbEVW~zmaajx#-w33| zx&gDkOeS|M2}8_pNE)`%VH-;W0kOms~H@c z+E7@+4{=Z=$ELvRUC=!efsfCUkEzY$!28tYxzrehGIN7y-pSa;#H|4k5@0kfvh#sJ zJo|3|E=z82{p@Yu^?uXN`RR7(Ft*$br%$4o$P0FZDDuX!d_xlw0CJ_Au}*Gmnkk1u!YH@^+&FE~!d-k)v=Ilg(GN{U}{k;*&Ll%6;vSmJu=`2H># zvxU*dK3v6Yo5=jm(Wf=K9{sET3}@k)4F%4Zmmq??>Uy`H z#&MDbv0R>R?oewFbPp^>zZRwMt@L(16GcYr2M;1rg4#W=w@Mg`Fe+f3ipz)z$s>iLC|nQcv}vDT-{dSz8fbvnGhoX#)}u)(oS^bK^KGGALNLSZIC(;f)!#zCkN z9Q4np>G4%yK4hM9HE(h(F|MUSXvK)#9#70`c-lrh%Hp>J9cmn%HeQn9Z1MI}l$jpB zUczJLvdh*6wx)H0{C+^($Cx|fk%ZtA^9tlN1@63NkT@{hJvZvXOf+7mX&}{DduvY% zL-L6NI|T~qBbndalRCXfohl}6&$2V^OJrYaS;b4vjKkmlIoq7JM3D^D)?~AWBu*@S z6Gc=HI##-M$I{Swh>j|I9KO-tWYDX#$nZ7#+Q|x9GHa;F7f(H_3?!#9HRY*LK<;J1 z^2?VQ8f`{9S*5Fw5ECKV>~5C54;sw$E3aj%zzG*#1-8jcE13vV3=;8$B;e3$Q6QBK zl5d!v7>1p(GM+Y>RY(Yzf&jU4dOvc06DJ+~YPe-_r8_VIo?F)&_mxgZv^3+u;E5!lJye5tMNWXOWp%goBQGEWEM9=sQlq3DJqH zQrCjszO&efQZKBi%s}2XMa=HJq&TiRYXWbf#l9hG>(^QH=q2T>z+$8tCnZboo0DLk z^jmH#Dpx7f#VZgcNtuw#taHGbE@%c*{)!0`DR0H>i!}vAWVN^YmNB*CjluQm3%aA$ z(JW#fI$^j?#nI0qZpxejOqLdG>*N6ADEYDZyvw)4BW!jAA^o^Mw^7;DML9i0%X6hoqT1o=0V zU{#~uX?>B%Y~m4PNuY^d%1uge9mwhpa9?2F%mtADz0$woo98zux6;Uj`wi0tympxP2Vk_ z_kMhw-XA!t&u#o}pJFHOF($C@#nZQU4ajyG5q2s()iy&K_4%;;Vhy?RA`Su~usbo< zHwWhq)0$m;_!r_FBWc)Az0bk)-|biPBxGiTiEAIo|N z_mpEv4npf$PczdWEBBYGBZho<^jq?NZtmS|_O|HF<--~4iM`ua;Ffl>i3#>0TE|8;YCYJRxdO(kUfQM)nfzUPbim2Ix%zLdVb(qCPsYR- z8nA)J6g4!S_?}hngJ0%jv>D^dwmeHh)kBR3Bh4sk-i%-|RuygEkx&6zmT0^)1u)4Y zf!o>+IH!6D+jdPhDsRF(tQn06@h-;B{Ywb3GL?)YXNx_zP&VaV*tRb8(2~#wwjn)7 zOI&->I?X|m9?b1O6#5G-bGqB?=wmU#z;lRc1=VWea6x+=^4;N5l?UgiaEY6~gtto0 zY~Jnx@Mt#-<$?LlY*+_o?^Janm9k4$sDuOt&B9Mkh*x^qy$U?Py;;7nc3K=!qP&iY zXDvmWna^En+n~VdVCl}+l_%FGxDP?r9~TnJBzV7`VcI{Crm!r4$QrH{VQqqx*mxsg@l;DxVZf08hiO@PD6u2h}Ib6^a|y(4T62xS`>k9%u-Mf>rCDb(1l(??KO z78EIwRO5q=Yvm97T=lRw!8%Wbh~V(kit-OYhr2=~(pcj_-qTcz3b&T#pBHZ7l!;_@ z-4qsK$NGAGYEyOc+e8Q4O~TE^#jwfOvqv<3N77J-bO++Kd_t)K^lo;~wd(0pulc*L zZJU-io3qk_oK>i8$cwR12ruO&><#e@*aKpdk;GSro++MSH1;W8Qr-{!#`H@xMT{70 zkaUUSU3=a5k4GD{u3!x8UmM6gbZs;Yfmyb{(D&R#pF3Y0V}=o$1**_Ecsh9Edc5WC zy2neGGbsQ|vh&f6O8kM2zj301OfZ*)8r}`mwOEV#)DX4wCP+#YVJ4;qWy$JvyYQQ=F%R1IE6sa?gRMCLNbh8 zuyRB(Ze7s|@Ea>u%2)^Jy!dh~8O^bqHGBUaLd^A+rSTs-X9z7yEZO`j^mYIXj65?4j+TP2}x^U$3l~d^UXJ&n1 z01lDrLhixS3-dF3;4LE2UWzh0-|AKd`Y+=P(-B7P6`~`y=eI?sm{bV#ADP(Khr0p? zCfc?FN-3IZh1-~h$Y7r?H&lpM8n~_ce(q*th2lXaJlFP}NCWDjMCev0?K>rnoO9%x z6KqLLG-SiD77etg6Hnh)NU6I_e^tJg>^V-tMqRNw6J6Sb5O)dgjcUXV>rw_<0C__{ z6>m=rSSjtdYrhRTq`Y&ROnl70gNn?_kG&i3oQmB(nl5Kmu{A(3{^(ujVb0e|a14Iu z^PDwMx#Xl}i9iJ_ez3wM)7qKvEhcCXyXi*IJ!P~_Ku%bvcP%TzV_}}>@|XkMT$sS(RY>wUcn94+bB%l#aY6Ej$m-(y zslJ0G&z$OwdZyt#dT<5xt-IZ45;y5YPwmGh-j5q{XQS3DNdsL&Uh3c6=u?$-VSCC# zRNe#pTNAcQn<&vYLR?jz;iTUI%5-oYXkHLvtu6k82@7IJoBiB%$A(jn=Xma3&f#po z@KdU@%`OZ~8=1^Dqu$V^Fx@8)jT}{k3yB87Gp?-R8(6B}ZGMd89ioIhA8gHzaxI!$ z@6kND7RlSRL&NMAbNO!UI56*Obif;yeDhSb7nogB{LT{5`9b*Z@iEXZ-pu�UQ$; za;ZGYs-{llD$5}mx?WpDT3J?X>VV038liSXQKajhVF*9yY#L>dDpN{st__4q9-_%* zQp?&*sPIeK;17zGM_67u-`l=q^U>;j;HWe8>GlsJovCo8PtovmPxWjN9MGzVbHF{` zgYQ#YEZG-)m+0(0M6)-ELqD-QnO|-k>8*vDjN8i7=*o3ScntdOvLR3s(=h^jD?DF1 z=6}FHjn06-pg$dqw{2FYvfek2QOyB%E@vZ@YfEc#kc`6Z%e$5(@e`3(KOaw~^MyRK zC5%ArVICaLCUtN^ZlCCMrpoOoTV_AWHR>k3V`7wIkNg7}-aiC|##S~nPFGhlNrly- zp{-d(`6xIv{qjk|D*fZ^bT0YPK;gZpn$M4e7gUG3IMTGmG(N{^NCa$>3IJQ_V=EFdEsjZW0vfdf6l5anubU550)`wQ5_Y7mRZlwt#pb7rfEF#Ah{L;Lo;6P1nQs{wx z$-;~3>dA2!t#DW&wG^g2JBy?-DmS%)qr#1k~!Xa&^lEoo(k9W%^@9l-&D^JK=sx#q;OB}_U zMnog0k;bDPn7gpt2mo5IJY9rv>hK^t*h=nY;XUi8ZmQ<<7{u%kJgejIaZb0sV69=rR_YF1)Ah};2Qq6X$ofD*(s*=xqWr2w}cWS zPz$$_=&>mJd@$X?tmBaRxdSCeO8|fU1&TpM@OzbTl&~&RygPJ*ZJ)USYp8OCw~NpI z#<36eu8de@8OyIR+_V> z2z^{>Bm0S$ggc97x4<^@n{iLRLt>e0`VsHV2O-jzB9CS*Yq+C2e;<+H(kcyJkWfFu z=&UO&vV@vkAu~KQC2E@5?|U3dyI3y!R04@hmxPhU zXZ*!*hzWO?Gc^dGtQwEV$#K2@7$l%57}q9lFG`ADMc{<iG70H=>-XOShyJ~{OaPE$U?clJa(Oe-LlWtspU zVB&luC=z{;1P)|>y2 zWmx=Dh48LP2*D}J2Y!C5E7BNiYfn;%Q0bU8*3vB+e~s5xOh!d z{s{GCOXx{1+Bb#;!-@T{6Vl!sS3cXk-L`*o${y64Y#Sup9xBTPn|fe~Cho5<-D>KU ze$(SD2`KY%l)c)yK|XmVUCn6(9bJkusG`UgmFf7C3gER_xOUlsG{(z$Jn0i8=W0K?)X4M-L|;u&dZl~t@`B5QZawekE)nj^_SM|i;cV=arnSAbY$=cnbe_?{Sw7>W={|&>s-FU&USY4@d z)bMGZl|E(A@6$A_UmeKy9AsZTB_$>Tg^#`?Ae>2foJb0JPVa^Dj<>rx)}oDd6Ma|qP`DkU0pbj`e9Z4xJM4u-!>0KDK{eG+j& zm49vg%VVPcfAj&&zwM_>HEOBHdYREHv$E7+sa!QCd_2sk#6JTD3S647mw}S-Ybn&1 zL8`j~0RLscb(DzA{4y|~p~2B$!bj~dkftqo;fsD3A*1l;2Fd^H8>rC8dQ+jTd;_GV z>5Gqzdi19=3QLR*11oeaO&uLW2l7?6TPAex4$Ls!6v@8U`r%PMPQfR88u!Qecg@R* z#f4S!EJ(};-VhHm24ab@`qd*#1T(4%^i|bB{;diV#JCn6|oMs{EHIZ>cQ?=_cZrhL|txsCzj%XQ((k{RB zH@sTFOkf1x2zgp=b6P1;;jKqkLM8Vui(zGE=_p$N;0j{hId*l_i`z}O`PZ(r^(Cw7eib1KWlrZTC5%295iSFCxm|t6x*#NeG28 zvpJZV8Bqm78}Ky$=t)cCH7##7NMt{&B{GI+{zK~}XZ1U<4>uf{LB1AP;X`v9p~|;P zkZ*ORcZEPge580zSVcvQ!fF%pIff#z=$!i0|0`5F>sOSs!vMFt&pN54_)Tw>h_JS> zwCqq&Yre!reZ5pi4^q-I*&lm(Sk!z?`_i>leBeY|`Z4ly7YBw?a+k+`RBlcwv6or29t@>fYvmicI({LPA2#ZU>lE zA6i1j#*|*u)0^F0?GZ}^wxd2dJ(0Y8QWyUGT@|c~@^@qlVs3I=2lhr_)w&;P)H|%| zc;pPWU3A7cb>1OniFghCkPOuV1%W3aUiKAAC_AQnn-R5-@Lt9s%3$6-C64!Hg0V+*hOZGQ&bu>XedK> z6C`$y=gZ4{o{at#J%5P$-Jl;#WQDf1wY@ig^t}dtV()CQ3wB(G5fQN>;ITYCgB1{1 zGwz}+y}UfkPt(2F=+Fl5k9sfveS^YoQEw1To9CIvyOV>PGncG)CmX+pKgY#&o}w4Z zpsdA2b~nVL*F%pO=iBk{@U%yL)Ba|ZFoNDjJx!B(N=?RN}S zu~Z3>iP#`Z89BL~l?J;VTYST@KCG9;50jb>S9Jb0l*A%QKQOQpTwL5a6dj2qF22;^ z$p)#DOQ3w5`DnusXln1xcu=ZY`jT4uf?~l39Hu*cQ7l{z8DeCUflvCX#h8T!{pXSc$v-kRAfODL`hbvj1_<}-l0Bj1 z?J7RI?vxI%3*%)nn`Xb}P*hfFo=bQOpo0?bi;aAJHil4Su88;S*kgX%4X)-WvPu6q znIpkbW$f4-+8l$5_vd1brLaG7aW9AHNr@^*CS;SMtd&bnN{b_u6^~)7nBQTlSe2^( z03Gdw_CH--BU&AGDgQe9_qm#d&k5THWmO~-qCzrj)cP9{%3% zFD(KdEWREW@%$B5Ot->~z}6w9hwfB-&ux6vLgFh3SB)z@2zt@o;kVO-A8ihSO88*B z@i`pOcfnIRBbJ!=tYuUJsS&W$I*$)JH21lj8B0UbBdWR443a2`yUaK^e> zW9(0KKoMQQ10JEn?U|p5Q`kw+FV^@0v9ai(LP1Xzl9?(Z7Mu?lzr&rMK7{ua6c&>8 z?Tw~j>~B0GAzmg7B{O!X^Sw=Bf4f}g6NVg>XfEUG>Z-*twSR%B-NCQ?b!M~g41;W{ z&Qj4cph@EjQJI;D{GJl=_;5>-+L2hDc5a40W+P*}op<^bJB%@cNre74fH4B_BIduMWVVNCMpRJLUuIXQDTSX+ z{x{p8B1TZQE!b*aF~-cUT-WsQ5|k^5geuG5-oB@t=#P&QYW%|pTe}<%dQ)oM>EH5n z8?I4^dTwReGc$v$f7)eU@ZqDiZq{kaZNR54Lr1pfV$|D^u4IRAM$;>{<) z2i=VhpW8&#O1}isirBEd{?w%=Ld3bN*c- zcp?=8;>X)wGzp4>N5YJB`|h5^#r3x}68*bQa~Va2UcYGIZw>#G`VW2oF_Zsz_UJbc z3@|#_W?OH!8Ww+}hI9eO%1fE>OF`#9B!lJ`)&EPqVlRtM^I~CtEBl|+e^&h;i+QQ zj_Q+CZ$`-*Hds5m`6Z_Z9ryMtRJ9^>&~f?OyQ{bT(Z^5J8>a?^sA`p@Y8|Zx0}p1l zoyJZb2y;~!bCqcAP3rp+{*%`&ddJgUt)DddRphmpwVuF*Paq^+jSxv)>gD#vZ6ZJr3^6tLFT z-SgV(688r=ZxblrPG-No*f>sidwx2OLB_)R+=Ux^p=n}b3Nodph9MTlC+0^-Mun4$ zg^`PYB^L)LB^_LA5q!BDT*GNP^O{%dz%F%CEY-p+OG2cmHjcrc0h~5d)bk}*e~b_$&#qIc@l0{& zlBY}~n=0l=4h3U=49cr%_pznjqqQ)9Nj^#igzW#}-m2cN3dk=J=5J^JC-v{H|1bUj zD=It`g5V}LP1uN{lC!C!DFTyT7bwV-jEY?R8qbtriu+A?TlT{3rF_J7xQ`)w;09rryzvFGBFNo_^`UsO2a$4D#S3fpfvJ zgS7%~ngB&4`vl=$=hb=lLib>GDO}>2M-f$u$E8j4Fk|W=!9;p89V>S$3 z_3k1ohyA>$v_GkPJ9_yfw4pi(J@M{ZpDTSU^QwC@Viqinon15PEM=N`Cj3x<%F zkKfuizerGa+p1Qi=bdBiK4!G%B;R^n1y1rie5ML|6$p`B=NN&?`$#Zfa5${sS7;%1 zKHx%j&^)?WE==|wE84rh-L~=DBey?`t95m@ntZ>Vyee|vFd2n9Yr1IfbDf6S08=FgmWHTZ2+>@xV{!4qdg_FQI;rv98g6S|a-iRh9|JdPl`Sno=a(_GetwS@BYM&A z0ovs|m?DgoOa1FzQUI@u4L;&p&SW~i>#sPO{4ei}?8Hilcoj4Sh1SGl^bG9M3Kpv$ znXFTr(P_@y%X%^uxMBk;7)+TyRaluJ7pr7*rvCoTgXnUs72WfpBXE{FTC81d$BXl- zjk6z43MKGbHJ2FY>AvpfY?aRU-ra*uPqZkuFj6M9*LL4c|LplCzdU-in=AjFBBoNZ zaPMc5rT&pH{!u&pGnf2*Dn5mDLuuFvdN6K@iy$ZNtw|jAcP%3@R&P6x+O$}`DDjt2 zZ@*o+`j_}yu3P=&)$VTizY^NT)*ciu!OG}$zs0MiwfCp? z{*;`|y9nyLK}B%nd!!Afbx2^d%u#Mp415{SMMj9!Dsy&bcI?!Uc?)Gj`L7;Y%l_x# zgmj!g{k8wbC;KO0Hay*qxk3yykUv~%jaqE>$X<{WT0!!8E{-!Uv%1^$(X`V%4;l_d zz9d%bU!5_p8QM%H0owmdwfXIh{?@23^5C$QD2KhV*5EqSQ+~0PZKq307oiQc`5|{~kH5$a`bN+929)$4N&}wo<&6bQ(`Diwk%;DM= zT>A+_O`$DCw1%U-!VpwwJ}hiz!7xYBdX7YM+6Gjd9hGFb@qpnK0+cjtLfl7laCW$%hH{ zENW`ntugb|0GIP;s6K%2l}}%7_Yf4|OhOLacqGjTaCHpV-X>V74MfWn8}-FM#yAVb z3|b2<+8oXl*=o{ezLfq zMFOJ`+Jhxm4PouCUjERE)YsSlNomX;UE6$>h`B1FEe=Cs?8urYc$8Z{9MNTXiB1F@ z4uZt(&qW`v0ncnNZQ%)TfSFp585Xh@sQI2pqWPqfwiw0@iSg(2%7X{~Z7lNtYa06h zb^)|G2uFs(O`n+A*!qIq8cNkp#kNd%)DtHPQThk0hF!;L`KPYw9{Bt9t<3^hoo!aL z$=&rv2a8-|j_PLf^D4@ndT#O>Gg{kb%FFZ&mC?Q7(+dRMoZHaPi>HB%g&iCheuq)& z_R$yog zAyYU|#hjghwYKoTh1!W9V0}IIeqfBAmwnVI91jSo zc_T79;E)sK4VN;?h{4(UG_H8BjZNVa1Yt@gvP`VhG<w_j;`F@m9+MYIFs(@K zE5U9VeKZfeLfjbPOnsV}=5y*&eS^2kgUwWlV_P2MMOtt#bPE73$>&|gRF~1GcQ_JW z=Foja0_E2+7gJ2Ow3R&4>eFjc$}T^QAIV;v00<%eKN9eL8wP<>9#-9k&2EljXV(lL zjI_mOTf}W`)UY_6;Pp+cGs}n%*jo+W?W(5=6m6h5qg}&HW+9XuEaPyH3N@RDMfO=+ zY_`J)+$etbtim@&dBB;x7E27!l<|=iFPa*mS;^dW;ryDSFMW79WcREdvzW1PZRqVM z`j;{Mn56@1L9fa*v-?5b(@v70QXbWcVnt;&WmA;PUV6+XcOrMrSMWtsTIGiq4qC|< z0y-7NP+LyP?gwGWiFa1CYAO%dDJY*<9>Jgs=OaS5U0WaircqGln~zvm2A0XWKBtJ2j*RH1SWAY<)*K2fL~aRI02`u&;6QOB z?yhYmIdz>>bkKveT6X&|)o?AD(9A402SZ=x>G$Ao4K+Bea~d6`ebUFYx~XX)xi1(M ziXPtW>OL}wt%?6S)iBxeUrsf=C`w{%E!lAx8(i~1(QSH4%vs!BNhmsec<@O!@qpzr zb^L%;qkvtstx{R7M!HX0)}aKR9ubqb*el#Z!AibXi%*PAMu=}c>gL6s{;?m3n`JfI z^O>g|3)X;)W>#Y-RO*Ml)c8i&MRJYKn}8Mhx^cnw!a<`Xgx1b#hBYx$V{5-qa-ZNH zXS|mu>G;k&^?dq?L3m&6sOAkOA(w$iTUfMv7Z3o>cFIuzY^ygb%N2#%~sU)B3 zRu^tIEM@nNR%Xv##CB0Dyoh^=m&8i!9?s38Z)D;L zGK*3b>4tCh3ofZsfQ5`yqVwYzJ!J=Bvyj7NPGVXO&vz$yO55^=rd~G`4y|qJ;gV_A z>5H6=THDfUB0i+y+IEWM$H^~k?98vcJu}yWMfckK$Z+nRVT4fiYPAoZF6&OaO#ddM zp$|w@wAma`i~BvhSMv63A$liRGFv>*-qvRW|4N-?ze6;hhY!c6W%0=deQgiO1M940 zZQpmswa}htoV*-yKa)vaC-H}rk&@4aIKRqQr1azso3#^6$lw}oFDhbsx`v&kpf)>z z3h}iJ3f#t+9_lz-zgk@@^~AE>SHI!EglY6dMmfZ!iZ9e)3ffmo=gc+O`y!%6js8GgWg9Khv3FPye zr>F{Fn2cnuMi8}hwh1!tW-@!2kyzMzk91sdqGG-_eg^O@!Y0%18p%}f%I0*yV0xJr-h{`UpXtr?qx6AUA(S|w91OmksaKtO7l~L*F#E~*Je20gb6J>u0 z=t5)^Imw#MCQ=$Fz@r5e_FxG^N?%zL;gG7ye~H5bfMoDLws}8}_XYuC768rUtXe6N zmk<%>M{#Pze^$N{pPSFvNJ|+Wj=+^uR#rxNe|LxS1|2d+p(Aq?XFTAFLgscBquVJd zgU*hRnLnu6exVd-_Ch7ZB{lknyfsKPdh{KV2NjLOQcus9(hJy(y0c}`e~K@FO6c0X z6m2r3%f8;=u!XWyvVcz&q=m}*!PGQFKfpg?XJf-iO2|jZ@TTCpy+S`TGl<`3OUs)?hzcmzeUu4ZvdnesQEY_lHhfZvU6ZJ5{)kaMA<7^D8&@5 z?rjSJgTX;S*A;)tfB-0zosBgUDROdhQFooj58u8l>xquGn+^2|E(V=VL0O2AGog#R zyWJ9u-d1K%-rt845)hb5IoZFb8g+_bW2R^;HyKK2c}`lD>NsyZ+M9ejUN9B}%ql^b zKtV_6dhVq#M3~vU&6yRP5T(y#QD@P`mM2FzI&@h{2@Ms1LP(yW_g5Ts^tB)D4T^<} z`sdU$m=3{MFkVH~pBVZFSZYuG14%&)G^AYr6&n8)D1HJmJJi&(Y$*g%DG2_;zobdC z{E5d9Nd9a33C}w=n0y|i%Xa^qoFZa>N#)pYKWf+c3&{WW*U&`n58VFs;VbTA|I$|HKV!gM>2gBmc=96L?(!+oNd+yxQGwnOTx`d51_AS7=O8OA zGFa9>#)44Y^d5vob%3tz(V$3DuIHrBQ*SR0xYl3DF?Ei|W9!jb^X(;; z2>d_e-d~Y$dgh;j`mfOa3m*UQjXMrd|HRy%ZtvgF^4FDr#mv7!=--hTsg6r4Ec_Xz zBgG$M!M}cpNE7%QsQ(q<|KfuGl7)7D@&BP$|A%}3$A`aPeOY(oRoq(lsxZ#g+1I)j2?2!C|EkP{sRrL#R_{T}`CBVtf^SQx~q z8su3sFhn8Dh&GktZP_O*V$8$D#tMG}Lfr~P#z*~|4*zsB;$q*~|4S$Q(e_aSfBICA zqJJ67&Q;~_tG^tozZvhN!=%Nt^ZYS%{agCOdT*v3{=|)~)ZZu($@C9L>ZiZt)_2@X z(+0jjd*A-_Pdg_4lIm!`krPYur&Pa;`VT|iF&z4{)(nT=kujtteqRMW2HIPUok6Ix z#l>&?hJ?^b1Z92bnE$lw|E*T3Lz$U*y*If@K&R`D$nBk~BSZ0Ts00TYe>0eQ>Xo)H zo5?muAW1zQp}5HQYX~s%zUJ9k|Ne%C=TCy*H(dS!l%VuKAzENMu{m1n;t$#x$Xjbq1ii(_I z7#UU3wx`hRJ(IxB1pyW@3eN#(kLUvZIrd#?s^h1BurmJ&+g|&>Z2)2kI}U^YP^yIA z!^lqk3Zxw13qrn0Uhi54T{72OtG1<#MUeu(e?u*Zh)?O1E78bJ0+HHok3>`0cNiBg zeua6N5Ngd9+VxU-FI2f-J*_B+$shj58?Q7R(H$TDiI0O<^Q5pR>t*Qbz{zX?x<+tGv_vGC zp;J>`fOy20tZzSQMr+m-F3rFadGIlGvv@lcB1CkNHdzJ(1@yuIPa~aBQn=tnM7C>WV1Hc>;%!|awowC>J6TZh zr|R6+O8%;=_dAsPd1a)2lk;mOoaPIwh@VPU{VC?D6QP`tuR>vkI@tcr}@`JgfKZ})l&HWS;!}l}J z@=jifVg4&cp{5APDGiWbF&>}Hm_^EAQPK=EFFN5Ae-wsd9h)4T|18=GMc129N-l4z zh4E*{Aiyd}h8I#vnxRZ!=I%<3&7nLtiN9XN4MXOQEM-LI&3@($%BmIMQFP(*h?{uA zb1OFXpj}O~@w%~jYNdafL}lcPs_uxzBG%8_0UZuMp3678w`0UJBFm;ol&P=&YKJM%NVYEo*-*Cn zGp86Jn?@h`^u4XNr)@J0dMJHy(3?YfQw$Zqu4h$vaLLycI-%e*jO33oY74|m8S2_-}I3az3FKZ#A;q7G9}g2_Nd_GI{TL)1;<2^{fkB zti9KJDRMVtFjYxjapOrej*N=ikvs$pHuP8u zwWu};a>y$A%D^o_Vkpwpk)Uuo;ll^0jn6K>{Cd^W&<0T1jtIR+wpc z3WhAH=qL9k7djzq@D8O>2|omQ5Z8AUXjoP6Gb5bJ^=v)7RfKxSL=uEQI}sogSC~I@ zMHxO#1h3d59Bw-KNr^g^@@fx}YRDhRSs3aMiC)j3BZp9-TGtInC zHeRfRhnuRkvZ+Gqf_kmlR5rWrrN?_CFoqT}udOgpBY3zc04=#zBjj#HF6dVOiY|dZ zitg3y>9z`da{_3CpNo`*>A6WeN{Pdf;q~Cr$YqR#ZP9z!8jWUOJ5B$jH^YOh1sOyQ z0#rj~xCPUf3J&a~wM3_UP1&8PPr^3Fs85X+6pG&A-{G}cA*P6KPdedw?@mJ5*u1KL zn_y>2a@bjC$qz$Z!&@J>V1|d9ff9SMPuwi#8@U6rxG8;-GHWxgmeYYJj z09IoaKLEKjlf533Kvifm=Q&-Zv7)b?`35buwTbR*>CmTe+_HW7N)K7k=UkZG-eI)1 zI%WBUs}-~uGH>!)yOl9Ltr6NM-1Vu|u9B!x0^jj)%Bk(F``C=^q_SnwG~(s_1cu9a zet4zP#H6#jlG_!ZYYq`%_Wg?Y13q`gsUu%Hc8w4rkW|;P6&-cHu)W6HE)# z;8-)bf@vwjy;NDg!Q6`7XO;18Q(xdxhl^e%(Iu>qqEzFn?g!Q+407_Q<6~O@0I)n= zVK^JKO4OgoBC2}d+9-Q3t1YRmdjHPO7F#qiBKoUwo`*@>Av7Q7YoPZbZE#}o}%}xI4oeMWXdQkB?{j}f;VLczuhQQ8@ z>gL+l9yz^^C1EL!uT-ncQ?9kveBnZcI)oN-H<>Y}i_w;t$p&`zr^cy`$sw|eL{Ja= zQPfVihKiM7VwC`XApo^l=|OPrC0dVp#`6y*-K+aRGE>JXk7=U3!#K`q@7)lh2oL@C zB!UkgdO9s8^zI~y<((|wdBT;Jy*->;vhL2>8!6X8XFyiy%MqCL zITLGkA6f~sN)HK%5NVsCs?XOEBtp{nVKjC+UJr>l{wOC7+`>9D(?UHb68cD4#E+Q} z@BgJWC{w#el4SzKk3}l5xj9!H5xk@ud8$w#6?NHmWW(;KnYVgt$;P@iJpFbIBCV}L?rdR@-Sj?o@Y(pw)YOSL z9{X^<)z8p9`qr;5Q21MGrRKxyh}c2L&;=nHlc+W1cSH&HDJG#kp9WuNtF+A?N?c2LPDQUs5k>Vn1BFlJXlW#$b74A>Y=BmE@Up z^xeHSo3eRoQdjGGzqN)S^G5R1gE2}o2sTQ#*Np*LPh@-bRsMqUOJ@lS3y)3Z;_Wev z^F_5Vf|?)1B-w71?94RJ?nzp_&t3t~?kNFzDbYmLsM%zWpx7~in6CnYeIDZ-_l6hG zL~hlM?OzTaO-F?F-}o#rs6*|~f1I9H%gM^+tgmS6VZJDHbEx9rON_d{JX3M96 zuB6_}j)S=F8|oIfVDQl`UQ;$Gd(By^wQX~s-=XZCQ`iTwTu zxzZdZgB0E?uXFNo!g5%59n!$zZQd@4uMpN3V>TK$Du(yGb|$0a9$3xkQri__=SwIxBXTIW2sn1k;ydV0!0DQ|i8^-TvI zV!qrrot!4(e-0PYeL-odfEib~5oN^1x`dUxZ%S7mB-SopO^kT=3wQkpDEkK4?7XAt%+!4LiL6ys#|_r! z?g#RxtSk)hkWk2&?A;pr2icU|BCxJDUabW_78O;6Wjhq`?nmFvk@N02A|@v0jnyIw zM6DJ^Jike3wb;X|+M|TX`q6svEf%@p3(h7qRZ(tnaW;R{VC-2AAzHqnZv?RnB=~T$ z%Doet+buIJ*&s$Xw|8TA@o{|?@8Ti?OOpV_;#{JFdk$|sMnjZB3ET{D%D5B--D+lw z_oufK6`PFVSV=C`bleo=LBP+e+<51f< zo1Ep6Ns(ZgM?ed&!c7Atig` zU;}^P%r#r$g+~)hjsl-7zge?x=LjngPyiY-D$fWfUZ&E|`Z&KbS(WVy;@ZnPiB8+> zsVNMy_L=q&dMO03%l%;KQHIFd-vZ@?@wV$|VYvME{Xt+m#~wzlqvTPypqt_K z_qZnjnpt;0J&W*Zw@0m7QM-XBXD<%EtlbinrUNi4T&9)qFkvKJcOFjHR*=9R%5awU zdtwbfaFMUXI}QBka6#wG9$QsAz+|_5krtNQFeQB8rzGt(4p)*3PH_;`n5 zP3X&8QiIRyt{M?BGLONVj$MVxyw{qqR6(c3*prtcqpW1XrSOn4_3F-_>!<|v(pj5% z71V0S(|aiMQ6wfQ>7%dj6BI&Lv8ZVfxiwKXNrDp%=rk_B`0v4 zY7(z~HZpE=X!O)oP3AAV-H4l>_p1dn!!`wTiVx6G8%Vwmy`K0m%L~+5kV2^Gt+l4| ztOrRr;@o_g$LT~@1M_2pKy?-O>Ct|BCh5t5rioA++z#XX(+gcGxuqbNJ&6C_k_fJ^ z8W4^@l~FlL!a>Pg1I^!Xk_I!+J%j^?*y)m5!fsajkzp1EpzP`VLnfa@K6IBC%X9bN@rDxv3RPg@z%f$boJ_laN?d zaBGmH1cmqRG=fd3obPOC9hyF-bHwa#*kJ88I3PYBDZe~A9wXZiA7&&Nzj=42NuFI+ zSG$ESX*kzoAB_Pw=#4Zu^kBIdm?85HfsaWcDmUUpp9?T+HR$t{Lj%1>H*?k|=UV?w znS?2aL8}`f5|0Gw(8rl$RhA=e{CK%w+!@5<88EgWxHUI_ab20Au|L9i?Kut2+H!k< zd$qgcQRGWTjgLZLQ?KV{`o)kJj5%yQ8(kt_XmlL0x&0PHb+go{Fb~8qFEjV?oWzwI zJgb|1tK7WzUM!^^w}B!zME4}C5BiGk2o)=$TARS$WCEz2ZT{ZQOaJL@jtL22L_F%E z0g2!`ePu`+(zK~pN{{xXL}AJW+OiR@_#w*qaE;p*!;Y4B2#V5#*w_fne? znb{bI3?f-Hh*xeqg%lyi5Qv1~YsBVWlptDYO7ZX{3lbI5)=omh92 z{GJ&gB@_}7|)m`%AD?SHruS8yCq}8N@ko|Z&t|S@V>6C=z z(jZ3&jrsLNlyo{bbDH@d6S6U1&e>#wdw_1I7=(mx@Yfn&iZZ<}p4#X-FM)yAcR>lJPRA|-d76HDzjeM@a9+?{V;(Lxvtyg^J{!970}RG3adq(=`K zOIh`fpPEp;fL0+9LS}78aOrm7O-*`R{dodL+^5hS1oVBd7wNk_a*^-;m;Db}?qOgx zcUm%a^^f(y6GMKTA|kfOa1iSP%_f5P;T~r0J~ldrD2S@6DNFx8{fcT!FUeO|N8$jE zX=P5kCFD)Gqy zuNEJhtn}m7RzWk{?Bre#6Z8J9_B9Be;B^VjlILjzSw59!-7~j-g1zVkidaw-D03hs z6Fm8uR?%7trEeG+DYzMf?vw3d9p(0L7G5fy8FcFaGC|8cl}!R*0Cxs%Cg%27yX;oJ z%=`uSgmu86?p>g`&B8}(Jo1bXL4J5>zG{Dun$Y}ZCG3H-PCo}@eMN?g`y+972L}=5 z%gDryxWMtS)~tts2s@M0Cc7i(p|$J{zT<Pu)uZ8EfJ{js)9`Eqkt2s_+!?lDCSSKS708M*J;xF~L7!4k-bO%zgddGIk7aA3NujS7k>fB0kbT26ODv;~pw9N> zEnWP8W_%@Nb$sc&r8Tur5q3!d_Z6tbefrHUCV~5H4?4=^q=h+5W1U{PdUAeIayYL5 zJA$Vt(hEPXbp_wo@@vhVwU?1?a(pQ_96w2g1X{;SSGh-qrW#|2xb)_Rndc4!Eh>S` zNOL_dVtc$3@&%RH^F-C1ec4mXl`Su6)u!^`h|uROYOL+^wLdp#`g{1?q9zHrt99>5 zq|7vNT|iu6`#O}c#*}K5=GPF$`gydw0NxD^(~ocdTU_YvC0o)dn$Zt+srmxVkdB>hUeY~la?homM8cuGZJkAJ-h<5aFy9}Q`T{3 zo)9wQ+H^QX82|GLJ|nP}8SJee);|mqZ4Px#SOac#Zk`B{MmBb@#vmQVQ5~-?oXamL zH<3^5ec9<;V0Q0S4=ZB;p39TRt7;nTQeknOZ4Y8 zopqS9G``GuT8^6IVDh-1aw0$l^HKqBpsdapGH<4GM_%5`^jOUW;p=*0?oF1^pQ+P- zA4Y!MeCQ{dM)oF6T*S+-7QB$jnRRAkO2khfvpFC-lrC^?#DHS74GWnjcoAj^qZ`sw z`F2IEzCDt-v*LpZKQQcK#=QM$1AK3Ol@tM_F3rM)0&>qMBs@^hsiDlweDi3I59)lw z!p_el)XK=PIy&TAJlz0h4~&n6auMA#z+8HUvQ0gxgRlUIEVnHk^d%PAg|YO>z?z;_ z5*vS8k61@an4|E^Zkv|wrco1ZM9^(Ekj2uPLhyE`CA;NVETbE--j7BR17WwTVK>!p zS4mohh1Fo8v8_Ao%=nbY9p|O$;LRlpL4^&uMztv=C9vRwc8BVNU8kbrAQjIkk--|{ zP{4Z_)+mF!G0}qzrivW>?_Gni>D#Ow(AmxfHWAg-^pvoglQ64mOu*w^Q@OMId3-~> zAj4K(mM<5?9{BEGPLE}_1^F76x$gH`Q~d*VwMTsxeUt|wFS2-{KbW`K$8f+RG`Ok1 zjd@%8#beN^F}xm-j_t|pXpI2o&j?Z?o@!<6eFr*euHd#g3V+MV{NlOc-r{qBCVxeN zcg&ULg|cO@K$$*EjNJvD&i!*38WL~EVF3Ny{c&N*`I~zE{Hkw7*xQ8;@S_-n@5QZ$ z$W9{;rk{orJe5t(xoFsYh}p`h0}DX+m{EG}7UbFfSns`?V|`8N-X5`8i=;n`2i)*> z@v9=AM__HvTR!PQdq<+x!Q9=Ww@M&~a@CK#kSEcR@p%|0!9TL2feO=S0E0E=2HBV?Xy>#O((HdSp7MFWm(yt*GI0skz%k8K{JKEi?ntbh=5 zH&5_M!?hHG4Bl?>iP!DLgj|k{=!a=IRNun|WSxrvu4|tU-!S%;SoBsyab54vr_P~` zkwgjIg)`-KyY8+82&$YJd-#_~I0}`5EY?6LhfF4?Eu7!z0A>jl?q^}9np3zF^F*te zEo0?EbS`)tloxG|XCAqvL=6@~E+tX7?n7-OSE)6;*HC!qJHGLUgZb}g2%YBm!=fe< zzHQ!F_qP*nUz!?@J39M$>}|2wf2}(>I#KMq@48Q{-oh}xFb~#HP90eZroTD(0BZxn zUpNwlt~0kSSt8S>u?gfodA*s$vu)?&#U1xZkV9q>#^CAC5d{!U@Xo}P zE);mF@0fkiv(j2)Wj1FBT}$|vl6#CZ>AJw;Xi$K82kDwO-z zGQe(HN7plq$nki_VFN_gv6HDG4HU`i)ypq;eTKknmbKu%ZN5q9X2J7rlfBhtlZqdS zqod^>&9zRq4K(hNKe~u@wcDQ!F(g(qPF?Fp8+~sK64$lRY!vF=&GQyVqQyDFwdf;GRzjN7%~HM48ob<6MmTP+yj58r{}0(#~c zcxlR8(!)L>RYky&(>&8u9_0G9pwAJO+WF%Ft zcroK-$hC%p*93+Qf#?$VWT82#8u)vxP}d|A)@ zMKY;xB#YWh+_+?C@I!K{#jdOf1B2(2S34IrIhyqlCUyX`S>1cBtCp73m9fBzA8C{M zO9w9N;o2W=4ttWD7en3+NT|#sdR~7(<>aiE?L*wT(MX+2MbE(CeU zN2KeDw0ur?uMFcbb^V5-z_5cgL)Lq27MT>wCGA*CyWDUw*%Vhqfz2)o|Fmd(5pF)D zTTUH`p(85HXw43Gr ztvq&$B#~~<_10K*89Q3k9L05C3IMcgo-~7%{X7;}Ds7Y~4Vw-2lRc?Wh=(b{-1kPYKn(`p_QJ?5-J4X#Fmxy~X$ZZk-o_%CZyz_Agt*E5g zGIhKb*yL5%Y%t_D5JO7%74@@>t4mt48c#pvJ|`QIuxfwPQ9Nu4zTk!>^CAIxNLmGA zBIh740CtILeJOig#8f)8(?nz2{XRXvgqS*Y#DCQ247WJydlTHN($?_O?7IqHky}++ zl%68(YM z-ko-QeW4dx3v1%7H9)DPxgfMM$0#PsE5JQscQ~Hnik|xml6iN>+eLB)0Z zV%T#L=lBbMnmO|!kxzJ?NfxV^$#vma9jnVh`$RR>a^zOt-;=&hVCEdmY3hQ$JS>)u zNNNplxbzKKCSqKip4#plQz;!Wh$g!2Vpo?WRv|OX;VU`0A+dQ4G(l7S3;Vp00mE^K zkn3dOmwZmvWr2-v;3IE+fmH!)!lvVHP@69$)+$mKzxSIfUJ@Ejm?KQ{84^%Tc}D!S z3LGYlTa?^7#jKi8m_KQqb&ayY4%qQA)LTHx--jHqrM0rZ9(pLVme^_-;e>!)QC!4E zcD4K7FFG*`Z}r8NBnyYlW^eaTRd6n{<2Jth+K9L{bz`deOtpMeh%{4S2Z7rj*Gtk} ze^;GDz=DgIED%!zVTpyHW4Yf#?JlA=uvUCdgw06*$VSIidyYuvn{3x5r^vLG1owrm zY?s}_b61>shHn9=`UVVw6CD?`tc-wM6w72o7PqbFLb;ptMCV0-Y?=HS`HEk!JLE(k zgvmEse7Dl-K*;5u*k$ z3bjk&JB~MGb%A^{#5SI*fjSzWn=CYsZ|r0xL=y4Jm@>cwo7z+^Nl+Eq%|EK_S{)1v zgpN4VITbbFph|OB?W3RGuQGc$W7JBl-`4`l310qaXN^ZA4Fxg^p9jv!30ZliiCi6# zFvPNrv{rW6XK_(}IIOa*#qGex9_}gOQqgQK=8Qdfz~-Ga^%q1Qde*~#`jbyKJ|z6^ z#ER+VEs%@qiRCh0PaMFRZ(}1jENZ)izjYuB#9M~}Zb|pS_69xl)2z(fy_&`!QlXrW zyxisN2=*whM6Qo zc_&&5@X+htax4O!Vhi7X(r6nmRZJni;l-ou=WO>FGdjECB}uolyIQ(R&1a=#81(=U znWbkEdiKoqOt20U918 zyb*+E2LqYObS*M>YAE0q+Y64Xm?>1ONH-)T=VmLX1N5H*6eOWelAV`=O2az?haJVb zsSPu0omBHNa%q+|aYY!vUt4^xb-K^v0q`-1n%cEOX7upXDOnO*_)C}1#}rSdzGGOT zRsiKi)JWF7J)c=ly(+@M{q6^SYQs(q=emx$w}&%&EZ_kSjFMa5C$i!=OzbY|cy8&| zBnVmiq#c#8v2Gr!6IyCLz;fqX;?m%2QRLqK_1yJzGT|MOGin3&1Tug*f#F_2^fW%t zZR*P&GV)c3dIt=h)0{`XZXNlggRb;z(ZqvbLOG?*fZ56LfhTM}!Jq6tuxoI-l3z!# zW`va(*BQZzWF74mKaka`3qU64Gk$Z_cwv&P_vD__!v*q`vOM_VT-9$J`-5Cb_*0^{ zD&2IpSvjA~jTCR3!P_pPfHb?*e#qtRJRmOZI+JgrE zriD=RrQ~iA=;>x6Hu~z;A^9R}mEJ08!ie+m@*FTc+0Giw8N5PX!k zvTfD02xX@$}!z-&u z9DYKRmo-MJ?BE<>Ol7)6WXXpeQqPWEsaUGl`DHSH%mj6OK4s$+I=y7(36ob7c8gts z)LVr1hLW%faq<|LSMp{w%-7zk>>gIH&|qIYJJ(=P%2=%SQO zFd8M_GCaTbflfFNMp&_|)l5yw&_zZR{$6z(F|r&L9V0a2;@-)L&v-i%$O`z9+1BeX zl(V zOu^gjz>iJuPZ#9|k-~bo4~~GG90c^}-TJ5TsR2gbVO(;{vnp$xPB}YLHnM8ifsF8m z^(Q)b@Ev(1$)?iA3bD32$?aLI>bU==H^@6<;mjH`}AdkEpT`U)}}*qQxO5 zpI^qbx7f@V�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 U(%0;P?-_u=)78&qol`;+012D26#xJL diff --git a/src/functions/__init__.py b/src/functions/__init__.py index e595dd4..4349d71 100644 --- a/src/functions/__init__.py +++ b/src/functions/__init__.py @@ -28,7 +28,7 @@ class ContainerEnv: "EASYHAPROXY_LABEL_PREFIX") else "easyhaproxy" env_vars["certbot"] = { - "autoconfig": os.getenv("EASYHAPROXY_CERTBOT_AUTOCONFIG", "letsencrypt"), + "autoconfig": os.getenv("EASYHAPROXY_CERTBOT_AUTOCONFIG", ""), "email": os.getenv("EASYHAPROXY_CERTBOT_EMAIL", ""), "server": os.getenv("EASYHAPROXY_CERTBOT_SERVER", False), "eab_kid": os.getenv("EASYHAPROXY_CERTBOT_EAB_KID", ""), @@ -164,8 +164,9 @@ class Functions: Functions.log(source, Functions.WARN, process.stderr.readlines()) break - return output + return [return_code, output] except Exception as e: + return [-99, e] Functions.log(source, Functions.ERROR, "%s" % e) diff --git a/src/tests/fixtures/run_bash.sh b/src/tests/fixtures/run_bash.sh new file mode 100755 index 0000000..75a66bb --- /dev/null +++ b/src/tests/fixtures/run_bash.sh @@ -0,0 +1,7 @@ +#!/bin/bash + +# Just return the exit code +echo "Processing run_bash.sh" +if [ -n "$1" ]; then + exit "$1" +fi diff --git a/src/tests/test_containerenv.py b/src/tests/test_containerenv.py index 3c60928..46df944 100644 --- a/src/tests/test_containerenv.py +++ b/src/tests/test_containerenv.py @@ -8,7 +8,7 @@ def test_container_env_empty(): "customerrors": False, "ssl_mode": "default", "lookup_label": "easyhaproxy", - "certbot": {"autoconfig": "letsencrypt", + "certbot": {"autoconfig": "", "eab_hmac_key": "", "eab_kid": "", "email": "", @@ -25,7 +25,7 @@ def test_container_env_customerrors(): "customerrors": True, "ssl_mode": "default", "lookup_label": "easyhaproxy", - "certbot": {"autoconfig": "letsencrypt", + "certbot": {"autoconfig": "", "eab_hmac_key": "", "eab_kid": "", "email": "", @@ -42,7 +42,7 @@ def test_container_env_sslmode(): "customerrors": False, "ssl_mode": "strict", "lookup_label": "easyhaproxy", - "certbot": {"autoconfig": "letsencrypt", + "certbot": {"autoconfig": "", "eab_hmac_key": "", "eab_kid": "", "email": "", @@ -60,7 +60,7 @@ def test_container_env_stats(): "customerrors": False, "ssl_mode": "default", "lookup_label": "easyhaproxy", - "certbot": {"autoconfig": "letsencrypt", + "certbot": {"autoconfig": "", "eab_hmac_key": "", "eab_kid": "", "email": "", @@ -84,7 +84,7 @@ def test_container_env_stats_password(): "port": "1936" }, - "certbot": {"autoconfig": "letsencrypt", + "certbot": {"autoconfig": "", "eab_hmac_key": "", "eab_kid": "", "email": "", @@ -108,7 +108,7 @@ def test_container_env_stats_password_2(): "password": "xyz", "port": "2101" }, - "certbot": {"autoconfig": "letsencrypt", + "certbot": {"autoconfig": "", "eab_hmac_key": "", "eab_kid": "", "email": "", @@ -128,11 +128,11 @@ def test_container_env_certbot_email(): "ssl_mode": "default", "lookup_label": "easyhaproxy", "certbot": { - "autoconfig": "letsencrypt", + "autoconfig": "", 'eab_hmac_key': "", 'eab_kid': "", "email": "acme@example.org", - "server": 'https://acme-v02.api.letsencrypt.org/directory' + "server": False } } == ContainerEnv.read() finally: @@ -150,7 +150,7 @@ def test_container_env_certbot_full(): "ssl_mode": "default", "lookup_label": "easyhaproxy", "certbot": { - "autoconfig": "letsencrypt", + "autoconfig": "", "email": "acme@example.org", "server": "schema://url/a", 'eab_hmac_key': 'eab_hmac_key', diff --git a/src/tests/test_functions.py b/src/tests/test_functions.py index 861e8d1..728ab80 100644 --- a/src/tests/test_functions.py +++ b/src/tests/test_functions.py @@ -65,8 +65,9 @@ def test_functions_run_bash_log_output(): print() Functions.debug_log = [] try: - result = Functions.run_bash(Functions.EASYHAPROXY_LOG, "echo 'test run 1'", log_output=True, - return_result=False) + return_code, result = Functions.run_bash(Functions.EASYHAPROXY_LOG, "echo 'test run 1'", log_output=True, + return_result=False) + assert return_code == 0 assert result == [] assert len(Functions.debug_log) == 1 assert re.match("\[EASYHAPROXY\] .* \[INFO\]: test run 1", Functions.debug_log[0]) @@ -78,8 +79,9 @@ def test_functions_run_bash_no_log_output(): print() Functions.debug_log = [] try: - result = Functions.run_bash(Functions.EASYHAPROXY_LOG, "echo 'test run 2'", log_output=False, - return_result=False) + return_code, result = Functions.run_bash(Functions.EASYHAPROXY_LOG, "echo 'test run 2'", log_output=False, + return_result=False) + assert return_code == 0 assert result == [] assert len(Functions.debug_log) == 0 finally: @@ -90,8 +92,9 @@ def test_functions_run_bash_return(): print() Functions.debug_log = [] try: - result = Functions.run_bash(Functions.EASYHAPROXY_LOG, "echo 'test run 3'", log_output=False, - return_result=True) + return_code, result = Functions.run_bash(Functions.EASYHAPROXY_LOG, "echo 'test run 3'", log_output=False, + return_result=True) + assert return_code == 0 assert len(Functions.debug_log) == 0 assert "".join(result) == 'test run 3' finally: @@ -102,9 +105,51 @@ def test_functions_run_bash_log_and_return_output(): print() Functions.debug_log = [] try: - result = Functions.run_bash(Functions.EASYHAPROXY_LOG, "echo 'test run 4'", log_output=True, return_result=True) + return_code, result = Functions.run_bash(Functions.EASYHAPROXY_LOG, "echo 'test run 4'", log_output=True, return_result=True) + assert return_code == 0 assert "".join(result) == 'test run 4' assert len(Functions.debug_log) == 1 assert re.match("\[EASYHAPROXY\] .* \[INFO\]: test run 4", Functions.debug_log[0]) finally: Functions.debug_log = None + + +def test_functions_run_bash_ok(): + print() + Functions.debug_log = [] + try: + return_code, result = Functions.run_bash(Functions.EASYHAPROXY_LOG, "%s/fixtures/run_bash.sh" % os.path.dirname(__file__), log_output=True, + return_result=False) + assert return_code == 0 + assert result == [] + assert len(Functions.debug_log) == 1 + assert re.match("\[EASYHAPROXY\] .* \[INFO\]: Processing run_bash.sh", Functions.debug_log[0]) + finally: + Functions.debug_log = None + + +def test_functions_run_bash_fail(): + print() + Functions.debug_log = [] + try: + return_code, result = Functions.run_bash(Functions.EASYHAPROXY_LOG, "%s/fixtures/run_bash.sh 15" % os.path.dirname(__file__), log_output=True, + return_result=False) + assert return_code == 15 + assert result == [] + assert len(Functions.debug_log) == 1 + assert re.match("\[EASYHAPROXY\] .* \[INFO\]: Processing run_bash.sh", Functions.debug_log[0]) + finally: + Functions.debug_log = None + + +def test_functions_run_command_not_found(): + print() + Functions.debug_log = [] + try: + return_code, result = Functions.run_bash(Functions.EASYHAPROXY_LOG, "no_command_here", log_output=True, + return_result=False) + assert return_code == -99 + assert str(result) == "[Errno 2] No such file or directory: 'no_command_here'" + assert len(Functions.debug_log) == 0 + finally: + Functions.debug_log = None From 382368d58d7b4c89c0dcd4293ac5311545a2e26a Mon Sep 17 00:00:00 2001 From: Joao Gilberto Magalhaes Date: Sun, 2 Jul 2023 10:05:30 -0500 Subject: [PATCH 26/33] Minor changes --- src/functions/__init__.py | 2 +- src/tests/test_functions.py | 3 ++- 2 files changed, 3 insertions(+), 2 deletions(-) diff --git a/src/functions/__init__.py b/src/functions/__init__.py index 4349d71..3ea2dae 100644 --- a/src/functions/__init__.py +++ b/src/functions/__init__.py @@ -166,8 +166,8 @@ class Functions: return [return_code, output] except Exception as e: - return [-99, e] Functions.log(source, Functions.ERROR, "%s" % e) + return [-99, e] class Consts: diff --git a/src/tests/test_functions.py b/src/tests/test_functions.py index 728ab80..e31a0ea 100644 --- a/src/tests/test_functions.py +++ b/src/tests/test_functions.py @@ -150,6 +150,7 @@ def test_functions_run_command_not_found(): return_result=False) assert return_code == -99 assert str(result) == "[Errno 2] No such file or directory: 'no_command_here'" - assert len(Functions.debug_log) == 0 + assert len(Functions.debug_log) == 1 + assert re.match("\[EASYHAPROXY\] .* \[ERROR\]: \[Errno 2\] No such file or directory: 'no_command_here'", Functions.debug_log[0]) finally: Functions.debug_log = None From c96860c44955f639f24382cbe648e70cf0fbe9e1 Mon Sep 17 00:00:00 2001 From: Joao Gilberto Magalhaes Date: Sun, 2 Jul 2023 10:56:05 -0500 Subject: [PATCH 27/33] Changes in the process of issue/renew certificate --- src/functions/__init__.py | 78 +++++++++++++++++++++++++++------------ 1 file changed, 54 insertions(+), 24 deletions(-) diff --git a/src/functions/__init__.py b/src/functions/__init__.py index 3ea2dae..334cf59 100644 --- a/src/functions/__init__.py +++ b/src/functions/__init__.py @@ -188,7 +188,8 @@ class DaemonizeHAProxy: self.__prepare( "/usr/sbin/haproxy -W -f /etc/haproxy/haproxy.cfg -p /run/haproxy.pid -S /var/run/haproxy.sock") else: - pid = "".join(Functions().run_bash(Functions.HAPROXY_LOG, "cat /run/haproxy.pid", log_output=False)) + return_code, pid_tmp = Functions().run_bash(Functions.HAPROXY_LOG, "cat /run/haproxy.pid", log_output=False) + pid = "".join(pid_tmp) self.__prepare( "/usr/sbin/haproxy -W -f /etc/haproxy/haproxy.cfg -p /run/haproxy.pid -x /var/run/haproxy.sock -sf %s" % pid) @@ -258,6 +259,7 @@ class Certbot: self.acme_server = self.set_acme_server(env["certbot"]["server"]) self.eab_kid = self.set_eab_kid(env["certbot"]["eab_kid"]) self.eab_hmac_key = self.set_eab_hmac_key(env["certbot"]["eab_hmac_key"]) + self.freeze_issue = {} @staticmethod def set_acme_server(acme_server): @@ -291,30 +293,23 @@ class Certbot: try: request_certs = [] renew_certs = [] - current_time = time.time() for host in hosts: - filename = "%s/%s.pem" % (self.certs, host) + cert_status = self.get_certificate_status(host) host_arg = '-d %s' % host - if not os.path.exists(filename): - Functions.log(Functions.CERTBOT_LOG, Functions.DEBUG, "Request new certificate for %s" % host) + if cert_status == "ok" or cert_status == "error": + continue + elif host in self.freeze_issue: + freeze_count = self.freeze_issue.pop(host, 0) + if freeze_count > 0: + Functions.log(Functions.CERTBOT_LOG, Functions.DEBUG, + "Waiting freezing period (%d) for %s due previous errors" % (freeze_count, host)) + self.freeze_issue[host] = freeze_count-1 + elif cert_status == "not_found" or cert_status == "expired": + Functions.log(Functions.CERTBOT_LOG, Functions.DEBUG, "[%s] Request new certificate for %s" % (cert_status, host)) request_certs.append(host_arg) - else: - try: - with open(filename, 'rb') as file: - certificate_str = file.read() - certificate = crypto.load_certificate(crypto.FILETYPE_PEM, certificate_str) - expiration_after = datetime.strptime(certificate.get_notAfter().decode()[:-1], - '%Y%m%d%H%M%S').timestamp() - - if current_time >= expiration_after: - Functions.log(Functions.CERTBOT_LOG, Functions.DEBUG, - "Request expired certificate for %s" % host) - request_certs.append(host_arg) - if (expiration_after - current_time) // (24 * 3600) <= 15: - Functions.log(Functions.CERTBOT_LOG, Functions.DEBUG, "Renew certificate for %s" % host) - renew_certs.append(host_arg) - except Exception as e: - Functions.log(Functions.CERTBOT_LOG, Functions.ERROR, "Certificate %s error %s" % (host, e)) + elif cert_status == "expiring": + Functions.log(Functions.CERTBOT_LOG, Functions.DEBUG, "[%s] Renew certificate for %s" % (cert_status, host)) + renew_certs.append(host_arg) certbot_certonly = ('/usr/bin/certbot certonly {acme_server}' ' --standalone' @@ -334,17 +329,24 @@ class Certbot: ) ret_reload = False + return_code_issue = 0 + return_code_renew = 0 if len(request_certs) > 0: - Functions.run_bash(Functions.CERTBOT_LOG, certbot_certonly, return_result=False) + return_code_issue, output = Functions.run_bash(Functions.CERTBOT_LOG, certbot_certonly, return_result=False) ret_reload = True if len(renew_certs) > 0: - Functions.run_bash(Functions.CERTBOT_LOG, "/usr/bin/certbot renew", return_result=False) + return_code_renew, output = Functions.run_bash(Functions.CERTBOT_LOG, "/usr/bin/certbot renew", return_result=False) ret_reload = True if ret_reload: self.find_live_certificates() + if return_code_issue != 0: + self.find_missing_certificates(request_certs) + if return_code_renew != 0: + self.find_missing_certificates(renew_certs) + return ret_reload except Exception as e: Functions.log(Functions.CERTBOT_LOG, Functions.ERROR, "%s" % e) @@ -365,3 +367,31 @@ class Certbot: key = Functions.load(os.path.join(path, "privkey.pem")) filename = "%s/%s.pem" % (self.certs, item) self.merge_certificate(cert, key, filename) + + def get_certificate_status(self, host): + current_time = time.time() + filename = "%s/%s.pem" % (self.certs, host) + if not os.path.exists(filename): + return "not_found" + + try: + with open(filename, 'rb') as file: + certificate_str = file.read() + certificate = crypto.load_certificate(crypto.FILETYPE_PEM, certificate_str) + expiration_after = datetime.strptime(certificate.get_notAfter().decode()[:-1], '%Y%m%d%H%M%S').timestamp() + if current_time >= expiration_after: + return "expired" + elif (expiration_after - current_time) // (24 * 3600) <= 15: + return "expiring" + except Exception as e: + Functions.log(Functions.CERTBOT_LOG, Functions.ERROR, "Certificate %s error %s" % (host, e)) + return "error" + + return "ok" + + def find_missing_certificates(self, hosts): + for host in hosts: + cert_status = self.get_certificate_status(host) + if cert_status != "ok": + self.freeze_issue[host] = 5 + Functions.log(Functions.CERTBOT_LOG, Functions.DEBUG, "Freezing issue ssl for %s due failure. The certificate is %s" % (host, cert_status)) From ac1b84a842ca0d7f77ae0b755b30bff5fd930a52 Mon Sep 17 00:00:00 2001 From: Joao Gilberto Magalhaes Date: Sun, 2 Jul 2023 11:06:52 -0500 Subject: [PATCH 28/33] Changes in the process of issue/renew certificate --- src/functions/__init__.py | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/src/functions/__init__.py b/src/functions/__init__.py index 334cf59..c5e8c99 100644 --- a/src/functions/__init__.py +++ b/src/functions/__init__.py @@ -391,7 +391,9 @@ class Certbot: def find_missing_certificates(self, hosts): for host in hosts: + if host.startswith("-d "): + host = host[3:] cert_status = self.get_certificate_status(host) if cert_status != "ok": self.freeze_issue[host] = 5 - Functions.log(Functions.CERTBOT_LOG, Functions.DEBUG, "Freezing issue ssl for %s due failure. The certificate is %s" % (host, cert_status)) + Functions.log(Functions.CERTBOT_LOG, Functions.DEBUG, "Freeze issuing ssl for %s due failure. The certificate is %s" % (host, cert_status)) From 4bb1c7931d29a223bc8b080630bdf27f695dde25 Mon Sep 17 00:00:00 2001 From: Joao Gilberto Magalhaes Date: Sun, 2 Jul 2023 11:19:51 -0500 Subject: [PATCH 29/33] Added Retry Count --- docs/acme.md | 1 + docs/docker-environment.md | 1 + src/functions/__init__.py | 4 +++- src/tests/test_containerenv.py | 23 ++++++++++++++++------- 4 files changed, 21 insertions(+), 8 deletions(-) diff --git a/docs/acme.md b/docs/acme.md index 9de7088..5c24fdf 100644 --- a/docs/acme.md +++ b/docs/acme.md @@ -13,6 +13,7 @@ To enable the ACME protocol we need to enable Certbot in EasyHAProxy by setting - EASYHAPROXY_CERTBOT_SERVER (optional): The ACME Endpoint of your certificate authority. If you use AUTOCONFIG, it is set automatically. See table below. - EASYHAPROXY_CERTBOT_EAB_KID (optional): External Account Binding (EAB) Key Identifier (KID) provided by your certificate authority. Some CA require it. See table below. - EASYHAPROXY_CERTBOT_EAB_HMAC_KEY (optional): External Account Binding (EAB) HMAC Key provided by your certificate authority. Some CA require it. See table below. +- EASYHAPROXY_CERTBOT_RETRY_COUNT (optional): Wait 'n' requests before retrying issue invalid requests. Default 60. Here are detailed instructions per Certificate Authority (CA). If anyone is missing, please let's know. diff --git a/docs/docker-environment.md b/docs/docker-environment.md index 54cb3e9..2415e82 100644 --- a/docs/docker-environment.md +++ b/docs/docker-environment.md @@ -8,6 +8,7 @@ | EASYHAPROXY_CERTBOT_SERVER | (Optional) Can be `staging` or 'schema://domain.tld', if using other service than Letsencrypt. Might be necessary set EASYHAPROXY_CERTBOT_EAB_KID and EASYHAPROXY_CERTBOT_EAB_HMAC_KEY | *empty* | | EASYHAPROXY_CERTBOT_EAB_KID | (Optional) eab-kid configuration when required. | *empty* | | EASYHAPROXY_CERTBOT_EAB_HMAC_KEY | (Optional) eab-kid-hmac-key configuration when required. | *empty* | +| EASYHAPROXY_CERTBOT_RETRY_COUNT | (Optional) Wait 'n' requests before try re-issue invalid calls | | EASYHAPROXY_SSL_MODE | (Optional) `strict` supports only the most recent TLS version; `default` good SSL integration with recent browsers; `loose` supports all old SSL protocols for old browsers (not recommended). | `default` | | EASYHAPROXY_REFRESH_CONF | (Optional) Check configuration every N seconds. | 10 | | EASYHAPROXY_LOG_LEVEL | (Optional) The log level for EasyHAproxy messages. Available: TRACE,DEBUG,INFO,WARN,ERROR,FATAL | DEBUG | diff --git a/src/functions/__init__.py b/src/functions/__init__.py index c5e8c99..a331697 100644 --- a/src/functions/__init__.py +++ b/src/functions/__init__.py @@ -33,6 +33,7 @@ class ContainerEnv: "server": os.getenv("EASYHAPROXY_CERTBOT_SERVER", False), "eab_kid": os.getenv("EASYHAPROXY_CERTBOT_EAB_KID", ""), "eab_hmac_key": os.getenv("EASYHAPROXY_CERTBOT_EAB_HMAC_KEY", ""), + "retry_count": int(os.getenv("EASYHAPROXY_CERTBOT_RETRY_COUNT", 60)), } if env_vars["certbot"]["autoconfig"] != "" and not env_vars["certbot"]["server"] and env_vars["certbot"]["email"] != "": @@ -260,6 +261,7 @@ class Certbot: self.eab_kid = self.set_eab_kid(env["certbot"]["eab_kid"]) self.eab_hmac_key = self.set_eab_hmac_key(env["certbot"]["eab_hmac_key"]) self.freeze_issue = {} + self.retry_count = env["certbot"]["retry_count"] @staticmethod def set_acme_server(acme_server): @@ -395,5 +397,5 @@ class Certbot: host = host[3:] cert_status = self.get_certificate_status(host) if cert_status != "ok": - self.freeze_issue[host] = 5 + self.freeze_issue[host] = self.retry_count Functions.log(Functions.CERTBOT_LOG, Functions.DEBUG, "Freeze issuing ssl for %s due failure. The certificate is %s" % (host, cert_status)) diff --git a/src/tests/test_containerenv.py b/src/tests/test_containerenv.py index 46df944..beac0c1 100644 --- a/src/tests/test_containerenv.py +++ b/src/tests/test_containerenv.py @@ -12,7 +12,8 @@ def test_container_env_empty(): "eab_hmac_key": "", "eab_kid": "", "email": "", - "server": False} + "server": False, + "retry_count": 60} } == ContainerEnv.read() # os.environ['CERTBOT_LOG_LEVEL'] = 'warn' @@ -29,7 +30,8 @@ def test_container_env_customerrors(): "eab_hmac_key": "", "eab_kid": "", "email": "", - "server": False} + "server": False, + "retry_count": 60} } == ContainerEnv.read() finally: os.environ['HAPROXY_CUSTOMERRORS'] = '' @@ -46,7 +48,8 @@ def test_container_env_sslmode(): "eab_hmac_key": "", "eab_kid": "", "email": "", - "server": False} + "server": False, + "retry_count": 60} } == ContainerEnv.read() finally: os.environ['EASYHAPROXY_SSL_MODE'] = '' @@ -64,7 +67,8 @@ def test_container_env_stats(): "eab_hmac_key": "", "eab_kid": "", "email": "", - "server": False} + "server": False, + "retry_count": 60} } == ContainerEnv.read() finally: os.environ['HAPROXY_USERNAME'] = '' @@ -88,7 +92,8 @@ def test_container_env_stats_password(): "eab_hmac_key": "", "eab_kid": "", "email": "", - "server": False} + "server": False, + "retry_count": 60} } == ContainerEnv.read() finally: os.environ['HAPROXY_PASSWORD'] = '' @@ -112,7 +117,8 @@ def test_container_env_stats_password_2(): "eab_hmac_key": "", "eab_kid": "", "email": "", - "server": False} + "server": False, + "retry_count": 60} } == ContainerEnv.read() finally: os.environ['HAPROXY_USERNAME'] = '' @@ -132,7 +138,8 @@ def test_container_env_certbot_email(): 'eab_hmac_key': "", 'eab_kid': "", "email": "acme@example.org", - "server": False + "server": False, + "retry_count": 60 } } == ContainerEnv.read() finally: @@ -144,6 +151,7 @@ def test_container_env_certbot_full(): os.environ['EASYHAPROXY_CERTBOT_SERVER'] = 'schema://url/a' os.environ['EASYHAPROXY_CERTBOT_EAB_KID'] = 'eab_kid' os.environ['EASYHAPROXY_CERTBOT_EAB_HMAC_KEY'] = 'eab_hmac_key' + os.environ['EASYHAPROXY_CERTBOT_RETRY_COUNT'] = "10" try: assert { "customerrors": False, @@ -155,6 +163,7 @@ def test_container_env_certbot_full(): "server": "schema://url/a", 'eab_hmac_key': 'eab_hmac_key', 'eab_kid': 'eab_kid', + 'retry_count': 10 } } == ContainerEnv.read() finally: From 166b75cd754b0ce4ce6d3a18794ca713ee9d8f08 Mon Sep 17 00:00:00 2001 From: Joao Gilberto Magalhaes Date: Mon, 3 Jul 2023 14:59:17 -0500 Subject: [PATCH 30/33] Minor Fix Pre-Merge --- docs/kubernetes.md | 19 +++++----- docs/volumes.md | 14 ++++---- examples/swarm/easyhaproxy.yml | 4 +-- examples/swarm/portainer.yml | 4 +-- src/functions/__init__.py | 18 +++++++--- src/processor/__init__.py | 2 +- src/templates/bind.j2 | 2 +- src/tests/expected/docker.txt | 2 +- src/tests/expected/services-letsencrypt.txt | 2 +- src/tests/expected/services-redirect-ssl.txt | 2 +- src/tests/expected/services.txt | 2 +- src/tests/expected/static.txt | 2 +- src/tests/test_containerenv.py | 37 +++++++++++--------- src/tests/test_daemonize.py | 22 +++++++----- src/tests/test_docker.py | 2 +- src/tests/test_functions.py | 8 ++--- 16 files changed, 77 insertions(+), 65 deletions(-) diff --git a/docs/kubernetes.md b/docs/kubernetes.md index 9354b91..b3fac3c 100644 --- a/docs/kubernetes.md +++ b/docs/kubernetes.md @@ -79,17 +79,14 @@ Notes: ## Kubernetes annotations -| annotation | Description | Default | Example | -|----------------------------------|-----------------------------------------------------------------------------------------|--------------|---------------------------------------| -| kubernetes.io/ingress.class | (required) Activate EasyHAProxy. | **required** | easyhaproxy-ingress | -| easyhaproxy.redirect_ssl | (optional) Boolean. Force redirect all endpoints to HTTPS. | false | true or false | -| easyhaproxy.letsencrypt | (optional) Boolean. It will request letsencrypt certificates for the ingresses domains. | false | true or false | -| easyhaproxy.redirect | (optional) JSON. Key pair with a domain and its destination. | *empty* | {"domain":"redirect_url"} | -| easyhaproxy.mode | (optional) Set the HTTP mode for that connection. | http | http or tcp | -| easyhaproxy.listen_port | (optional) Set the an additional port for that ingress | http | http or tcp | -| easyhaproxt.logLevel.certbot | (optional) Certbot log level | DEBUG | TRACE,DEBUG,INFO,WARN,ERROR or FATAL | -| easyhaproxt.logLevel.eashhaproxy | (optional) EasyHAProxy log level | DEBUG | TRACE,DEBUG,INFO,WARN,ERROR or FATAL | -| easyhaproxt.logLevel.haproxy | (optional) HAProxy log level | INFO | TRACE,DEBUG,INFO,WARN,ERROR or FATAL | +| annotation | Description | Default | Example | +|----------------------------------|-------------------------------------------------------------------------------------|--------------|---------------------------------------| +| kubernetes.io/ingress.class | (required) Activate EasyHAProxy. | **required** | easyhaproxy-ingress | +| easyhaproxy.redirect_ssl | (optional) Boolean. Force redirect all endpoints to HTTPS. | false | true or false | +| easyhaproxy.certbot | (optional) Boolean. It will request certbot certificates for the ingresses domains. | false | true or false | +| easyhaproxy.redirect | (optional) JSON. Key pair with a domain and its destination. | *empty* | {"domain":"redirect_url"} | +| easyhaproxy.mode | (optional) Set the HTTP mode for that connection. | http | http or tcp | +| easyhaproxy.listen_port | (optional) Set the an additional port for that ingress | http | http or tcp | **Important**: The annotations are per ingress and applied to all hosts in that ingress configuration. diff --git a/docs/volumes.md b/docs/volumes.md index 4413095..47daa02 100644 --- a/docs/volumes.md +++ b/docs/volumes.md @@ -2,13 +2,13 @@ You can map the following volumes: -| Volume | Description | -|-----------------------------|----------------------------------------------------------------------------------------| -| /etc/haproxy/static/ | The folder that will contain the [config.yml](static.md) file for static configuration | -| /certs/haproxy/ | The folder that will contain the certificates (`PEM`) for the [SSL](ssl.md) | -| /certs/letsencrypt/ | The folder that will contain the certificates (`PEM`) for the SSL. Use this volume to cache the [letsencrypt](letsencrypt.md) certificate and avoid re-issue certificates between restarts. | -| /etc/haproxy/conf.d/ | The folder that will contain the [custom configuration](other.md) files. | -| /etc/haproxy/errors-custom/ | The folder that will contain the [custom error](other.md) html files. | +| Volume | Description | +|-----------------------------|-------------------------------------------------------------------------------------------------------------------------------| +| /etc/haproxy/static/ | The folder that will contain the [config.yml](static.md) file for static configuration | +| /certs/haproxy/ | The folder that will contain the certificates (`PEM`) for the [SSL](ssl.md) | +| /certs/certbot/ | The folder that will contain the certificates (`PEM`) processed by Certbot (e.g. Let's Encrypt). More info: [acme](acme.md). | +| /etc/haproxy/conf.d/ | The folder that will contain the [custom configuration](other.md) files. | +| /etc/haproxy/errors-custom/ | The folder that will contain the [custom error](other.md) html files. | ---- [Open source ByJG](http://opensource.byjg.com) diff --git a/examples/swarm/easyhaproxy.yml b/examples/swarm/easyhaproxy.yml index 3d088ef..a15260b 100644 --- a/examples/swarm/easyhaproxy.yml +++ b/examples/swarm/easyhaproxy.yml @@ -11,7 +11,7 @@ services: volumes: - /var/run/docker.sock:/var/run/docker.sock - ./certs:/certs/haproxy - - certs_letsencrypt:/certs/letsencrypt + - certs_certbot:/certs/certbot deploy: replicas: 1 environment: @@ -34,7 +34,7 @@ networks: external: true volumes: - certs_letsencrypt: + certs_certbot: # external: true # certs_haproxy: # external: true \ No newline at end of file diff --git a/examples/swarm/portainer.yml b/examples/swarm/portainer.yml index b438b58..d259988 100644 --- a/examples/swarm/portainer.yml +++ b/examples/swarm/portainer.yml @@ -13,13 +13,13 @@ services: replicas: 1 labels: # easyhaproxy.http.redirect_ssl: true - # easyhaproxy.http.letsencrypt: true + # easyhaproxy.http.certbot: true easyhaproxy.http.host: portainer.local easyhaproxy.http.port: 80 easyhaproxy.http.localport: 9000 volumes: - certs_letsencrypt: + certs_certbot: external: true # certs_haproxy: # external: true diff --git a/src/functions/__init__.py b/src/functions/__init__.py index 70f8ed8..1b3b1c0 100644 --- a/src/functions/__init__.py +++ b/src/functions/__init__.py @@ -27,6 +27,13 @@ class ContainerEnv: env_vars["lookup_label"] = os.getenv("EASYHAPROXY_LABEL_PREFIX") if os.getenv( "EASYHAPROXY_LABEL_PREFIX") else "easyhaproxy" + env_vars["logLevel"] = { + "easyhaproxy": os.getenv("EASYHAPROXY_LOG_LEVEL") if os.getenv( + "EASYHAPROXY_LOG_LEVEL") else Functions.DEBUG, + "haproxy": os.getenv("HAPROXY_LOG_LEVEL") if os.getenv("HAPROXY_LOG_LEVEL") else Functions.INFO, + "certbot": os.getenv("CERTBOT_LOG_LEVEL") if os.getenv("CERTBOT_LOG_LEVEL") else Functions.DEBUG, + } + env_vars["certbot"] = { "autoconfig": os.getenv("EASYHAPROXY_CERTBOT_AUTOCONFIG", ""), "email": os.getenv("EASYHAPROXY_CERTBOT_EMAIL", ""), @@ -72,7 +79,7 @@ class ContainerEnv: env_vars["certbot"]["eab_kid"] = os.environ['EASYHAPROXY_CERTBOT_EAB_KID'] = resp["eab_kid"] env_vars["certbot"]["eab_hmac_key"] = os.environ['EASYHAPROXY_CERTBOT_EAB_HMAC_KEY'] = resp["eab_hmac_key"] else: - os.environ["EASYHAPROXY_CERTBOT_EMAIL"] = "" + del os.environ["EASYHAPROXY_CERTBOT_EMAIL"] Functions.log(Functions.CERTBOT_LOG, Functions.ERROR, "Could not obtain ZeroSSL credentials " + resp["error"]["type"]) os.environ['EASYHAPROXY_CERTBOT_SERVER'] = env_vars["certbot"]["server"] @@ -195,16 +202,17 @@ class DaemonizeHAProxy: self.thread = Process(target=self.__start, args=()) self.thread.start() - def get_haproxy_command(self, action): + def get_haproxy_command(self, action, pid_file="/run/haproxy.pid"): custom_config_files = "" if len(list(self.get_custom_config_files().keys())) != 0: custom_config_files = "-f %s" % (self.custom_config_folder) if action == "start": - return "/usr/sbin/haproxy -W -f /etc/haproxy/haproxy.cfg %s -p /run/haproxy.pid -S /var/run/haproxy.sock" % (custom_config_files) + return "/usr/sbin/haproxy -W -f /etc/haproxy/haproxy.cfg %s -p %s -S /var/run/haproxy.sock" % (custom_config_files, pid_file) else: - pid = "".join(Functions().run_bash(Functions.HAPROXY_LOG, "cat /run/haproxy.pid", log_output=False)) - return "/usr/sbin/haproxy -W -f /etc/haproxy/haproxy.cfg %s -p /run/haproxy.pid -x /var/run/haproxy.sock -sf %s" % (custom_config_files, pid) + return_code, output = Functions().run_bash(Functions.HAPROXY_LOG, "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) def __prepare(self, command): source = Functions.HAPROXY_LOG diff --git a/src/processor/__init__.py b/src/processor/__init__.py index 5373b75..0d3f8a5 100644 --- a/src/processor/__init__.py +++ b/src/processor/__init__.py @@ -198,7 +198,7 @@ class Kubernetes(ProcessorInterface): self.cert_cache = {} super().__init__() - def _check_annotation(self, annotations, key, default = None): + def _check_annotation(self, annotations, key, default=None): if key not in annotations: return default return annotations[key] diff --git a/src/templates/bind.j2 b/src/templates/bind.j2 index e4f264e..d69de8f 100644 --- a/src/templates/bind.j2 +++ b/src/templates/bind.j2 @@ -1,5 +1,5 @@ {% if "ssl" in o %} - bind *:{{ o["port"] }} ssl crt /certs/letsencrypt/ alpn h2,http/1.1 crt /certs/haproxy/ alpn h2,http/1.1 + bind *:{{ o["port"] }} ssl crt /certs/certbot/ alpn h2,http/1.1 crt /certs/haproxy/ alpn h2,http/1.1 {% elif "h2" in o and o["h2"] %} bind *:{{ o["port"] }} proto h2 option http-use-htx diff --git a/src/tests/expected/docker.txt b/src/tests/expected/docker.txt index 92c7430..91f928b 100644 --- a/src/tests/expected/docker.txt +++ b/src/tests/expected/docker.txt @@ -36,7 +36,7 @@ backend srv_stats server Local 127.0.0.1:1936 frontend http_in_443 - bind *:443 ssl crt /certs/letsencrypt/ alpn h2,http/1.1 crt /certs/haproxy/ alpn h2,http/1.1 + bind *:443 ssl crt /certs/certbot/ alpn h2,http/1.1 crt /certs/haproxy/ alpn h2,http/1.1 mode http acl is_rule_hostssl_local_443_1 hdr(host) -i hostssl.local diff --git a/src/tests/expected/services-letsencrypt.txt b/src/tests/expected/services-letsencrypt.txt index ff7dd8c..8d5da5d 100644 --- a/src/tests/expected/services-letsencrypt.txt +++ b/src/tests/expected/services-letsencrypt.txt @@ -74,7 +74,7 @@ backend srv_test2_example_org_80 server srv-0 83d57d592e26:8080 check weight 1 frontend http_in_443 - bind *:443 ssl crt /certs/letsencrypt/ alpn h2,http/1.1 crt /certs/haproxy/ alpn h2,http/1.1 + bind *:443 ssl crt /certs/certbot/ alpn h2,http/1.1 crt /certs/haproxy/ alpn h2,http/1.1 mode http acl is_rule_test_example_org_443_1 hdr(host) -i test.example.org diff --git a/src/tests/expected/services-redirect-ssl.txt b/src/tests/expected/services-redirect-ssl.txt index 01c9c09..ad841cc 100644 --- a/src/tests/expected/services-redirect-ssl.txt +++ b/src/tests/expected/services-redirect-ssl.txt @@ -49,7 +49,7 @@ backend srv_host1_local_80 server srv-0 5b69bc7fea1b:80 check weight 1 frontend http_in_443 - bind *:443 ssl crt /certs/letsencrypt/ alpn h2,http/1.1 crt /certs/haproxy/ alpn h2,http/1.1 + bind *:443 ssl crt /certs/certbot/ alpn h2,http/1.1 crt /certs/haproxy/ alpn h2,http/1.1 mode http acl is_rule_host2_local_443_1 hdr(host) -i host2.local diff --git a/src/tests/expected/services.txt b/src/tests/expected/services.txt index f6aaf6a..82f8a6c 100644 --- a/src/tests/expected/services.txt +++ b/src/tests/expected/services.txt @@ -67,7 +67,7 @@ backend srv_node-exporter_quantum_example_org_31337 server srv-0 my-stack_node-exporter:9100 check weight 1 frontend http_in_443 - bind *:443 ssl crt /certs/letsencrypt/ alpn h2,http/1.1 crt /certs/haproxy/ alpn h2,http/1.1 + bind *:443 ssl crt /certs/certbot/ alpn h2,http/1.1 crt /certs/haproxy/ alpn h2,http/1.1 mode http redirect prefix https://www.somehost.com.br code 301 if { hdr(host) -i somehost.com.br } redirect prefix https://www.somehost.com.br code 301 if { hdr(host) -i somehost.com } diff --git a/src/tests/expected/static.txt b/src/tests/expected/static.txt index b643379..a5af1ec 100644 --- a/src/tests/expected/static.txt +++ b/src/tests/expected/static.txt @@ -74,7 +74,7 @@ backend srv_host2_com_br_80 server srv-0 other:3000 check weight 1 frontend http_in_443 - bind *:443 ssl crt /certs/letsencrypt/ alpn h2,http/1.1 crt /certs/haproxy/ alpn h2,http/1.1 + bind *:443 ssl crt /certs/certbot/ alpn h2,http/1.1 crt /certs/haproxy/ alpn h2,http/1.1 mode http acl is_rule_host1_com_br_443_1 hdr(host) -i host1.com.br diff --git a/src/tests/test_containerenv.py b/src/tests/test_containerenv.py index 9a3f3aa..30c2680 100644 --- a/src/tests/test_containerenv.py +++ b/src/tests/test_containerenv.py @@ -13,7 +13,7 @@ def test_container_env_empty(): "haproxy": Functions.INFO, "certbot": Functions.DEBUG, }, - "certbot": {"autoconfig": "", + "certbot": {"autoconfig": "", "eab_hmac_key": "", "eab_kid": "", "email": "", @@ -44,7 +44,7 @@ def test_container_env_customerrors(): "retry_count": 60} } == ContainerEnv.read() finally: - os.environ['HAPROXY_CUSTOMERRORS'] = '' + del os.environ['HAPROXY_CUSTOMERRORS'] def test_container_env_sslmode(): @@ -67,7 +67,7 @@ def test_container_env_sslmode(): "retry_count": 60} } == ContainerEnv.read() finally: - os.environ['EASYHAPROXY_SSL_MODE'] = '' + del os.environ['EASYHAPROXY_SSL_MODE'] def test_container_env_stats(): @@ -91,8 +91,8 @@ def test_container_env_stats(): "retry_count": 60} } == ContainerEnv.read() finally: - os.environ['HAPROXY_USERNAME'] = '' - os.environ['HAPROXY_STATS_PORT'] = '' + del os.environ['HAPROXY_USERNAME'] + del os.environ['HAPROXY_STATS_PORT'] def test_container_env_stats_password(): @@ -121,7 +121,7 @@ def test_container_env_stats_password(): "retry_count": 60} } == ContainerEnv.read() finally: - os.environ['HAPROXY_PASSWORD'] = '' + del os.environ['HAPROXY_PASSWORD'] def test_container_env_stats_password_2(): @@ -151,9 +151,9 @@ def test_container_env_stats_password_2(): "retry_count": 60} } == ContainerEnv.read() finally: - os.environ['HAPROXY_USERNAME'] = '' - os.environ['HAPROXY_STATS_PORT'] = '' - os.environ['HAPROXY_PASSWORD'] = '' + del os.environ['HAPROXY_USERNAME'] + del os.environ['HAPROXY_STATS_PORT'] + del os.environ['HAPROXY_PASSWORD'] def test_container_env_certbot_email(): @@ -178,7 +178,7 @@ def test_container_env_certbot_email(): } } == ContainerEnv.read() finally: - os.environ['EASYHAPROXY_CERTBOT_EMAIL'] = '' + del os.environ['EASYHAPROXY_CERTBOT_EMAIL'] def test_container_env_certbot_full(): @@ -192,10 +192,6 @@ def test_container_env_certbot_full(): "customerrors": False, "ssl_mode": "default", "lookup_label": "easyhaproxy", - "letsencrypt": { - "email": "acme@example.org", - "server": True - }, "logLevel": { "easyhaproxy": Functions.DEBUG, "haproxy": Functions.INFO, @@ -211,7 +207,12 @@ def test_container_env_certbot_full(): } } == ContainerEnv.read() finally: - os.environ['EASYHAPROXY_LETSENCRYPT_EMAIL'] = '' + del os.environ['EASYHAPROXY_CERTBOT_EMAIL'] + del os.environ['EASYHAPROXY_CERTBOT_SERVER'] + del os.environ['EASYHAPROXY_CERTBOT_EAB_KID'] + del os.environ['EASYHAPROXY_CERTBOT_EAB_HMAC_KEY'] + del os.environ['EASYHAPROXY_CERTBOT_RETRY_COUNT'] + def test_container_log_level(): os.environ['CERTBOT_LOG_LEVEL'] = Functions.TRACE @@ -231,10 +232,12 @@ def test_container_log_level(): "autoconfig": "", 'eab_hmac_key': "", 'eab_kid': "", - "email": "acme@example.org", + "email": "", "server": False, "retry_count": 60 } } == ContainerEnv.read() finally: - os.environ['EASYHAPROXY_CERTBOT_EMAIL'] = '' + del os.environ['CERTBOT_LOG_LEVEL'] + del os.environ['EASYHAPROXY_LOG_LEVEL'] + del os.environ['HAPROXY_LOG_LEVEL'] diff --git a/src/tests/test_daemonize.py b/src/tests/test_daemonize.py index fb1ea08..e5d87c9 100644 --- a/src/tests/test_daemonize.py +++ b/src/tests/test_daemonize.py @@ -1,10 +1,7 @@ -import json -import pytest import os -import re -import random -import string -from functions import DaemonizeHAProxy + +from functions import DaemonizeHAProxy, Functions + def test_daemonize_haproxy(): daemon = DaemonizeHAProxy() @@ -38,7 +35,14 @@ def test_daemonize_haproxy_get_haproxy_command_start(): command = daemon.get_haproxy_command("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_haproxy_get_haproxy_command_reload(): - daemon = DaemonizeHAProxy(os.path.abspath(os.path.dirname(__file__)) + '/fixtures') - command = daemon.get_haproxy_command("reload") - assert command == "/usr/sbin/haproxy -W -f /etc/haproxy/haproxy.cfg -f %s -p /run/haproxy.pid -x /var/run/haproxy.sock -sf " % (os.path.dirname(__file__) + "/fixtures") + 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("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) diff --git a/src/tests/test_docker.py b/src/tests/test_docker.py index b6b95e1..e31063e 100644 --- a/src/tests/test_docker.py +++ b/src/tests/test_docker.py @@ -100,7 +100,7 @@ def test_processor_docker(): 'hostssl.local.pem': 'Some PEM Certificate' } finally: - os.environ['EASYHAPROXY_CERTBOT_EMAIL'] = '' + del os.environ['EASYHAPROXY_CERTBOT_EMAIL'] container.stop() container2.stop() diff --git a/src/tests/test_functions.py b/src/tests/test_functions.py index e31a0ea..770cd55 100644 --- a/src/tests/test_functions.py +++ b/src/tests/test_functions.py @@ -13,15 +13,15 @@ def test_functions_check_local_level(): os.environ['CERTBOT_LOG_LEVEL'] = 'warn' assert Functions.skip_log('CERTBOT', Functions.INFO) == True - os.environ['CERTBOT_LOG_LEVEL'] = '' + del os.environ['CERTBOT_LOG_LEVEL'] os.environ['HAPROXY_LOG_LEVEL'] = 'warn' assert Functions.skip_log('HAPROXY', Functions.INFO) == True - os.environ['HAPROXY_LOG_LEVEL'] = '' + del os.environ['HAPROXY_LOG_LEVEL'] os.environ['EASYHAPROXY_LOG_LEVEL'] = 'warn' assert Functions.skip_log('EASYHAPROXY', Functions.INFO) == True - os.environ['EASYHAPROXY_LOG_LEVEL'] = '' + del os.environ['EASYHAPROXY_LOG_LEVEL'] def test_function_load_and_save(): @@ -57,7 +57,7 @@ def test_functions_check_log_sanity(): assert len(Functions.debug_log) == 2 finally: - os.environ['EASYHAPROXY_LOG_LEVEL'] = '' + del os.environ['EASYHAPROXY_LOG_LEVEL'] Functions.debug_log = None From 63048adf0fcb1c092977d3f3cd62506f0cc5ff12 Mon Sep 17 00:00:00 2001 From: Joao Gilberto Magalhaes Date: Mon, 3 Jul 2023 15:26:56 -0500 Subject: [PATCH 31/33] Minor Fix Pre-Merge --- docs/acme.md | 82 ++++++++++++++++--- docs/docker.md | 2 +- ...environment.md => environment-variable.md} | 8 +- docs/kubernetes.md | 2 +- docs/letsencrypt.md | 55 ------------- docs/static.md | 2 +- docs/swarm.md | 2 +- 7 files changed, 78 insertions(+), 75 deletions(-) rename docs/{docker-environment.md => environment-variable.md} (72%) delete mode 100644 docs/letsencrypt.md diff --git a/docs/acme.md b/docs/acme.md index 5c24fdf..f695726 100644 --- a/docs/acme.md +++ b/docs/acme.md @@ -6,28 +6,34 @@ allowing the automated deployment of public key infrastructure. Most of the issuers offers Automatic Issuing free of cost. +## Environment Variables + To enable the ACME protocol we need to enable Certbot in EasyHAProxy by setting up to 4 environment variables: -- EASYHAPROXY_CERTBOT_AUTOCONFIG (optional): Will use pre-sets for your Certificate Authority (CA). See table below. -- EASYHAPROXY_CERTBOT_EMAIL (required): Your email in the certificate authority. -- EASYHAPROXY_CERTBOT_SERVER (optional): The ACME Endpoint of your certificate authority. If you use AUTOCONFIG, it is set automatically. See table below. -- EASYHAPROXY_CERTBOT_EAB_KID (optional): External Account Binding (EAB) Key Identifier (KID) provided by your certificate authority. Some CA require it. See table below. -- EASYHAPROXY_CERTBOT_EAB_HMAC_KEY (optional): External Account Binding (EAB) HMAC Key provided by your certificate authority. Some CA require it. See table below. -- EASYHAPROXY_CERTBOT_RETRY_COUNT (optional): Wait 'n' requests before retrying issue invalid requests. Default 60. +| Environment Variable | Required? | Description | +|----------------------------------|-----------|----------------------------------------------------------------------------------------------------------------------------------| +| EASYHAPROXY_CERTBOT_EMAIL | YES | Your email in the certificate authority. | +| EASYHAPROXY_CERTBOT_AUTOCONFIG | - | Will use pre-sets for your Certificate Authority (CA). See table below. | +| EASYHAPROXY_CERTBOT_SERVER | - | The ACME Endpoint of your certificate authority. If you use AUTOCONFIG, it is set automatically. See table below. | +| EASYHAPROXY_CERTBOT_EAB_KID | - | External Account Binding (EAB) Key Identifier (KID) provided by your certificate authority. Some CA require it. See table below. | +| EASYHAPROXY_CERTBOT_EAB_HMAC_KEY | - | External Account Binding (EAB) HMAC Key provided by your certificate authority. Some CA require it. See table below. | +| EASYHAPROXY_CERTBOT_RETRY_COUNT | - | Wait 'n' requests before retrying issue invalid requests. Default 60. | + +## Auto Config Certificate Authority (CA) Here are detailed instructions per Certificate Authority (CA). If anyone is missing, please let's know. | CA | Auto Config | Free? | Account Required? | EAB KID? | EAB HMAC Key? | More Info | |----------------------|------------------|-------|--------------------|----------|---------------|--------------------------------------------------------------------------------------------------------------------------------------------------------------------| -| Let's Encrypt | - | Yes | No | No | No | [Link](letsencrypt.md) | -| Let's Encrypt (Test) | letsencrypt_test | Yes | No | No | No | [Link](letsencrypt.md) | +| Let's Encrypt | - | Yes | No | No | No | - | +| Let's Encrypt (Test) | letsencrypt_test | Yes | No | No | No | - | | ZeroSSL | zerossl | Yes | No | No | No | [Link](https://zerossl.com/documentation/acme/) | | BuyPass | buypass | Yes | No | No | No | [Link](https://community.buypass.com/t/63d4ay/buypass-go-ssl-endpoints-updated-14-05-2020) | | BuyPass (test) | buypass_test | Yes | No | No | No | [Link](https://community.buypass.com/t/63d4ay/buypass-go-ssl-endpoints-updated-14-05-2020) | | Google | google | Yes | Yes | Yes | Yes | [Link](https://cloud.google.com/blog/products/identity-security/automate-public-certificate-lifecycle-management-via--acme-client-api) | | Google Test | google_test | Yes | Yes | Yes | Yes | [Link](https://cloud.google.com/blog/products/identity-security/automate-public-certificate-lifecycle-management-via--acme-client-api) | -| SSLCOM RCA | sslcom_rca | Yes | No. Keys by email. | Yes | Yes | [Link](https://www.ssl.com/blogs/sslcom-supports-acme-protocol-ssl-tls-certificate-automation/) | -| SSLCOM ECC | sslcom_ecc | Yes | No. Keys by email. | Yes | Yes | [Link](https://www.ssl.com/blogs/sslcom-supports-acme-protocol-ssl-tls-certificate-automation/) | +| SSLCOM RCA | sslcom_rca | Trial | EAB Keys by email. | Yes | Yes | [Link](https://www.ssl.com/blogs/sslcom-supports-acme-protocol-ssl-tls-certificate-automation/) | +| SSLCOM ECC | sslcom_ecc | Trial | EAB Keys by email. | Yes | Yes | [Link](https://www.ssl.com/blogs/sslcom-supports-acme-protocol-ssl-tls-certificate-automation/) | | Digicert | - | No | Yes | Yes | Yes | [Link](https://docs.digicert.com/en/certcentral/certificate-tools/certificate-lifecycle-automation-guides/use-a-third-party-acme-client-for-host-automations.html) | | Entrust | - | No | Yes | Yes | Yes | [Link](https://www.entrust.com/knowledgebase/ssl/how-to-use-acme-to-install-ssl-tls-certificates-in-entrust-certificate-services-apache) | | Sectigo | - | No | Yes | Yes | Yes | [Link](https://www.sectigo.com/resource-library/sectigos-acme-automation) | @@ -35,3 +41,59 @@ Here are detailed instructions per Certificate Authority (CA). If anyone is miss This configuration is global. After set up ACME properly, is necessary enable for each domain the certificate request. To do that add the label: `easyhaproxy..certbot=true`. See the method of installation you are using to learn how to set up properly. + +## Example + +### Setting up EasyHAProxy + +Run the EasyHAProxy container: + +```bash +docker run \ + ... \ + -e EASYHAPROXY_CERTBOT_AUTOCONFIG=zerossl \ + -e EASYHAPROXY_CERTBOT_EMAIL=john@doe.com \ + -p 80:80 \ + -p 443:443 \ + -v /path/to/guest/certbot/certs:/certs/certbot \ + ... \ + byjg/easy-haproxy +``` + +Notes: + +- The `EASYHAPROXY_CERTBOT_AUTOCONFIG` is not required for Let's Encrypt. In this example, the certificate will be issued by ZeroSSL. +- If you don't setup `EASYHAPROXY_CERTBOT_EMAIL` environment variable, EasyHAProxy will fail silently and **will not request** a certificate. +- The ports 80 and 443 needs to accessible through the internet as [Let's Encrypt requirement](https://letsencrypt.org/docs/allow-port-80/) + +In order to avoid several certificate issuing, +**It is required you to persist the container folder `/certs/certbot` outside the container.** +You cannot delete or change it contents. +If you do not persist, or change/delete the contents, Issue a certificate might not work properly and hit rate limit. + +If you are using Let's Encrypt, be aware of it rate limits: + +- https://letsencrypt.org/docs/duplicate-certificate-limit/ +- https://letsencrypt.org/docs/rate-limits/ + + +## Setting up your container to use the ACME CA + +```bash +docker run \ + ... \ + --label easyhaproxy.express.port=80 \ + --label easyhaproxy.express.localport=3000 \ + --label easyhaproxy.express.host=example.org \ + --label easyhaproxy.express.certbot=true \ + ... \ + some/myimage +``` + +Requirements: + +- Your container **must** listen to port 80. The CA will not issue the certificate if `easyhaproxy..port` is in another port, and EasyHAProxy will fail silently. +- You cannot set port 443 for the container with the Letsencrypt because EasyHAProxy will create this port automatically once the certificate is issued. + +---- +[Open source ByJG](http://opensource.byjg.com) \ No newline at end of file diff --git a/docs/docker.md b/docs/docker.md index ce0c5cd..362b309 100644 --- a/docs/docker.md +++ b/docs/docker.md @@ -55,7 +55,7 @@ Please follow the [docker label configuration](container-labels.md) to see other ## Setup the EasyHAProxy container -You can configure the behavior of the EasyHAProxy by setup specific environment variables. To get a list of the variables, please follow the [docker container environment](docker-environment.md) +You can configure the behavior of the EasyHAProxy by setup specific environment variables. To get a list of the variables, please follow the [environment variable guide](environment-variable.md) ## Setup certificates with ACME (e.g. Letsencrypt) diff --git a/docs/docker-environment.md b/docs/environment-variable.md similarity index 72% rename from docs/docker-environment.md rename to docs/environment-variable.md index b9f4d33..b00bacf 100644 --- a/docs/docker-environment.md +++ b/docs/environment-variable.md @@ -4,13 +4,9 @@ |----------------------------------|------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------|---------------| | EASYHAPROXY_DISCOVER | How the services will be discovered to create `haproxy.cfg`: `static`, `docker`, `swarm` or `kubernetes` | **required** | | EASYHAPROXY_LABEL_PREFIX | (Optional) The key will search for matching resources. | `easyhaproxy` | -| EASYHAPROXY_CERTBOT_EMAIL | (Optional) The email will be used to request the certificate to Certbot | *empty* | -| EASYHAPROXY_CERTBOT_SERVER | (Optional) Can be `staging` or 'schema://domain.tld', if using other service than Letsencrypt. Might be necessary set EASYHAPROXY_CERTBOT_EAB_KID and EASYHAPROXY_CERTBOT_EAB_HMAC_KEY | *empty* | -| EASYHAPROXY_CERTBOT_EAB_KID | (Optional) eab-kid configuration when required. | *empty* | -| EASYHAPROXY_CERTBOT_EAB_HMAC_KEY | (Optional) eab-kid-hmac-key configuration when required. | *empty* | -| EASYHAPROXY_CERTBOT_RETRY_COUNT | (Optional) Wait 'n' requests before try re-issue invalid calls | +| EASYHAPROXY_CERTBOT_* | (Optional) Enable Let's Encrypt or any other ACME certificate. See more: [acme](acme.md) | *empty* | | EASYHAPROXY_SSL_MODE | (Optional) `strict` supports only the most recent TLS version; `default` good SSL integration with recent browsers; `loose` supports all old SSL protocols for old browsers (not recommended). | `default` | -| EASYHAPROXY_REFRESH_CONF | (Optional) Check configuration every N seconds. | 10 | +| EASYHAPROXY_REFRESH_CONF | (Optional) Check for new containers/services every N seconds. | 10 | | EASYHAPROXY_LOG_LEVEL | (Optional) The log level for EasyHAproxy messages. Available: TRACE,DEBUG,INFO,WARN,ERROR,FATAL | DEBUG | | CERTBOT_LOG_LEVEL | (Optional) The log level for Certbot messages. Available: TRACE,DEBUG,INFO,WARN,ERROR,FATAL | DEBUG | | HAPROXY_LOG_LEVEL | (Optional) The log level for HAProxy messages. Available: TRACE,DEBUG,INFO,WARN,ERROR,FATAL | DEBUG | diff --git a/docs/kubernetes.md b/docs/kubernetes.md index b3fac3c..a7f4e6f 100644 --- a/docs/kubernetes.md +++ b/docs/kubernetes.md @@ -39,7 +39,7 @@ kubectl apply -f \ https://raw.githubusercontent.com/byjg/docker-easy-haproxy/4.3.1-rc1/deploy/kubernetes/easyhaproxy-daemonset.yml ``` -If necessary, you can configure environment variables. To get a list of the variables, please follow the [docker container environment](docker-environment.md) +If necessary, you can configure environment variables. To get a list of the variables, please follow the [environment variable guide](environment-variable.md) ## Running containers diff --git a/docs/letsencrypt.md b/docs/letsencrypt.md deleted file mode 100644 index acecae0..0000000 --- a/docs/letsencrypt.md +++ /dev/null @@ -1,55 +0,0 @@ -# Letsencrypt - -EasyHAProxy can issue a letsencrypt certificate. Follow the steps below: - -## Setting up EasyHAProxy - -Run the EasyHAProxy container: - -```bash -docker run \ - ... \ - -e EASYHAPROXY_CERTBOT_EMAIL=john@doe.com \ - -p 80:80 \ - -p 443:443 \ - -v /path/to/guest/certbot/certs:/certs/certbot \ - ... \ - byjg/easy-haproxy -``` - -Notes: - -- If you don't setup `EASYHAPROXY_CERTBOT_EMAIL` environment variable, EasyHAProxy will fail silently and **will not request** a certificate. -- The ports 80 and 443 needs to accessible through the internet as [Let's Encrypt requirement](https://letsencrypt.org/docs/allow-port-80/) - -Be aware of Letsencrypt issue rate limits: - -- https://letsencrypt.org/docs/duplicate-certificate-limit/ -- https://letsencrypt.org/docs/rate-limits/ - -In order to avoid several certificate issuing, -**It is required you to persist the container folder `/certs/certbot` outside the container.** -You cannot delete or change it contents. -If you do not persist, or change/delete the contents, Let's encrypt will not work properly. - - -## Setting up your container to use Let's Encrypt - -```bash -docker run \ - ... \ - --label easyhaproxy.express.port=80 \ - --label easyhaproxy.express.localport=3000 \ - --label easyhaproxy.express.host=example.org \ - --label easyhaproxy.express.certbot=true \ - ... \ - some/myimage -``` - -Requirements: - -- Your container **must** listen to port 80. Letsencrypt will not issue the certificate if `easyhaproxy..port` is in another port, and EasyHAProxy will fail silently. -- You cannot set port 443 for the container with the Letsencrypt because EasyHAProxy will create this port automatically once the certificate is issued. - ----- -[Open source ByJG](http://opensource.byjg.com) diff --git a/docs/static.md b/docs/static.md index ff6ee32..53cb2d6 100644 --- a/docs/static.md +++ b/docs/static.md @@ -72,7 +72,7 @@ docker run -d \ byjg/easy-haproxy ``` -You can find other informations on [docker label configuration](container-labels.md) and [docker container environment](docker-environment.md) +You can find other informations on [docker label configuration](container-labels.md) and [environment variable guide](environment-variable.md) ## Yaml Definition diff --git a/docs/swarm.md b/docs/swarm.md index 9ab29c4..16fe71c 100644 --- a/docs/swarm.md +++ b/docs/swarm.md @@ -91,7 +91,7 @@ Please follow the [docker label configuration](container-labels.md) to see other ## Setup the EasyHAProxy container -You can configure the behavior of the EasyHAProxy by setup specific environment variables. To get a list of the variables, please follow the [docker container environment](docker-environment.md) +You can configure the behavior of the EasyHAProxy by setup specific environment variables. To get a list of the variables, please follow the [environment variable guide](environment-variable.md) ## More information From 3f38951b60cb2db03aa42b6660960a8990701ad6 Mon Sep 17 00:00:00 2001 From: Joao Gilberto Magalhaes Date: Mon, 3 Jul 2023 20:28:56 -0500 Subject: [PATCH 32/33] Update README.md --- README.md | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/README.md b/README.md index 0eeb50d..fe8a32b 100644 --- a/README.md +++ b/README.md @@ -35,7 +35,7 @@ Easy to set up and low configuration to numerous features. EasyHAProxy will discover the services based on the Docker Tags of the containers running on a Docker host or Docker Swarm cluster and dynamically set up the `haproxy.cfg`. Below, EasyHAProxy main features: -- Support Automatic Certificate Management Environment (ACME) protocol compatible with Let's encrypt and other issuers. +- Support Automatic Certificate Management Environment (ACME) protocol compatible with Let's encrypt and others CA. - Set your custom SSL certificates - Balance traffic between multiple replicas - Set SSL with three different levels of validations and according to the most recent definitions. @@ -76,7 +76,7 @@ Or you can install using tools: If you already set up the EasyHAProxy, is time to go deeper: - [Custom SSL](docs/ssl.md) -- Automatic Certificate Issuing (e.g. Let's Encrypt) +- [Automatic Certificate Issuing](docs/acme.md) (e.g. Letsencrypt) ## See EasyHAProxy in action From f0057fbac7436bd80ffa43d2967e0543860741e6 Mon Sep 17 00:00:00 2001 From: Joao Gilberto Magalhaes Date: Mon, 3 Jul 2023 20:37:56 -0500 Subject: [PATCH 33/33] Update static.md --- docs/static.md | 15 ++++++++++----- 1 file changed, 10 insertions(+), 5 deletions(-) diff --git a/docs/static.md b/docs/static.md index 53cb2d6..4f7c3a1 100644 --- a/docs/static.md +++ b/docs/static.md @@ -91,9 +91,13 @@ logLevel: easyhaproxy: DEBUG # Optional (default: DEBUG). Can be: TRACE,DEBUG,INFO,WARN,ERROR,FATAL haproxy: INFO # Optional (default: INFO). Can be: TRACE,DEBUG,INFO,WARN,ERROR,FATAL -certbot: { # Optional. If you enable `certbot` will need to setu0p this, - # otherwise the certificate will be issued - "email": "acme@example.org" +certbot: + email: "acme@example.org" # If email is defined enable ACME/Certbot + autoconfig: "" # If empty use letsencrypt, otherwise try to set the CA defined. + eab_hmac_key: "" # If required by the CA, set here. + eab_kid: "" # If required by the CA, set here. + server: False # If empty/False uses Letsencrypt, otherwise the CA Endpoint defined here + retry_count: 60 # If the certificate reaches the Rate Limit, try again after 'n' iterations. } easymapping: @@ -103,7 +107,7 @@ easymapping: host1.com.br: # Hostname containers: - container:5000 # Endpoints of the hostname above (ip, dns, container, etc) - certbot: true # Optional. it will request a certbot certiticate + certbot: true # Optional. it will request a certbot certificate. Needs certbot.email set. redirect_ssl: true # Optional. It will redirect this site to it SSL. ssl: true # Optional. Inform this port will listen to SSL, instead of HTTP clone_to_ssl: true # Optional. Default False. You clone these hosts to its equivalent SSL. @@ -111,7 +115,8 @@ easymapping: www.host1.com.br: http://host1.com.br ``` -**Note**: The only way to pass SSL certificates is to map the certificates to EasyHAProxy as a docker volume. Refer to the [SSL documentation](ssl.md) to learn how to do it. +**Note**: The only way to pass SSL certificates in the static configuration file is to map the certificates +to EasyHAProxy as a docker volume. Refer to the [SSL documentation](ssl.md) to learn how to do it. ---- [Open source ByJG](http://opensource.byjg.com)