diff --git a/README.md b/README.md index 08d44f4..eb93115 100644 --- a/README.md +++ b/README.md @@ -31,17 +31,17 @@ tcp: - address: "host.docker.internal:8443" ``` -`HostSNI(...)` matches the hostname sent by the client during the TLS handshake. If a backend listens on the Docker host itself, use `host.docker.internal:PORT`. +`HostSNI(...)` matches the hostname sent by the client during the TLS handshake. This deployment uses host networking so Traefik can reach services bound to the Docker host loopback address, such as `127.0.0.1:8443`. HTTP-01 certificate challenges use normal HTTP routing on port `80`, not SNI. The included example forwards: ```text -lab.younerd.org/.well-known/acme-challenge/* -> host.docker.internal:8080 -azionelab.org/.well-known/acme-challenge/* -> host.docker.internal:9080 -www.azionelab.org/.well-known/acme-challenge/* -> host.docker.internal:9080 +lab.younerd.org -> 127.0.0.1:8080 +azionelab.org -> 127.0.0.1:9080 +www.azionelab.org -> 127.0.0.1:9080 ``` -Adjust `8080` and `9080` to the local ports where the Certbot challenge responder or webroot service is listening. +Adjust `8080` and `9080` to the local ports where the backend HTTP service is listening. The full HTTP request is forwarded so the backend can handle redirects and ACME challenge paths. ## Run diff --git a/compose.yaml b/compose.yaml index 4657981..f9843a7 100644 --- a/compose.yaml +++ b/compose.yaml @@ -5,16 +5,14 @@ services: restart: unless-stopped command: - --configFile=/etc/traefik/traefik.yml - ports: - - "80:8080/tcp" - - "443:8443/tcp" - extra_hosts: - - "host.docker.internal:host-gateway" + network_mode: host volumes: - ./config:/etc/traefik:ro read_only: true cap_drop: - ALL + cap_add: + - NET_BIND_SERVICE security_opt: - no-new-privileges:true healthcheck: diff --git a/config/dynamic/tcp-services.yml b/config/dynamic/tcp-services.yml index f4cfd22..bfb714f 100644 --- a/config/dynamic/tcp-services.yml +++ b/config/dynamic/tcp-services.yml @@ -3,25 +3,25 @@ http: lab-younerd-acme: entryPoints: - web - rule: "Host(`lab.younerd.org`) && PathPrefix(`/.well-known/acme-challenge/`)" + rule: "Host(`lab.younerd.org`)" service: lab-younerd-acme azionelab-acme: entryPoints: - web - rule: "(Host(`azionelab.org`) || Host(`www.azionelab.org`)) && PathPrefix(`/.well-known/acme-challenge/`)" + rule: "Host(`azionelab.org`) || Host(`www.azionelab.org`)" service: azionelab-acme services: lab-younerd-acme: loadBalancer: servers: - - url: "http://host.docker.internal:8080" + - url: "http://127.0.0.1:8080" azionelab-acme: loadBalancer: servers: - - url: "http://host.docker.internal:9080" + - url: "http://127.0.0.1:9080" tcp: routers: @@ -45,9 +45,9 @@ tcp: lab-younerd: loadBalancer: servers: - - address: "host.docker.internal:8443" + - address: "127.0.0.1:8443" azionelab: loadBalancer: servers: - - address: "host.docker.internal:9443" + - address: "127.0.0.1:9443" diff --git a/config/traefik.yml b/config/traefik.yml index 5ae64a7..457d3e7 100644 --- a/config/traefik.yml +++ b/config/traefik.yml @@ -4,9 +4,9 @@ global: entryPoints: web: - address: ":8080" + address: ":80" tls: - address: ":8443" + address: ":443" ping: address: ":8082" diff --git a/docs/adr/0001-traefik-tcp-tls-passthrough.md b/docs/adr/0001-traefik-tcp-tls-passthrough.md index cce3d24..0e3c63b 100644 --- a/docs/adr/0001-traefik-tcp-tls-passthrough.md +++ b/docs/adr/0001-traefik-tcp-tls-passthrough.md @@ -12,9 +12,9 @@ The load balancer must run as a Docker container and forward TCP TLS traffic to Use Traefik Proxy in Docker with the file provider and TCP TLS passthrough routing. Static and dynamic configuration live under the host `./config` directory and are mounted read-only at `/etc/traefik`. -The public host port `443/tcp` is mapped to container port `8443/tcp` so the container can run with all Linux capabilities dropped. Traefik routes incoming TLS connections with `HostSNI(...)` to the configured TCP load-balancer services. +Traefik uses host networking so it can reach backends bound to host loopback addresses such as `127.0.0.1:8443`. The container is granted only `NET_BIND_SERVICE` so Traefik can bind host ports `80` and `443`. -The public host port `80/tcp` is mapped to container port `8080/tcp` for HTTP-01 certificate challenge traffic. HTTP challenge routes use `Host(...)` and `PathPrefix('/.well-known/acme-challenge/')` because SNI exists only in TLS handshakes. +Traefik routes incoming TLS connections with `HostSNI(...)` to the configured TCP load-balancer services. HTTP routes on port `80` use `Host(...)` and forward the full request to the selected backend because SNI exists only in TLS handshakes and backend nginx instances are responsible for redirects and ACME challenge handling. ## Consequences @@ -30,11 +30,11 @@ The Docker provider was not selected because the backends are addressed directly ## Security impact -The container does not mount the Docker socket, does not run privileged, drops Linux capabilities, uses `no-new-privileges`, and mounts configuration read-only. No private keys or credentials are required in this repository. Public `80/tcp` exposure is limited by HTTP routers to ACME challenge paths for configured hostnames. +The container does not mount the Docker socket, does not run privileged, drops Linux capabilities except `NET_BIND_SERVICE`, uses `no-new-privileges`, and mounts configuration read-only. No private keys or credentials are required in this repository. Host networking exposes Traefik directly on the host network namespace, so host firewall rules should restrict non-public ports such as the ping entrypoint. ## Operational impact -Operators update backend IP addresses and ports in `config/dynamic/tcp-services.yml`. The Compose deployment publishes `80/tcp` and `443/tcp`; the ping entrypoint is internal to the container and used by the healthcheck. +Operators update backend IP addresses and ports in `config/dynamic/tcp-services.yml`. Traefik listens directly on host `80/tcp` and `443/tcp`; the ping entrypoint is used by the healthcheck. ## Rollback diff --git a/docs/architecture.md b/docs/architecture.md index 77f8c31..1d48aa1 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -4,24 +4,24 @@ This project runs Traefik Proxy as a Dockerized TCP load balancer. ## Components -- `compose.yaml`: defines the Traefik container, published port, host-based configuration volume, container hardening, and healthcheck. +- `compose.yaml`: defines the Traefik container, host networking, host-based configuration volume, container hardening, and healthcheck. - `config/traefik.yml`: static Traefik configuration for HTTP, TCP TLS, ping, file provider, and logging. - `config/dynamic/tcp-services.yml`: dynamic HTTP challenge routers, TCP routers, and backend load-balancer services. ## Data flow 1. A client opens a TLS connection to the Docker host on `443/tcp`. -2. Docker forwards the connection to Traefik on container port `8443/tcp`. -3. Traefik matches the TCP router using `HostSNI(*)`. +2. Traefik receives the connection directly through host networking on `443/tcp`. +3. Traefik matches the TCP router using `HostSNI(...)`. 4. Traefik forwards the unchanged TCP stream to one configured backend IP address and port. 5. The selected backend terminates TLS and presents its own SSL certificate. For HTTP-01 certificate validation: 1. Let's Encrypt opens an HTTP connection to the Docker host on `80/tcp`. -2. Docker forwards the request to Traefik on container port `8080/tcp`. -3. Traefik matches the HTTP `Host(...)` and `PathPrefix('/.well-known/acme-challenge/')` router. -4. Traefik forwards the request to the configured local challenge responder. +2. Traefik receives the request directly through host networking on `80/tcp`. +3. Traefik matches the HTTP `Host(...)` router. +4. Traefik forwards the full HTTP request to the configured local backend so that backend redirects and ACME challenge handling remain in one place. ## Persistence @@ -31,6 +31,6 @@ No certificates, private keys, application data, or Docker socket are mounted in ## Deployment topology -Traefik runs as one Docker container on the load-balancer host. Backends are external to this Compose stack and must be reachable from the container through their configured IP addresses and ports. The Compose service defines `host.docker.internal` through Docker's `host-gateway` feature for backends that listen on the Docker host itself. +Traefik runs as one Docker container on the load-balancer host with `network_mode: host`. Backends are external to this Compose stack and can be reached on host loopback addresses such as `127.0.0.1:8443`. See `docs/adr/0001-traefik-tcp-tls-passthrough.md` for the design decision. diff --git a/docs/deployment.md b/docs/deployment.md index 08fb1cd..aea42a2 100644 --- a/docs/deployment.md +++ b/docs/deployment.md @@ -23,12 +23,12 @@ tcp: example: loadBalancer: servers: - - address: "host.docker.internal:8443" + - address: "127.0.0.1:8443" ``` -If a backend listens on the Docker host, use `host.docker.internal:PORT`. Compose maps that name through Docker's `host-gateway` feature. +This deployment uses host networking so Traefik can reach services bound to host loopback addresses such as `127.0.0.1:8443`. -HTTP-01 certificate challenges are routed separately with HTTP `Host(...)` rules on port `80`. Configure the `*-acme` services in `config/dynamic/tcp-services.yml` to point at the local port where the challenge responder listens. +HTTP traffic is routed separately with HTTP `Host(...)` rules on port `80`. Configure the HTTP services in `config/dynamic/tcp-services.yml` to point at the local backend port that should handle redirects and ACME challenge paths. The `./config` directory is the host-based configuration volume. It is mounted read-only into the container: @@ -39,9 +39,9 @@ volumes: ## Ports -- Host `80/tcp` -> container `8080/tcp`: public HTTP entrypoint for ACME HTTP-01 challenge paths. -- Host `443/tcp` -> container `8443/tcp`: public TCP TLS passthrough load-balancer entrypoint. -- Container `8082/tcp`: internal Traefik ping entrypoint used only by the container healthcheck. +- Host `80/tcp`: public HTTP entrypoint handled directly by Traefik through host networking. +- Host `443/tcp`: public TCP TLS passthrough entrypoint handled directly by Traefik through host networking. +- Host `8082/tcp`: Traefik ping entrypoint for the container healthcheck; restrict this port with host firewall rules if needed. ## Deploy diff --git a/docs/operations.md b/docs/operations.md index 76e8360..98d72f3 100644 --- a/docs/operations.md +++ b/docs/operations.md @@ -48,13 +48,13 @@ docker compose logs -f traefik To remove a backend from rotation, edit `config/dynamic/tcp-services.yml` and remove or comment out its `address` entry. Keep at least one reachable backend configured before applying the change. -For backends running on the Docker host, use `host.docker.internal:PORT` rather than `127.0.0.1:PORT`, because `127.0.0.1` inside the container refers to the container itself. +This deployment uses host networking. For backends running on the Docker host, use `127.0.0.1:PORT` when the service is bound to host loopback. -For HTTP-01 challenges, update the `*-acme` HTTP services in `config/dynamic/tcp-services.yml` to match the local challenge responder ports. +For HTTP on port `80`, update the HTTP services in `config/dynamic/tcp-services.yml` to match the local backend ports that handle redirects and ACME challenge paths. ## Known operational risks - TLS passthrough prevents Traefik from inspecting HTTP paths, headers, or certificate contents. - Backend certificates and renewal jobs must be monitored on each backend. - SNI routing depends on clients sending the expected hostname during the TLS handshake. Clients that connect by IP address or omit SNI will not match hostname-specific routers. -- Public port `80/tcp` is only intended for `/.well-known/acme-challenge/` traffic in this configuration. +- Public port `80/tcp` forwards all HTTP paths for configured hostnames to the matching backend. diff --git a/docs/runbook.md b/docs/runbook.md index e4c99e9..a2e3434 100644 --- a/docs/runbook.md +++ b/docs/runbook.md @@ -41,11 +41,11 @@ Confirm that `config/traefik.yml` is mounted and that the ping entrypoint is ena ### Clients cannot connect -Check that the host is listening on `80/tcp` and `443/tcp`, firewall rules allow inbound traffic, and backend IP addresses are reachable from the Traefik container. For services on the Docker host, configure backends as `host.docker.internal:PORT`. +Check that the host is listening on `80/tcp` and `443/tcp`, firewall rules allow inbound traffic, and backend IP addresses are reachable from Traefik. For services bound to host loopback, configure backends as `127.0.0.1:PORT`. ### Certificate HTTP-01 challenge fails -Check that port `80/tcp` is reachable from the internet and that the matching `*-acme` service points to the local challenge responder port. +Check that port `80/tcp` is reachable from the internet and that the matching HTTP service points to the local backend that handles ACME challenge paths. ### Wrong certificate is presented diff --git a/docs/security.md b/docs/security.md index c96164c..9bc8439 100644 --- a/docs/security.md +++ b/docs/security.md @@ -13,9 +13,9 @@ Backend services are responsible for: ## Network exposure -Compose publishes `80/tcp` for HTTP-01 certificate challenges and `443/tcp` for TLS passthrough traffic. The Traefik ping entrypoint listens inside the container for healthchecks and is not published on the host. +Traefik uses host networking and listens directly on host ports `80/tcp` for HTTP traffic and `443/tcp` for TLS passthrough traffic. The Traefik ping entrypoint listens on host port `8082/tcp` for healthchecks; restrict this port with host firewall rules if needed. -The HTTP routers only match `/.well-known/acme-challenge/` paths for configured hostnames. +The HTTP routers match configured hostnames and forward the full HTTP request to the selected backend. Backend services are responsible for redirects and ACME challenge handling. ## Container hardening @@ -23,7 +23,7 @@ The Traefik service: - does not mount the Docker socket; - is not privileged; -- drops Linux capabilities; +- drops Linux capabilities except `NET_BIND_SERVICE`, which is required to bind host ports `80` and `443`; - enables `no-new-privileges`; - mounts configuration read-only; - uses an explicit Traefik image tag instead of `latest`. diff --git a/docs/testing.md b/docs/testing.md index 3e1966f..e8ba5e9 100644 --- a/docs/testing.md +++ b/docs/testing.md @@ -12,12 +12,12 @@ docker compose config ## Smoke test -After replacing the example backend addresses and starting the stack, verify HTTP-01 routing: +After replacing the example backend addresses and starting the stack, verify HTTP routing: ```bash -curl -H 'Host: lab.younerd.org' http://LOAD_BALANCER_HOST/.well-known/acme-challenge/test-token -curl -H 'Host: azionelab.org' http://LOAD_BALANCER_HOST/.well-known/acme-challenge/test-token -curl -H 'Host: www.azionelab.org' http://LOAD_BALANCER_HOST/.well-known/acme-challenge/test-token +curl -H 'Host: lab.younerd.org' http://LOAD_BALANCER_HOST/ +curl -H 'Host: azionelab.org' http://LOAD_BALANCER_HOST/ +curl -H 'Host: www.azionelab.org' http://LOAD_BALANCER_HOST/ ``` Then verify TCP TLS connectivity: @@ -40,5 +40,5 @@ openssl s_client -connect LOAD_BALANCER_HOST:443 -servername www.azionelab.org - Docker/Compose validation: `docker compose config`. - Runtime health: `docker compose ps`. -- HTTP-01 route smoke test: `curl`. +- HTTP route smoke test: `curl`. - TLS passthrough smoke test: `openssl s_client`.