From f5b097616ecb3917b0eb87f43a2e7e7109790721 Mon Sep 17 00:00:00 2001 From: till Date: Sat, 30 May 2020 15:50:22 +0200 Subject: [PATCH 1/5] Update: support tcp-mode (in frontend/backend) --- README.md | 14 ++++++++++++++ swarm.py | 2 ++ templates/haproxy.cfg.j2 | 17 ++++++++++++++--- 3 files changed, 30 insertions(+), 3 deletions(-) diff --git a/README.md b/README.md index 28cb482..a03d9ea 100644 --- a/README.md +++ b/README.md @@ -80,6 +80,7 @@ Important: easyhaproxy needs to be in the same network of the containers or othe | Tag | Description | |---------------------------------------------|---------------------------------------------------------------------------------------------------------| | com.byjg.easyhaproxy.definitions | A Comma delimited list with the definitions. Each name requires the definition of the parameters below. | +| com.byjg.easyhaproxy.mode.[definition] | (Optional) Is this http or tcp mode in HAProxy. (Defaults to http) | | com.byjg.easyhaproxy.port.[definition] | (Optional) What is the port that the HAProxy will listen to. (Defaults to 80) | | com.byjg.easyhaproxy.localport.[definition] | (Optional) What is the port that the container is listening. (Defaults to 80) | | com.byjg.easyhaproxy.host.[definition] | What is the host that the HAProxy will listen to. | @@ -126,6 +127,19 @@ docker run \ some/myimage ``` +### TLS passthrough + +Used to pass on SSL-termination to a backend: + +```bash +docker run \ + -l com.byjg.easyhaproxy.defintions=tcp-service \ + -l com.byjg.easyhaproxy.mode.tcp-service=tcp \ + -l com.byjg.easyhaproxy.port.tcp-service=443 + .... \ + some/tcp-service +``` + ### Redirect Example: ```bash diff --git a/swarm.py b/swarm.py index a74cef1..961f634 100644 --- a/swarm.py +++ b/swarm.py @@ -36,6 +36,7 @@ for line in lineList: if "com.byjg.easyhaproxy.host." + definition not in d: continue + mode = d["com.byjg.easyhaproxy.mode." + definition] if "com.byjg.easyhaproxy.mode." + definition in d else "http" port = d["com.byjg.easyhaproxy.port." + definition] if "com.byjg.easyhaproxy.port." + definition in d else "80" hash = hashlib.md5(d["com.byjg.easyhaproxy.sslcert." + definition].encode('utf-8')).hexdigest() if "com.byjg.easyhaproxy.sslcert." + definition in d else "" @@ -43,6 +44,7 @@ for line in lineList: if key not in easymapping: easymapping[key] = { + "mode": mode, "port": port, "hosts": dict(), "redirect": dict(), diff --git a/templates/haproxy.cfg.j2 b/templates/haproxy.cfg.j2 index 96862f3..732e48b 100644 --- a/templates/haproxy.cfg.j2 +++ b/templates/haproxy.cfg.j2 @@ -39,10 +39,17 @@ backend srv_stats {% endif %} {% for o in data["easymapping"] %} + {% set mode = o["mode"] or "http" %} {% set salt = loop.index %} -frontend http_in_{{ o["port"] }}_{{ salt }} +frontend {{ mode }}_in_{{ o["port"] }}_{{ salt }} bind *:{{ o["port"] }} {{ " ssl crt " + o["ssl_cert"] if "ssl_cert" in o else "" }} - mode http + mode {{ mode }} + + {% if mode == "tcp" -%} + option tcplog + tcp-request inspect-delay 5s + tcp-request content accept if { req.ssl_hello_type 1 } + {% endif -%} {% for k in o["redirect"] -%} redirect prefix {{ o["redirect"][k] }} code 301 if { hdr(host) -i {{ k }} } @@ -59,10 +66,14 @@ frontend http_in_{{ o["port"] }}_{{ salt }} {% set host = k.replace(".", "_") + "_{0}_{1}".format(o["port"], salt) %} backend srv_{{ host }} balance roundrobin - mode http + mode {{ mode }} + +{% if mode == "http" %} option forwardfor http-request set-header X-Forwarded-Port %[dst_port] http-request add-header X-Forwarded-Proto https if { ssl_fc } +{% endif %} + server srv {{ o["hosts"][k] }} check weight 1 {% endfor %} {% endfor %} From fcc79806020bce1eca7d9e1b036ce4acf6bdc9d6 Mon Sep 17 00:00:00 2001 From: till Date: Sat, 30 May 2020 17:08:49 +0200 Subject: [PATCH 2/5] Fix: inspect services (vs. containers/tasks) --- assets/scripts/haproxy-reload.sh | 12 ++++++++---- 1 file changed, 8 insertions(+), 4 deletions(-) diff --git a/assets/scripts/haproxy-reload.sh b/assets/scripts/haproxy-reload.sh index df9f603..ecee161 100755 --- a/assets/scripts/haproxy-reload.sh +++ b/assets/scripts/haproxy-reload.sh @@ -18,14 +18,18 @@ else if [[ "$DISCOVER" == "docker" ]]; then CONTAINERS=$(docker ps -q) LABEL_PATH=".Config.Labels" + + for container in ${CONTAINERS}; do + docker inspect --format "{{ json $LABEL_PATH }}" ${container} | xargs -I % echo ${container}=% >> ${CONTROL_FILE} + done else CONTAINERS=$(docker node ps $(docker node ls -q) --format "{{ .Name }}" --filter desired-state=running | cut -d. -f1 | sort | uniq) LABEL_PATH=".Spec.Labels" - fi - for container in ${CONTAINERS}; do - docker inspect --format "{{ json $LABEL_PATH }}" ${container} | xargs -I % echo ${container}=% >> ${CONTROL_FILE} - done + for container in ${CONTAINERS}; do + docker service inspect --format "{{ json $LABEL_PATH }}" ${container} | xargs -I % echo ${container}=% >> ${CONTROL_FILE} + done + fi if cmp -s ${CONTROL_FILE} ${CONTROL_FILE}.old ; then RELOAD="false" From 5f9eac4b8a459ec3ea6f9290f1d9375166c20ca5 Mon Sep 17 00:00:00 2001 From: till Date: Sat, 30 May 2020 19:37:12 +0200 Subject: [PATCH 3/5] Fix: send haproxy logs to stdout --- templates/haproxy.cfg.j2 | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/templates/haproxy.cfg.j2 b/templates/haproxy.cfg.j2 index 732e48b..f0293e3 100644 --- a/templates/haproxy.cfg.j2 +++ b/templates/haproxy.cfg.j2 @@ -15,7 +15,7 @@ defaults {% endif %} global - log /dev/log local0 + log stdout format raw local0 info maxconn 2000 tune.ssl.default-dh-param 2048 From 08de132450434d8dc426c31880bb87deb1223be0 Mon Sep 17 00:00:00 2001 From: till Date: Sat, 30 May 2020 22:24:40 +0200 Subject: [PATCH 4/5] Refactor: tried to refactor code - move scripts to assets/scripts (and updated build) - put most logic into HaproxyConfigGenerator - created DockerLabelHandler for all label handling from previous code - added unit tests and fixtures to cover HaproxyConfigGenerator and DockerLabelHandler - added a Makefile with build (for docker build) and test targets --- Dockerfile | 2 - Makefile | 7 ++ README.md | 42 ++++----- static.py => assets/scripts/static.py | 0 assets/scripts/swarm.py | 24 +++++ easymapping/__init__.py | 128 +++++++++++++++++++++++++- pytest.ini | 2 + setup.py | 20 ++++ swarm.py | 76 --------------- templates/bind.j2 | 8 ++ templates/frontend-mode-http.j2 | 11 +++ templates/frontend-mode-tcp.j2 | 6 ++ templates/haproxy.cfg.j2 | 50 ++++------ tests/__init__.py | 0 tests/context.py | 5 + tests/fixtures/no-services | 5 + tests/fixtures/services | 5 + tests/fixtures/services-tcp | 2 + tests/fixtures/static.yml | 23 +++++ tests/test_labels.py | 32 +++++++ tests/test_parser.py | 70 ++++++++++++++ 21 files changed, 387 insertions(+), 131 deletions(-) create mode 100644 Makefile rename static.py => assets/scripts/static.py (100%) create mode 100644 assets/scripts/swarm.py create mode 100644 pytest.ini create mode 100644 setup.py delete mode 100644 swarm.py create mode 100644 templates/bind.j2 create mode 100644 templates/frontend-mode-http.j2 create mode 100644 templates/frontend-mode-tcp.j2 create mode 100644 tests/__init__.py create mode 100644 tests/context.py create mode 100644 tests/fixtures/no-services create mode 100644 tests/fixtures/services create mode 100644 tests/fixtures/services-tcp create mode 100644 tests/fixtures/static.yml create mode 100644 tests/test_labels.py create mode 100644 tests/test_parser.py diff --git a/Dockerfile b/Dockerfile index 4827b7d..9720c23 100644 --- a/Dockerfile +++ b/Dockerfile @@ -8,8 +8,6 @@ COPY requirements.txt /scripts RUN pip3 install --upgrade pip \ && pip install -r requirements.txt -COPY swarm.* /scripts/ -COPY static.* /scripts/ COPY templates /scripts/templates/ COPY easymapping /scripts/easymapping/ diff --git a/Makefile b/Makefile new file mode 100644 index 0000000..d721ef9 --- /dev/null +++ b/Makefile @@ -0,0 +1,7 @@ +.PHONY: build +build: + docker build -t byjg/easy-haproxy -t byjg/easy-haproxy:local . + +.PHONY: test +test: + pytest tests/ diff --git a/README.md b/README.md index a03d9ea..09373a8 100644 --- a/README.md +++ b/README.md @@ -1,24 +1,24 @@ -# Easy HAProxy +# Easy HAProxy -This Docker image will create dynamically the `haproxy.cfg` based on the labels defined in docker containers or from -a simple Yaml instead docker +This Docker image will create dynamically the `haproxy.cfg` based on the labels defined in docker containers or from +a simple Yaml instead docker # Features - Enable or disable Stats on port 1936 with custom password -- Discover and setup haproxy from Docker Tag +- Discover and setup haproxy from Docker Tag - Discover and setup haproxy redirect from Docker Tag -- Setup HAProxy CFG from a Yaml file. +- Setup HAProxy CFG from a Yaml file. # Basic Usage -The Easy HAProxy will create the `haproxy.cfg` automatically based on the containers or from a YAML provided. +The Easy HAProxy will create the `haproxy.cfg` automatically based on the containers or from a YAML provided. The basic command line to run is: ```bash -docker run -d \ +docker run -d \ --name easy-haproxy-container \ -v /var/run/docker.sock:/var/run/docker.sock \ -e DISCOVER="swarm|docker|static" \ @@ -29,7 +29,7 @@ docker run -d \ The mapping to `/var/run/docker.sock` is necessary to discover the docker containers and get the labels; -The environment variables will setup the HAProxy. +The environment variables will setup the HAProxy. {:.table} | Environment Variable | Description | @@ -49,9 +49,9 @@ The environment variable `DISCOVER` will define where is located your containers # DISCOVER: docker -This method will use a regular docker installation to discover the containers and configure the HAProxy. +This method will use a regular docker installation to discover the containers and configure the HAProxy. -The only requirement is that containers and easy-haproxy must be in the same docker network. +The only requirement is that containers and easy-haproxy must be in the same docker network. The discover will occur every minute. @@ -67,12 +67,12 @@ docker run --network easyhaproxy myimage # DISCOVER: swarm -This method requires a functional Docker Swarm Cluster. The system will search for the labels in all containers on all -swarm nodes. +This method requires a functional Docker Swarm Cluster. The system will search for the labels in all containers on all +swarm nodes. The discover will occur every minute. -Important: easyhaproxy needs to be in the same network of the containers or otherwise will not access. +Important: easyhaproxy needs to be in the same network of the containers or otherwise will not access. ## Tags to be attached in the Docker Container @@ -163,12 +163,12 @@ customerrors: true # Optional (default false) easymapping: - port: 80 - hosts: + hosts: host1.com.br: container:5000 host2.com.br: other:3000 redirect: www.host1.com.br: http://host1.com.br - + - port: 443 ssl_cert: BASE64_PEM_CERTIFICATE hosts: @@ -187,10 +187,10 @@ docker run -v /my/config.yml:/etc/haproxy/easyconfig.yml .... byjg/easyhaproxy # Mapping custom .cfg files -Map a folder containing valid HAProxy `.cfg` files to `/etc/haproxy/conf.d`. It will be concatenated to your HAProxy CFG. +Map a folder containing valid HAProxy `.cfg` files to `/etc/haproxy/conf.d`. It will be concatenated to your HAProxy CFG. ```bash -docker run \ +docker run \ /* other parameters */ -v /your/local/conf.d:/etc/haproxy/conf.d \ -d byjg/easy-haproxy @@ -199,9 +199,9 @@ docker run \ # Handling SSL -You can attach a valid SSL certificate to the request. +You can attach a valid SSL certificate to the request. -1. First Create a single PEM file including CA. +1. First Create a single PEM file including CA. ```bash cat example.com.crt example.com.key > single.pem @@ -229,8 +229,8 @@ cat single.pem | base64 -w0 # Setting Custom Errors -If enabled, map the volume : `/etc/haproxy/errors-custom/` to your container and put a file named `ERROR_NUMBER.http` -where ERROR_NUMBER is the http error code (e.g. 503.http) +If enabled, map the volume : `/etc/haproxy/errors-custom/` to your container and put a file named `ERROR_NUMBER.http` +where ERROR_NUMBER is the http error code (e.g. 503.http) # Build diff --git a/static.py b/assets/scripts/static.py similarity index 100% rename from static.py rename to assets/scripts/static.py diff --git a/assets/scripts/swarm.py b/assets/scripts/swarm.py new file mode 100644 index 0000000..c7741ec --- /dev/null +++ b/assets/scripts/swarm.py @@ -0,0 +1,24 @@ +import os +from easymapping import HaproxyConfigGenerator + +# path = os.path.dirname(os.path.realpath(__file__)) +with open("/tmp/.docker_data", 'r') as content_file: + lineList = content_file.readlines() + +result = { + "customerrors": True if os.getenv("HAPROXY_CUSTOMERRORS") == "true" else False +} + +if os.getenv("HAPROXY_PASSWORD"): + result["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", + } + +cfg = HaproxyConfigGenerator(result) +print(cfg.generate(lineList)) + +# print(jsonStr) + + diff --git a/easymapping/__init__.py b/easymapping/__init__.py index 1b54af2..d0d0e69 100644 --- a/easymapping/__init__.py +++ b/easymapping/__init__.py @@ -1,12 +1,138 @@ +import base64 +import hashlib from jinja2 import Environment, FileSystemLoader +import json +import time + + +class DockerLabelHandler: + def __init__(self, label): + self.__label_base = label + + + def create(self, key): + if isinstance(key, str): + return "{}.{}".format(self.__label_base, key) + + return "{}.{}".format(self.__label_base, ".".join(key)) + + + def get(self, label, default_value = ""): + if self.has_label(label): + return self.__data[label] + return default_value + + + def set_data(self, data): + self.__data = data + + + def has_label(self, label): + if label in self.__data: + return True + return False class HaproxyConfigGenerator: def __init__(self, mapping): self.mapping = mapping + self.label = DockerLabelHandler("com.byjg.easyhaproxy") + + + def generate(self, lineList = []): + # static? + if len(lineList) > 0: + self.mapping["easymapping"] = self.__parse(lineList) + + # still 'None' -> default to [] for jinja2 + if self.mapping["easymapping"] is None: + self.mapping["easymapping"] = [] - def generate(self): file_loader = FileSystemLoader('templates') env = Environment(loader=file_loader) + env.trim_blocks = True + env.lstrip_blocks = True + env.rstrip_blocks = True template = env.get_template('haproxy.cfg.j2') return template.render(data=self.mapping) + + + def __parse(self, lineList): + easymapping = dict() + + for line in lineList: + line = line.strip() + i = line.find("=") + container = line[:i] + jsonStr = line[i+1:] + d = json.loads(jsonStr) + + if self.label.create("definitions") not in d.keys(): + continue + + self.label.set_data(d) + + definitions = d[self.label.create("definitions")].split(",") + for definition in definitions: + mode = self.label.get( + self.label.create(["mode", definition]), + "http" + ) + + # TODO: we can ignore "host" in TCP, but it would break the template + host_label = self.label.create(["host", definition]) + if not self.label.has_label(host_label): + continue + + port = self.label.get( + self.label.create(["port", definition]), + "80" + ) + + if self.label.create(["sslcert", definition]) in d: + hash = hashlib.md5( + d[self.label.create(["sslcert", definition])].encode('utf-8') + ).hexdigest() + else: + hash = "" + + key = port+hash + + if key not in easymapping: + easymapping[key] = { + "mode": mode, + "port": port, + "hosts": dict(), + "redirect": dict(), + } + + # TODO: this could use `EXPOSE` from `Dockerfile`? + ct_port = self.label.get( + self.label.create(["localport", definition]), + "80" + ) + + easymapping[key]["hosts"][d[host_label]] = "{}:{}".format(container, ct_port) + + # handle SSL + ssl_label = self.label.create(["sslcert", definition]) + if self.label.has_label(ssl_label): + filename = "/etc/haproxy/certs/{}.{}.pem".format( + d[ssl_label], str(time.time()) + ) + easymapping[key]["ssl_cert"] = filename + with open(filename, 'wb') as file: + file.write( + base64.b64decode(d[ssl_label]) + ) + + # handle redirects + redirect = self.label.get( + self.label.create(["redirect", definition]) + ) + if len(redirect) > 0: + for r in redirect.split(","): + r_parts = r.split("--") + easymapping[key]["redirect"][r_parts[0]] = r_parts[1] + + return easymapping.values() diff --git a/pytest.ini b/pytest.ini new file mode 100644 index 0000000..3acaa4f --- /dev/null +++ b/pytest.ini @@ -0,0 +1,2 @@ +[pytest] +addopts = -v -p no:warnings diff --git a/setup.py b/setup.py new file mode 100644 index 0000000..f19e91e --- /dev/null +++ b/setup.py @@ -0,0 +1,20 @@ +from setuptools import setup, find_packages + + +with open('README.md') as f: + readme = f.read() + +with open('LICENSE') as f: + license = f.read() + +setup( + name='easymapping', + version='0.1.0', + description='HAProxy label based routing', + long_description=readme, + author='', + author_email='', + url='', + license=license, + packages=find_packages(exclude=('tests', 'docs')) +) diff --git a/swarm.py b/swarm.py deleted file mode 100644 index 961f634..0000000 --- a/swarm.py +++ /dev/null @@ -1,76 +0,0 @@ -import os -import json -import time -import base64 -import hashlib -from easymapping import HaproxyConfigGenerator - -# path = os.path.dirname(os.path.realpath(__file__)) -with open("/tmp/.docker_data", 'r') as content_file: - lineList = content_file.readlines() - -result = { - "easymapping": [], - "customerrors": True if os.getenv("HAPROXY_CUSTOMERRORS") == "true" else False -} -easymapping = dict() - -if os.getenv("HAPROXY_PASSWORD"): - result["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", - } - -for line in lineList: - line = line.strip() - i = line.find("=") - container = line[:i] - jsonStr = line[i+1:] - d = json.loads(jsonStr) - - if "com.byjg.easyhaproxy.definitions" in d.keys(): - definitions = d["com.byjg.easyhaproxy.definitions"].split(",") - - for definition in definitions: - if "com.byjg.easyhaproxy.host." + definition not in d: - continue - - mode = d["com.byjg.easyhaproxy.mode." + definition] if "com.byjg.easyhaproxy.mode." + definition in d else "http" - port = d["com.byjg.easyhaproxy.port." + definition] if "com.byjg.easyhaproxy.port." + definition in d else "80" - hash = hashlib.md5(d["com.byjg.easyhaproxy.sslcert." + definition].encode('utf-8')).hexdigest() if "com.byjg.easyhaproxy.sslcert." + definition in d else "" - - key = port+hash - - if key not in easymapping: - easymapping[key] = { - "mode": mode, - "port": port, - "hosts": dict(), - "redirect": dict(), - # "ssl_cert": "" - } - - easymapping[key]["hosts"][d["com.byjg.easyhaproxy.host." + definition]] = container + ":" + (d["com.byjg.easyhaproxy.localport." + definition] if "com.byjg.easyhaproxy.localport." + definition in d else "80") - - if "com.byjg.easyhaproxy.sslcert." + definition in d: - filename = '/etc/haproxy/certs/' + d["com.byjg.easyhaproxy.host." + definition] + "." + str(time.time()) + ".pem" - easymapping[key]["ssl_cert"] = filename - with open(filename, 'wb') as file: - file.write(base64.b64decode(d["com.byjg.easyhaproxy.sslcert." + definition])) - - if "com.byjg.easyhaproxy.redirect." + definition in d: - redirect = d["com.byjg.easyhaproxy.redirect." + definition] if "com.byjg.easyhaproxy.redirect." + definition in d else "" - for r in redirect.split(","): - r_parts = r.split("--") - easymapping[key]["redirect"][r_parts[0]] = r_parts[1] - - result["easymapping"] = easymapping.values() - - -cfg = HaproxyConfigGenerator(result) -print(cfg.generate()) - -# print(jsonStr) - - diff --git a/templates/bind.j2 b/templates/bind.j2 new file mode 100644 index 0000000..abbd254 --- /dev/null +++ b/templates/bind.j2 @@ -0,0 +1,8 @@ + {% if "ssl_cert" in o %} + bind *:{{ o["port"] }} ssl crt {{ o["ssl_cert"] }} + {% elif "h2" in o and o["h2"] %} + bind *:{{ o["port"] }} proto h2 + option http-use-htx + {% else %} + bind *:{{ o["port"] }} + {% endif %} diff --git a/templates/frontend-mode-http.j2 b/templates/frontend-mode-http.j2 new file mode 100644 index 0000000..5d903e6 --- /dev/null +++ b/templates/frontend-mode-http.j2 @@ -0,0 +1,11 @@ + mode http + {% for k in o["redirect"] %} + redirect prefix {{ o["redirect"][k] }} code 301 if { hdr(host) -i {{ k }} } + {% endfor %} + {% for k in o["hosts"] %} + {% set host = k.replace(".", "_") + "_{0}_{1}".format(o["port"], salt) %} + + acl is_rule_{{ host }}_1 hdr(host) -i {{ k }} + acl is_rule_{{ host }}_2 hdr(host) -i {{ k }}:{{ o["port"] }} + use_backend srv_{{ host }} if is_rule_{{ host }}_1 OR is_rule_{{ host }}_2 + {% endfor %} diff --git a/templates/frontend-mode-tcp.j2 b/templates/frontend-mode-tcp.j2 new file mode 100644 index 0000000..012d865 --- /dev/null +++ b/templates/frontend-mode-tcp.j2 @@ -0,0 +1,6 @@ + mode tcp + option tcplog + log global +{% set backend = (o["hosts"]|first) %} + default_backend srv_{{ backend.replace(".", "_") + "_{0}_{1}".format(o["port"], salt) }} + diff --git a/templates/haproxy.cfg.j2 b/templates/haproxy.cfg.j2 index f0293e3..6b502d6 100644 --- a/templates/haproxy.cfg.j2 +++ b/templates/haproxy.cfg.j2 @@ -1,3 +1,8 @@ +global + log stdout format raw local0 info + maxconn 2000 + tune.ssl.default-dh-param 2048 + defaults log global @@ -14,11 +19,6 @@ defaults errorfile 504 /etc/haproxy/errors-custom/504.http {% endif %} -global - log stdout format raw local0 info - maxconn 2000 - tune.ssl.default-dh-param 2048 - {% if "stats" in data %} frontend stats bind *:{{ data["stats"]["port"] | default(1936) }} @@ -37,43 +37,31 @@ backend srv_stats mode http server Local 127.0.0.1:{{ data["stats"]["port"] | default(1936) }} {% endif %} - -{% for o in data["easymapping"] %} +{% for o in data["easymapping"] -%} {% set mode = o["mode"] or "http" %} {% set salt = loop.index %} + frontend {{ mode }}_in_{{ o["port"] }}_{{ salt }} - bind *:{{ o["port"] }} {{ " ssl crt " + o["ssl_cert"] if "ssl_cert" in o else "" }} - mode {{ mode }} + {% include "bind.j2" %} + {% if mode == "http" %} + {% include "frontend-mode-http.j2" %} + {% else %} + {% include "frontend-mode-tcp.j2" %} + {% endif %} - {% if mode == "tcp" -%} - option tcplog - tcp-request inspect-delay 5s - tcp-request content accept if { req.ssl_hello_type 1 } - {% endif -%} - - {% for k in o["redirect"] -%} - redirect prefix {{ o["redirect"][k] }} code 301 if { hdr(host) -i {{ k }} } - {% endfor -%} - - {% for k in o["hosts"] %} - {% set host = k.replace(".", "_") + "_{0}_{1}".format(o["port"], salt) %} - acl is_rule_{{ host }}_1 hdr(host) -i {{ k }} - acl is_rule_{{ host }}_2 hdr(host) -i {{ k }}:{{ o["port"] }} - use_backend srv_{{ host }} if is_rule_{{ host }}_1 OR is_rule_{{ host }}_2 - {% endfor %} - - {% for k in o["hosts"] %} + {% for k in o["hosts"] -%} {% set host = k.replace(".", "_") + "_{0}_{1}".format(o["port"], salt) %} backend srv_{{ host }} balance roundrobin mode {{ mode }} - -{% if mode == "http" %} + {% if mode == "http" %} option forwardfor http-request set-header X-Forwarded-Port %[dst_port] http-request add-header X-Forwarded-Proto https if { ssl_fc } -{% endif %} - + {% elif mode == "tcp" %} + option tcp-check + tcp-check connect + {% endif %} server srv {{ o["hosts"][k] }} check weight 1 {% endfor %} {% endfor %} diff --git a/tests/__init__.py b/tests/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/tests/context.py b/tests/context.py new file mode 100644 index 0000000..67dda65 --- /dev/null +++ b/tests/context.py @@ -0,0 +1,5 @@ +import os +import sys +sys.path.insert(0, os.path.abspath(os.path.join(os.path.dirname(__file__), '..'))) + +import easymapping diff --git a/tests/fixtures/no-services b/tests/fixtures/no-services new file mode 100644 index 0000000..a0f782d --- /dev/null +++ b/tests/fixtures/no-services @@ -0,0 +1,5 @@ +swarm-prom_caddy={"com.docker.stack.image":"stefanprodan/caddy","com.docker.stack.namespace":"swarm-prom"} +swarm-prom_cadvisor={"com.docker.stack.image":"google/cadvisor","com.docker.stack.namespace":"swarm-prom"} +swarm-prom_dockerd-exporter={"com.docker.stack.image":"stefanprodan/caddy","com.docker.stack.namespace":"swarm-prom"} +swarm-prom_unsee={"com.docker.stack.image":"cloudflare/unsee:v0.8.0","com.docker.stack.namespace":"swarm-prom"} +test_proxy={"com.docker.stack.image":"byjg/easy-haproxy","com.docker.stack.namespace":"test"} diff --git a/tests/fixtures/services b/tests/fixtures/services new file mode 100644 index 0000000..2d832e7 --- /dev/null +++ b/tests/fixtures/services @@ -0,0 +1,5 @@ +portainer-agent_agent={"com.docker.stack.image":"portainer/agent:1.5.1","com.docker.stack.namespace":"portainer-agent"} +my-stack_agent={"com.byjg.easyhaproxy.definitions":"agent","com.byjg.easyhaproxy.host.agent":"agent.quantum.example.org","com.byjg.easyhaproxy.localport.agent":"9001","com.byjg.easyhaproxy.mode.agent":"tcp","com.byjg.easyhaproxy.port.agent":"31339","com.docker.stack.image":"portainer/agent:1.5.1","com.docker.stack.namespace":"my-stack","com.planetary-quantum":"monitoring"} +my-stack_cadvisor={"com.byjg.easyhaproxy.definitions":"cadvisor","com.byjg.easyhaproxy.host.cadvisor":"cadvisor.quantum.example.org","com.byjg.easyhaproxy.localport.cadvisor":"8080","com.byjg.easyhaproxy.port.cadvisor":"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={"com.byjg.easyhaproxy.definitions":"exp","com.byjg.easyhaproxy.host.exp":"node-exporter.quantum.example.org","com.byjg.easyhaproxy.localport.exp":"9100","com.byjg.easyhaproxy.port.exp":"31337","com.docker.stack.image":"stefanprodan/swarmprom-node-exporter:v0.16.0","com.docker.stack.namespace":"my-stack","com.planetary-quantum":"monitoring"} +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"} diff --git a/tests/fixtures/services-tcp b/tests/fixtures/services-tcp new file mode 100644 index 0000000..8520b59 --- /dev/null +++ b/tests/fixtures/services-tcp @@ -0,0 +1,2 @@ +test_agent={"com.byjg.easyhaproxy.definitions":"agent","com.byjg.easyhaproxy.host.agent":"agent.quantum.local","com.byjg.easyhaproxy.localport.agent":"9001","com.byjg.easyhaproxy.mode.agent":"tcp","com.byjg.easyhaproxy.port.agent":"31339","com.docker.stack.image":"portainer/agent:1.5.1","com.docker.stack.namespace":"test","com.planetary-quantum":"monitoring"} +test_proxy={"com.docker.stack.image":"byjg/easy-haproxy:local","com.docker.stack.namespace":"test"} diff --git a/tests/fixtures/static.yml b/tests/fixtures/static.yml new file mode 100644 index 0000000..dd660d5 --- /dev/null +++ b/tests/fixtures/static.yml @@ -0,0 +1,23 @@ +stats: + username: admin + password: test123 + port: 1936 # Optional (default 1936) + +customerrors: true # Optional (default false) + +easymapping: + - port: 80 + hosts: + host1.com.br: container:5000 + host2.com.br: other:3000 + redirect: + www.host1.com.br: http://host1.com.br + + - port: 443 + ssl_cert: BASE64_PEM_CERTIFICATE + hosts: + host1.com.br: container:80 + + - port: 8080 + hosts: + host3.com.br: domain:8181 diff --git a/tests/test_labels.py b/tests/test_labels.py new file mode 100644 index 0000000..f56c212 --- /dev/null +++ b/tests/test_labels.py @@ -0,0 +1,32 @@ +from .context import easymapping +import json +import pytest + +def test_label_generation(): + label = easymapping.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.set_data(json.loads('{"base.definitions":"h2"}')) + + label_name = label.create("definitions") + assert label_name == "base.definitions" + assert label.has_label(label_name) + assert label.get(label_name) == "h2" + + +def test_label_complex_key(): + label = easymapping.DockerLabelHandler("till") + + data = dict() + data["till.definitions"] = "h2" + data["till.host.h2"] = "fqdn.example.org" + data["till.mode.h2"] = "tcp" + label.set_data(json.loads(json.dumps(data))) + + assert label.get(label.create(["host", "h2"])) == "fqdn.example.org" + assert label.get(label.create(["mode", "h2"])) == "tcp" diff --git a/tests/test_parser.py b/tests/test_parser.py new file mode 100644 index 0000000..edf0357 --- /dev/null +++ b/tests/test_parser.py @@ -0,0 +1,70 @@ +from .context import easymapping +import pytest +import os +import yaml + + +def load_fixture(file): + path = os.path.dirname(os.path.realpath(__file__)) + with open(path + "/fixtures/" + file, 'r') as content_file: + lineList = content_file.readlines() + + return lineList + + +def test_parser_doesnt_crash(): + lineList = load_fixture("no-services") + + result = { + "customerrors": False + } + + cfg = easymapping.HaproxyConfigGenerator(result) + haproxy_config = cfg.generate(lineList) + assert len(haproxy_config) > 0 + assert "frontend" not in haproxy_config + assert "backend" not in haproxy_config + + +def test_parser_finds_services(): + lineList = load_fixture("services") + + result = { + "customerrors": False + } + + cfg = easymapping.HaproxyConfigGenerator(result) + haproxy_config = cfg.generate(lineList) + assert len(haproxy_config) > 0 + assert "mode tcp" in haproxy_config + assert "mode http" in haproxy_config + + assert "frontend tcp_in_31339_1" in haproxy_config + assert "frontend http_in_31337_2" in haproxy_config + + +def test_parser_static(): + path = os.path.dirname(os.path.realpath(__file__)) + with open(path + "/fixtures/static.yml", 'r') as content_file: + parsed = yaml.load(content_file.read(), Loader=yaml.FullLoader) + + cfg = easymapping.HaproxyConfigGenerator(parsed) + haproxy_config = cfg.generate() + assert len(haproxy_config) > 0 + + # assert on auth on stats + assert "stats auth admin:test123" in haproxy_config + + # assert that we found redirect + assert "redirect prefix http://host1.com.br code 301 if { hdr(host) -i www.host1.com.br }" in haproxy_config + + # assert that we found the services + assert "frontend http_in_80_1" in haproxy_config + assert "bind *:80" + assert "frontend http_in_443_2" in haproxy_config + assert "bind *:443" + assert "frontend http_in_8080_3" in haproxy_config + assert "bind :*8080" + + # verify ssl config + assert "frontend http_in_443_2\n bind *:443 ssl crt BASE64_PEM_CERTIFICATE" in haproxy_config From 75deebc783f9174fad857eafe285a03d13b78694 Mon Sep 17 00:00:00 2001 From: till Date: Fri, 5 Jun 2020 16:27:02 +0200 Subject: [PATCH 5/5] Update: more tests and ssl health-check - restructure more tests for the "mode http" - add tests tests for "mode tcp" - add the ability to ssl health check on a backend (in mode tcp) --- README.md | 5 +++- easymapping/__init__.py | 6 +++++ templates/haproxy.cfg.j2 | 4 +-- tests/fixtures/services-tcp | 2 +- tests/test_parser.py | 54 +++++++++++++++++++++++++++++++------ 5 files changed, 59 insertions(+), 12 deletions(-) diff --git a/README.md b/README.md index 09373a8..7417dc7 100644 --- a/README.md +++ b/README.md @@ -86,7 +86,7 @@ Important: easyhaproxy needs to be in the same network of the containers or othe | com.byjg.easyhaproxy.host.[definition] | What is the host that the HAProxy will listen to. | | com.byjg.easyhaproxy.redirect.[definition] | (Optional) Host redirects from connections in the port defined above. | | com.byjg.easyhaproxy.sslcert.[definition] | (Optional) Cert PEM Base64 encoded. | - +| com.byjg.easyhaproxy.health-check.[definition] | (Optional) `ssl`, enable health check via SSL in `mode tcp` (Defaults to "empty") | Note: if you are deploying a stack set labels at the `deploy` level: @@ -135,11 +135,14 @@ Used to pass on SSL-termination to a backend: docker run \ -l com.byjg.easyhaproxy.defintions=tcp-service \ -l com.byjg.easyhaproxy.mode.tcp-service=tcp \ + -l com.byjg.easyhaproxy.health-check.tcp-service=ssl \ -l com.byjg.easyhaproxy.port.tcp-service=443 .... \ some/tcp-service ``` + - enable health-check via SSL on the backend with the optional `health-check` label + ### Redirect Example: ```bash diff --git a/easymapping/__init__.py b/easymapping/__init__.py index d0d0e69..a26b99f 100644 --- a/easymapping/__init__.py +++ b/easymapping/__init__.py @@ -101,6 +101,7 @@ class HaproxyConfigGenerator: if key not in easymapping: easymapping[key] = { "mode": mode, + "health-check": "", "port": port, "hosts": dict(), "redirect": dict(), @@ -112,6 +113,11 @@ class HaproxyConfigGenerator: "80" ) + easymapping[key]["health-check"] = self.label.get( + self.label.create(["health-check", definition]), + "" + ) + easymapping[key]["hosts"][d[host_label]] = "{}:{}".format(container, ct_port) # handle SSL diff --git a/templates/haproxy.cfg.j2 b/templates/haproxy.cfg.j2 index 6b502d6..192bbd4 100644 --- a/templates/haproxy.cfg.j2 +++ b/templates/haproxy.cfg.j2 @@ -60,8 +60,8 @@ backend srv_{{ host }} http-request add-header X-Forwarded-Proto https if { ssl_fc } {% elif mode == "tcp" %} option tcp-check - tcp-check connect + tcp-check connect{{ " ssl" if o["health-check"] == "ssl" }} {% endif %} - server srv {{ o["hosts"][k] }} check weight 1 + server srv {{ o["hosts"][k] }} check weight 1{{ " verify none" if o["health-check"] == "ssl" }} {% endfor %} {% endfor %} diff --git a/tests/fixtures/services-tcp b/tests/fixtures/services-tcp index 8520b59..64bb7a6 100644 --- a/tests/fixtures/services-tcp +++ b/tests/fixtures/services-tcp @@ -1,2 +1,2 @@ -test_agent={"com.byjg.easyhaproxy.definitions":"agent","com.byjg.easyhaproxy.host.agent":"agent.quantum.local","com.byjg.easyhaproxy.localport.agent":"9001","com.byjg.easyhaproxy.mode.agent":"tcp","com.byjg.easyhaproxy.port.agent":"31339","com.docker.stack.image":"portainer/agent:1.5.1","com.docker.stack.namespace":"test","com.planetary-quantum":"monitoring"} +test_agent={"com.byjg.easyhaproxy.definitions":"agent","com.byjg.easyhaproxy.host.agent":"agent.quantum.local","com.byjg.easyhaproxy.localport.agent":"9001","com.byjg.easyhaproxy.mode.agent":"tcp","com.byjg.easyhaproxy.port.agent":"31339","com.docker.stack.image":"portainer/agent:1.5.1","com.docker.stack.namespace":"test", "com.byjg.easyhaproxy.health-check.agent":"ssl"} test_proxy={"com.docker.stack.image":"byjg/easy-haproxy:local","com.docker.stack.namespace":"test"} diff --git a/tests/test_parser.py b/tests/test_parser.py index edf0357..0851833 100644 --- a/tests/test_parser.py +++ b/tests/test_parser.py @@ -59,12 +59,50 @@ def test_parser_static(): assert "redirect prefix http://host1.com.br code 301 if { hdr(host) -i www.host1.com.br }" in haproxy_config # assert that we found the services - assert "frontend http_in_80_1" in haproxy_config - assert "bind *:80" - assert "frontend http_in_443_2" in haproxy_config - assert "bind *:443" - assert "frontend http_in_8080_3" in haproxy_config - assert "bind :*8080" + frontend_http_cfg = "frontend http_in_80_1\n" + frontend_http_cfg += " bind *:80" + assert frontend_http_cfg in haproxy_config - # verify ssl config - assert "frontend http_in_443_2\n bind *:443 ssl crt BASE64_PEM_CERTIFICATE" in haproxy_config + frontend_https_cfg = "frontend http_in_443_2\n" + frontend_https_cfg += " bind *:443" + assert frontend_https_cfg in haproxy_config + + # print(haproxy_config) + frontend_http8080_cfg = "frontend http_in_8080_3\n" + frontend_http8080_cfg += " bind *:8080" + assert frontend_http8080_cfg in haproxy_config + + + # verify ssl config with certificate + frontend_ssl_cfg = "frontend http_in_443_2\n" + frontend_ssl_cfg += " bind *:443 ssl crt BASE64_PEM_CERTIFICATE" + assert frontend_ssl_cfg in haproxy_config + + +def test_parser_tcp(): + lineList = load_fixture("services-tcp") + + result = { + "customerrors": False + } + + cfg = easymapping.HaproxyConfigGenerator(result) + haproxy_config = cfg.generate(lineList) + # print(haproxy_config) + + frontend_cfg = "frontend tcp_in_31339_1\n" + frontend_cfg += " bind *:31339\n" + frontend_cfg += " mode tcp\n" + frontend_cfg += " option tcplog\n" + frontend_cfg += " log global\n" + frontend_cfg += " default_backend srv_agent_quantum_local_31339_1\n\n" + assert frontend_cfg in haproxy_config + + backend_cfg = "backend srv_agent_quantum_local_31339_1\n" + backend_cfg += " balance roundrobin\n" + backend_cfg += " mode tcp\n" + backend_cfg += " option tcp-check\n" + backend_cfg += " tcp-check connect ssl\n" + backend_cfg += " server srv test_agent:9001 check weight 1 verify none" + + assert backend_cfg in haproxy_config