diff --git a/docs/concepts/limitations.md b/docs/concepts/limitations.md index a5c4061..5dd1fa8 100644 --- a/docs/concepts/limitations.md +++ b/docs/concepts/limitations.md @@ -8,7 +8,9 @@ sidebar_label: "Limitations" ## EasyHAProxy will not work with --network=host :::danger Network Mode Incompatibility -The `--network=host` option **cannot** be used with the EasyHAProxy container itself due to its networking requirements. +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. ::: ## Considerations for Multiple Replica Deployments in EasyHAProxy diff --git a/docs/getting-started/docker.md b/docs/getting-started/docker.md index 35de39f..ef8b95c 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. -- EasyHAProxy itself cannot run with the `--network=host` option. See [limitations](../concepts/limitations.md) for details. +- This method does not work with containers that use the `--network=host` option. See [limitations](../concepts/limitations.md) for details. ::: ## Step 1 — Create a shared network @@ -49,32 +49,6 @@ 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 91d01d8..9e88d32 100644 --- a/docs/reference/environment-variables.md +++ b/docs/reference/environment-variables.md @@ -9,7 +9,6 @@ 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 03d281a..5628b1e 100644 --- a/src/functions/container_env.py +++ b/src/functions/container_env.py @@ -38,8 +38,6 @@ 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 0e6b64f..3911a4d 100644 --- a/src/processor/docker.py +++ b/src/processor/docker.py @@ -1,10 +1,7 @@ -import ipaddress import socket import docker -from functions import ContainerEnv, logger_easyhaproxy - from .interface import ProcessorInterface @@ -15,145 +12,24 @@ class Docker(ProcessorInterface): super().__init__() def inspect_network(self): - self.parsed_object = {} - own_container = None try: - own_container = self.client.containers.get(socket.gethostname()) - ha_proxy_network_name = next(iter(own_container.attrs["NetworkSettings"]["Networks"])) + ha_proxy_network_name = next( + iter(self.client.containers.get(socket.gethostname()).attrs["NetworkSettings"]["Networks"])) except Exception: # HAProxy is not running in a container, get first container network - own_container = None - ha_proxy_network_name = self._first_attachable_network() - if ha_proxy_network_name is None: + 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 = 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 - - 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) - - 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: + # 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) - 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 {} + container = self.client.containers.get(container.name) # refresh object - 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) + ip_address = container.attrs["NetworkSettings"]["Networks"][ha_proxy_network_name]["IPAddress"] + self.parsed_object[ip_address] = container.labels \ No newline at end of file diff --git a/tests/conftest.py b/tests/conftest.py index 665e7db..3aa2f7f 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -23,7 +23,6 @@ _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 806e99c..e80e602 100644 --- a/tests/test_containerenv.py +++ b/tests/test_containerenv.py @@ -8,7 +8,6 @@ def test_container_env_empty(): "customerrors": False, "ssl_mode": "default", "lookup_label": "easyhaproxy", - "host_network_ip": "", "logLevel": { "easyhaproxy": Functions.DEBUG, "haproxy": Functions.INFO, @@ -43,7 +42,6 @@ def test_container_env_customerrors(): "customerrors": True, "ssl_mode": "default", "lookup_label": "easyhaproxy", - "host_network_ip": "", "logLevel": { "easyhaproxy": Functions.DEBUG, "haproxy": Functions.INFO, @@ -78,7 +76,6 @@ def test_container_env_sslmode(): "customerrors": False, "ssl_mode": "strict", "lookup_label": "easyhaproxy", - "host_network_ip": "", "logLevel": { "easyhaproxy": Functions.DEBUG, "haproxy": Functions.INFO, @@ -114,7 +111,6 @@ def test_container_env_stats(): "customerrors": False, "ssl_mode": "default", "lookup_label": "easyhaproxy", - "host_network_ip": "", "logLevel": { "easyhaproxy": Functions.DEBUG, "haproxy": Functions.INFO, @@ -150,7 +146,6 @@ def test_container_env_stats_password(): "customerrors": False, "ssl_mode": "default", "lookup_label": "easyhaproxy", - "host_network_ip": "", "stats": { "username": "admin", "password": "xyz", @@ -193,7 +188,6 @@ def test_container_env_stats_password_2(): "customerrors": False, "ssl_mode": "default", "lookup_label": "easyhaproxy", - "host_network_ip": "", "stats": { "username": "abc", "password": "xyz", @@ -236,7 +230,6 @@ def test_container_env_certbot_email(): "customerrors": False, "ssl_mode": "default", "lookup_label": "easyhaproxy", - "host_network_ip": "", "logLevel": { "easyhaproxy": Functions.DEBUG, "haproxy": Functions.INFO, @@ -279,7 +272,6 @@ def test_container_env_certbot_full(): "customerrors": False, "ssl_mode": "default", "lookup_label": "easyhaproxy", - "host_network_ip": "", "logLevel": { "easyhaproxy": Functions.DEBUG, "haproxy": Functions.INFO, @@ -324,7 +316,6 @@ 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 fd7ba7c..340ee5b 100644 --- a/tests/test_docker.py +++ b/tests/test_docker.py @@ -105,277 +105,4 @@ def test_processor_docker(): container2.stop() -def _skip_unless_docker_is_idle(): - try: - client = docker.from_env() - except docker.errors.DockerException: - pytest.skip("There is no docker environment") - - 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", - name="test_processor_docker_unlabeled", - detach=True, - auto_remove=True, - remove=True) - time.sleep(1) - container = client.containers.run("byjg/static-httpserver", - name="test_processor_docker_labeled", - detach=True, - auto_remove=True, - remove=True, - network=network.name, - labels={ - "easyhaproxy.labeled.port": "80", - "easyhaproxy.labeled.localport": "8080", - "easyhaproxy.labeled.host": "labeled.local", - }) - try: - time.sleep(1) - - static = ProcessorInterface.factory(ProcessorInterface.DOCKER) - - # The labeled container is served through the network it already belongs to. - labeled_ip = client.containers.get(container.name).attrs["NetworkSettings"]["Networks"][network.name][ - "IPAddress"] - assert _get_ip_host(static.get_parsed_object(), "easyhaproxy.labeled") == labeled_ip - - # The container without any label is neither inspected nor connected to the network. - unlabeled_networks = client.containers.get(unlabeled.name).attrs["NetworkSettings"]["Networks"] - assert list(unlabeled_networks.keys()) == ["bridge"] - assert unlabeled_networks["bridge"]["IPAddress"] not in static.get_parsed_object() - finally: - container.stop() - unlabeled.stop() - time.sleep(1) - network.remove() - - # test_processor_docker()