diff --git a/docs/concepts/limitations.md b/docs/concepts/limitations.md index 5dd1fa8..a5c4061 100644 --- a/docs/concepts/limitations.md +++ b/docs/concepts/limitations.md @@ -8,9 +8,7 @@ sidebar_label: "Limitations" ## EasyHAProxy will not work with --network=host :::danger Network Mode Incompatibility -The `--network=host` option **cannot** be used with EasyHAProxy due to its networking requirements. - -EasyHAProxy needs to inspect and interact with Docker containers from within the Docker network where it's running. Using the `--network=host` option bypasses Docker networking, preventing EasyHAProxy from accessing and configuring containers effectively. +The `--network=host` option **cannot** be used with the EasyHAProxy container itself due to its networking requirements. ::: ## Considerations for Multiple Replica Deployments in EasyHAProxy diff --git a/docs/getting-started/docker.md b/docs/getting-started/docker.md index ef8b95c..35de39f 100644 --- a/docs/getting-started/docker.md +++ b/docs/getting-started/docker.md @@ -9,7 +9,7 @@ EasyHAProxy inspects running Docker containers, reads their labels, and configur :::warning Limitations - You cannot mix Docker containers with Swarm containers. -- This method does not work with containers that use the `--network=host` option. See [limitations](../concepts/limitations.md) for details. +- EasyHAProxy itself cannot run with the `--network=host` option. See [limitations](../concepts/limitations.md) for details. ::: ## Step 1 — Create a shared network @@ -49,6 +49,32 @@ docker run -d \ EasyHAProxy detects this container automatically and routes traffic from `example.org:80` to port 8080 in your container. You do not need to expose any container ports. +### Containers using `--network=host` + +Containers that share the host network namespace (`--network=host`, or `network_mode: host` in +Compose) cannot join the EasyHAProxy network, so EasyHAProxy reaches them through the gateway of +its own network instead. Label them as usual, but set `localport` to the port the service binds +**on the host**: + +```yaml +services: + myapp: + image: my/image:tag + network_mode: host + labels: + easyhaproxy.myapp.host: example.org + easyhaproxy.myapp.port: 80 + easyhaproxy.myapp.localport: 8080 # the port on the host +``` + +The same applies to containers sharing another container's namespace +(`network_mode: "container:xxx"`, as used by VPN sidecars); those are reached at the address of +the container owning the namespace. + +The service must bind `0.0.0.0` rather than `127.0.0.1`, otherwise it is unreachable from the +EasyHAProxy container. If your host firewall blocks the Docker bridge, or the detected gateway is +not the address you want, override it with `EASYHAPROXY_HOST_NETWORK_IP`. + ## Step 4 — Verify Open `http://example.org` in your browser (or `curl http://example.org`). Traffic should reach your container. diff --git a/docs/reference/environment-variables.md b/docs/reference/environment-variables.md index 9e88d32..91d01d8 100644 --- a/docs/reference/environment-variables.md +++ b/docs/reference/environment-variables.md @@ -9,6 +9,7 @@ sidebar_label: "Environment Variables" |---------------------------|------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------|--------------------| | 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_HOST_NETWORK_IP | (Optional) Address used to reach containers running with `--network=host`. Defaults to the gateway of the EasyHAProxy network. | *auto* | | EASYHAPROXY_BASE_PATH | (Optional) Base directory for all EasyHAProxy files. All paths (config, certs, plugins, www) are constructed relative to this base. | `/etc/easyhaproxy` | | EASYHAPROXY_CERTBOT_* | (Optional) Enable Let's Encrypt or any other ACME certificate. See more: [acme](../guides/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` | diff --git a/src/functions/container_env.py b/src/functions/container_env.py index 5628b1e..03d281a 100644 --- a/src/functions/container_env.py +++ b/src/functions/container_env.py @@ -38,6 +38,8 @@ class ContainerEnv: env_vars["lookup_label"] = os.getenv("EASYHAPROXY_LABEL_PREFIX") if os.getenv( "EASYHAPROXY_LABEL_PREFIX") else "easyhaproxy" + env_vars["host_network_ip"] = os.getenv("EASYHAPROXY_HOST_NETWORK_IP", "") + env_vars["logLevel"] = { "easyhaproxy": os.getenv("EASYHAPROXY_LOG_LEVEL") if os.getenv( "EASYHAPROXY_LOG_LEVEL") else Functions.DEBUG, diff --git a/src/processor/docker.py b/src/processor/docker.py index a41e747..0e6b64f 100644 --- a/src/processor/docker.py +++ b/src/processor/docker.py @@ -1,7 +1,10 @@ +import ipaddress import socket import docker +from functions import ContainerEnv, logger_easyhaproxy + from .interface import ProcessorInterface @@ -12,27 +15,145 @@ class Docker(ProcessorInterface): super().__init__() def inspect_network(self): + self.parsed_object = {} + own_container = None try: - ha_proxy_network_name = next( - iter(self.client.containers.get(socket.gethostname()).attrs["NetworkSettings"]["Networks"])) + own_container = self.client.containers.get(socket.gethostname()) + ha_proxy_network_name = next(iter(own_container.attrs["NetworkSettings"]["Networks"])) except Exception: # HAProxy is not running in a container, get first container network - if len(self.client.containers.list()) == 0: + own_container = None + ha_proxy_network_name = self._first_attachable_network() + if ha_proxy_network_name is None: return - 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) + host_address = self._host_gateway_address(own_container, ha_proxy_network, ha_proxy_network_name) - self.parsed_object = {} for container in self.client.containers.list(): if not any(self.label in key for key in container.labels): continue - # Issue 32 - Docker container cannot connect to containers in different network. - if ha_proxy_network_name not in container.attrs["NetworkSettings"]["Networks"].keys(): - ha_proxy_network.connect(container.name) - container = self.client.containers.get(container.name) # refresh object + ip_address = self._container_address(container, ha_proxy_network, ha_proxy_network_name, host_address) + if ip_address is None: + continue + self._merge_labels(ip_address, container) - ip_address = container.attrs["NetworkSettings"]["Networks"][ha_proxy_network_name]["IPAddress"] - self.parsed_object[ip_address] = container.labels \ No newline at end of file + def _first_attachable_network(self): + # Containers sharing a network namespace have no network of their own to borrow. + for container in self.client.containers.list(): + if self._network_mode(container) in ["host", "none"] or self._namespace_owner_id(container) is not None: + continue + networks = container.attrs["NetworkSettings"]["Networks"] or {} + if networks: + return next(iter(networks)) + return None + + @staticmethod + def _network_mode(container): + return (container.attrs.get("HostConfig") or {}).get("NetworkMode", "") + + @staticmethod + def _namespace_owner_id(container): + network_mode = Docker._network_mode(container) + if network_mode.startswith("container:"): + return network_mode.split(":", 1)[1] + return None + + def _container_address(self, container, ha_proxy_network, ha_proxy_network_name, host_address, seen=None): + """ + Resolve the address HAProxy must use to reach this container. + Returns None when the container cannot be reached. + """ + # Issue 47 - Containers sharing the host network namespace cannot join any other network. + if self._network_mode(container) == "host": + if host_address is None: + logger_easyhaproxy.warning( + f"Container '{container.name}' uses the host network, but the address of the host could not be " + f"determined. Set EASYHAPROXY_HOST_NETWORK_IP to fix it. Skipping it." + ) + return host_address + + owner_id = self._namespace_owner_id(container) + if owner_id is not None: + return self._namespace_owner_address( + container, owner_id, ha_proxy_network, ha_proxy_network_name, host_address, seen + ) + + networks = container.attrs["NetworkSettings"]["Networks"] or {} + # Issue 32 - Docker container cannot connect to containers in different network. + if ha_proxy_network_name not in networks.keys(): + try: + ha_proxy_network.connect(container.name) + except docker.errors.APIError as e: + logger_easyhaproxy.warning( + f"Container '{container.name}' could not be connected to the network " + f"'{ha_proxy_network_name}': {e}. Skipping it." + ) + return None + container = self.client.containers.get(container.name) # refresh object + networks = container.attrs["NetworkSettings"]["Networks"] or {} + + return (networks.get(ha_proxy_network_name) or {}).get("IPAddress") or None + + def _namespace_owner_address(self, container, owner_id, ha_proxy_network, ha_proxy_network_name, host_address, + seen=None): + """ + Containers started with `network_mode: container:xxx` are reachable at the address of the + container owning the network namespace. + """ + seen = seen or set() + if container.id in seen: + logger_easyhaproxy.warning( + f"Container '{container.name}' has a circular network namespace reference. Skipping it." + ) + return None + seen.add(container.id) + + try: + owner = self.client.containers.get(owner_id) + except docker.errors.NotFound: + logger_easyhaproxy.warning( + f"Container '{container.name}' shares the network namespace of '{owner_id}', which was not found. " + f"Skipping it." + ) + return None + + return self._container_address(owner, ha_proxy_network, ha_proxy_network_name, host_address, seen) + + def _host_gateway_address(self, own_container, ha_proxy_network, ha_proxy_network_name): + """ + Address the host is reachable at from inside the HAProxy network. Every container sharing the + host network namespace is served through it. + """ + host_network_ip = ContainerEnv.read()["host_network_ip"] + if host_network_ip: + return host_network_ip + + if own_container is not None: + gateway = (own_container.attrs["NetworkSettings"]["Networks"].get(ha_proxy_network_name) or {}).get( + "Gateway") + if gateway: + return gateway + + for config in (ha_proxy_network.attrs.get("IPAM") or {}).get("Config") or []: + if config.get("Gateway"): + return config["Gateway"] + try: + return str(next(ipaddress.ip_network(config["Subnet"]).hosts())) + except (KeyError, ValueError, StopIteration): + continue + + return None + + def _merge_labels(self, ip_address, container): + # Every container sharing the host network namespace resolves to the same address, so the + # labels are merged instead of replaced. + labels = self.parsed_object.setdefault(ip_address, {}) + for key, value in container.labels.items(): + if key.startswith(self.label) and key in labels and labels[key] != value: + logger_easyhaproxy.warning( + f"Container '{container.name}' redefines '{key}' for '{ip_address}' as '{value}' instead of " + f"'{labels[key]}'. Give each container a unique definition name." + ) + labels.update(container.labels) diff --git a/tests/conftest.py b/tests/conftest.py index 3aa2f7f..665e7db 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -23,6 +23,7 @@ _CONTAINER_ENV_VARS = [ "HAPROXY_CUSTOMERRORS", "EASYHAPROXY_SSL_MODE", "EASYHAPROXY_LABEL_PREFIX", + "EASYHAPROXY_HOST_NETWORK_IP", "EASYHAPROXY_LOG_LEVEL", "HAPROXY_LOG_LEVEL", "CERTBOT_LOG_LEVEL", diff --git a/tests/test_containerenv.py b/tests/test_containerenv.py index e80e602..806e99c 100644 --- a/tests/test_containerenv.py +++ b/tests/test_containerenv.py @@ -8,6 +8,7 @@ def test_container_env_empty(): "customerrors": False, "ssl_mode": "default", "lookup_label": "easyhaproxy", + "host_network_ip": "", "logLevel": { "easyhaproxy": Functions.DEBUG, "haproxy": Functions.INFO, @@ -42,6 +43,7 @@ def test_container_env_customerrors(): "customerrors": True, "ssl_mode": "default", "lookup_label": "easyhaproxy", + "host_network_ip": "", "logLevel": { "easyhaproxy": Functions.DEBUG, "haproxy": Functions.INFO, @@ -76,6 +78,7 @@ def test_container_env_sslmode(): "customerrors": False, "ssl_mode": "strict", "lookup_label": "easyhaproxy", + "host_network_ip": "", "logLevel": { "easyhaproxy": Functions.DEBUG, "haproxy": Functions.INFO, @@ -111,6 +114,7 @@ def test_container_env_stats(): "customerrors": False, "ssl_mode": "default", "lookup_label": "easyhaproxy", + "host_network_ip": "", "logLevel": { "easyhaproxy": Functions.DEBUG, "haproxy": Functions.INFO, @@ -146,6 +150,7 @@ def test_container_env_stats_password(): "customerrors": False, "ssl_mode": "default", "lookup_label": "easyhaproxy", + "host_network_ip": "", "stats": { "username": "admin", "password": "xyz", @@ -188,6 +193,7 @@ def test_container_env_stats_password_2(): "customerrors": False, "ssl_mode": "default", "lookup_label": "easyhaproxy", + "host_network_ip": "", "stats": { "username": "abc", "password": "xyz", @@ -230,6 +236,7 @@ def test_container_env_certbot_email(): "customerrors": False, "ssl_mode": "default", "lookup_label": "easyhaproxy", + "host_network_ip": "", "logLevel": { "easyhaproxy": Functions.DEBUG, "haproxy": Functions.INFO, @@ -272,6 +279,7 @@ def test_container_env_certbot_full(): "customerrors": False, "ssl_mode": "default", "lookup_label": "easyhaproxy", + "host_network_ip": "", "logLevel": { "easyhaproxy": Functions.DEBUG, "haproxy": Functions.INFO, @@ -316,6 +324,7 @@ def test_container_log_level(): "customerrors": False, "ssl_mode": "default", "lookup_label": "easyhaproxy", + "host_network_ip": "", "logLevel": { "easyhaproxy": Functions.ERROR, "haproxy": Functions.FATAL, diff --git a/tests/test_docker.py b/tests/test_docker.py index 143b1f3..fd7ba7c 100644 --- a/tests/test_docker.py +++ b/tests/test_docker.py @@ -105,7 +105,7 @@ def test_processor_docker(): container2.stop() -def test_processor_docker_ignores_unlabeled(): +def _skip_unless_docker_is_idle(): try: client = docker.from_env() except docker.errors.DockerException: @@ -114,6 +114,230 @@ def test_processor_docker_ignores_unlabeled(): if len(client.containers.list()) > 0: pytest.skip("I cannot run this test with other containers running.") + return client + + +def _bridge_gateway(client): + return client.networks.get("bridge").attrs["IPAM"]["Config"][0]["Gateway"] + + +def test_processor_docker_host_network(): + client = _skip_unless_docker_is_idle() + + container = client.containers.run("byjg/static-httpserver", + name="test_processor_docker_host", + detach=True, + auto_remove=True, + remove=True, + network_mode="host", + labels={ + "easyhaproxy.hostmode.port": "80", + "easyhaproxy.hostmode.localport": "8080", + "easyhaproxy.hostmode.host": "hostmode.local", + }) + container2 = client.containers.run("byjg/static-httpserver", + name="test_processor_docker_bridge", + detach=True, + auto_remove=True, + remove=True, + labels={ + "easyhaproxy.bridged.port": "80", + "easyhaproxy.bridged.localport": "8080", + "easyhaproxy.bridged.host": "bridged.local", + }) + try: + time.sleep(1) + + static = ProcessorInterface.factory(ProcessorInterface.DOCKER) + + assert { + 'easyhaproxy.hostmode.host': 'hostmode.local', + 'easyhaproxy.hostmode.localport': '8080', + 'easyhaproxy.hostmode.port': '80', + } == _get_hydrated_object(static.get_parsed_object(), "easyhaproxy.hostmode") + + # The container shares the host network namespace, so it is served through the gateway. + assert _get_ip_host(static.get_parsed_object(), "easyhaproxy.hostmode") == _bridge_gateway(client) + + # The container on the bridge network keeps being served through its own address. + bridged_ip = client.containers.get(container2.name).attrs["NetworkSettings"]["Networks"]["bridge"]["IPAddress"] + assert _get_ip_host(static.get_parsed_object(), "easyhaproxy.bridged") == bridged_ip + + static.get_haproxy_conf() + assert static.get_hosts() == [ + 'bridged.local:80', + 'hostmode.local:80' + ] + finally: + container.stop() + container2.stop() + + +def test_processor_docker_host_network_merges_labels(): + client = _skip_unless_docker_is_idle() + + container = client.containers.run("byjg/static-httpserver", + name="test_processor_docker_host1", + detach=True, + auto_remove=True, + remove=True, + network_mode="host", + labels={ + "easyhaproxy.first.port": "80", + "easyhaproxy.first.localport": "8080", + "easyhaproxy.first.host": "first.local", + }) + container2 = client.containers.run("byjg/static-httpserver", + name="test_processor_docker_host2", + detach=True, + auto_remove=True, + remove=True, + network_mode="host", + environment={"PORT": "9000", "TLS_PORT": "9443"}, + labels={ + "easyhaproxy.second.port": "80", + "easyhaproxy.second.localport": "9000", + "easyhaproxy.second.host": "second.local", + }) + container3 = client.containers.run("byjg/static-httpserver", + name="test_processor_docker_bridge_merge", + detach=True, + auto_remove=True, + remove=True, + labels={ + "easyhaproxy.bridged.port": "80", + "easyhaproxy.bridged.localport": "8080", + "easyhaproxy.bridged.host": "bridged.local", + }) + try: + time.sleep(1) + + static = ProcessorInterface.factory(ProcessorInterface.DOCKER) + gateway = _bridge_gateway(client) + + # Both containers resolve to the same address, so their labels are merged, not replaced. + assert _get_ip_host(static.get_parsed_object(), "easyhaproxy.first") == gateway + assert _get_ip_host(static.get_parsed_object(), "easyhaproxy.second") == gateway + merged = {key: value for key, value in static.get_parsed_object()[gateway].items() + if key.startswith("easyhaproxy.")} + assert { + 'easyhaproxy.first.host': 'first.local', + 'easyhaproxy.first.localport': '8080', + 'easyhaproxy.first.port': '80', + 'easyhaproxy.second.host': 'second.local', + 'easyhaproxy.second.localport': '9000', + 'easyhaproxy.second.port': '80', + } == merged + + haproxy_cfg = static.get_haproxy_conf() + assert f"server srv-0 {gateway}:8080" in haproxy_cfg + assert f"server srv-0 {gateway}:9000" in haproxy_cfg + finally: + container.stop() + container2.stop() + container3.stop() + + +def test_processor_docker_host_network_ip_override(): + client = _skip_unless_docker_is_idle() + + container = client.containers.run("byjg/static-httpserver", + name="test_processor_docker_host_override", + detach=True, + auto_remove=True, + remove=True, + network_mode="host", + labels={ + "easyhaproxy.hostmode.port": "80", + "easyhaproxy.hostmode.localport": "8080", + "easyhaproxy.hostmode.host": "hostmode.local", + }) + container2 = client.containers.run("byjg/static-httpserver", + name="test_processor_docker_bridge_override", + detach=True, + auto_remove=True, + remove=True, + labels={ + "easyhaproxy.bridged.port": "80", + "easyhaproxy.bridged.localport": "8080", + "easyhaproxy.bridged.host": "bridged.local", + }) + try: + time.sleep(1) + + os.environ['EASYHAPROXY_HOST_NETWORK_IP'] = '10.20.30.40' + + static = ProcessorInterface.factory(ProcessorInterface.DOCKER) + + assert _get_ip_host(static.get_parsed_object(), "easyhaproxy.hostmode") == '10.20.30.40' + finally: + del os.environ['EASYHAPROXY_HOST_NETWORK_IP'] + container.stop() + container2.stop() + + +def test_processor_docker_shared_namespace(): + client = _skip_unless_docker_is_idle() + + owner = client.containers.run("byjg/static-httpserver", + name="test_processor_docker_owner", + detach=True, + auto_remove=True, + remove=True) + time.sleep(1) + container = client.containers.run("byjg/static-httpserver", + name="test_processor_docker_sidecar", + detach=True, + auto_remove=True, + remove=True, + environment={"PORT": "9000", "TLS_PORT": "9443"}, + network_mode="container:test_processor_docker_owner", + labels={ + "easyhaproxy.sidecar.port": "80", + "easyhaproxy.sidecar.localport": "9000", + "easyhaproxy.sidecar.host": "sidecar.local", + }) + try: + time.sleep(1) + + static = ProcessorInterface.factory(ProcessorInterface.DOCKER) + + # The sidecar is reachable at the address of the container owning the network namespace. + owner_ip = client.containers.get(owner.name).attrs["NetworkSettings"]["Networks"]["bridge"]["IPAddress"] + assert _get_ip_host(static.get_parsed_object(), "easyhaproxy.sidecar") == owner_ip + finally: + container.stop() + owner.stop() + + +def test_processor_docker_host_network_only(): + client = _skip_unless_docker_is_idle() + + container = client.containers.run("byjg/static-httpserver", + name="test_processor_docker_host_only", + detach=True, + auto_remove=True, + remove=True, + network_mode="host", + labels={ + "easyhaproxy.hostmode.port": "80", + "easyhaproxy.hostmode.localport": "8080", + "easyhaproxy.hostmode.host": "hostmode.local", + }) + try: + time.sleep(1) + + # There is no network to borrow, but the discovery must not fail. + static = ProcessorInterface.factory(ProcessorInterface.DOCKER) + assert static.get_parsed_object() == {} + assert static.get_haproxy_conf() != "" + finally: + container.stop() + + +def test_processor_docker_ignores_unlabeled(): + client = _skip_unless_docker_is_idle() + network = client.networks.create("test_processor_docker_network", driver="bridge") # The most recent container is inspected first, so the labeled one defines the network to use. unlabeled = client.containers.run("byjg/static-httpserver",