diff --git a/README.md b/README.md index 36acc42..08d44f4 100644 --- a/README.md +++ b/README.md @@ -5,6 +5,7 @@ Docker Compose deployment for Traefik used as a TCP load balancer with TLS passt ## What it does - publishes `443/tcp` on the Docker host; +- publishes `80/tcp` on the Docker host for HTTP-01 certificate challenges; - forwards TCP TLS traffic to backend IP addresses and ports; - leaves SSL certificates and private keys on the backends; - stores Traefik configuration in the host `./config` directory; @@ -32,6 +33,16 @@ tcp: `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`. +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 +``` + +Adjust `8080` and `9080` to the local ports where the Certbot challenge responder or webroot service is listening. + ## Run Validate the Compose file: diff --git a/compose.yaml b/compose.yaml index 36c3033..4657981 100644 --- a/compose.yaml +++ b/compose.yaml @@ -6,6 +6,7 @@ services: command: - --configFile=/etc/traefik/traefik.yml ports: + - "80:8080/tcp" - "443:8443/tcp" extra_hosts: - "host.docker.internal:host-gateway" diff --git a/config/dynamic/tcp-services.yml b/config/dynamic/tcp-services.yml index 56a9b82..f4cfd22 100644 --- a/config/dynamic/tcp-services.yml +++ b/config/dynamic/tcp-services.yml @@ -1,3 +1,28 @@ +http: + routers: + lab-younerd-acme: + entryPoints: + - web + rule: "Host(`lab.younerd.org`) && PathPrefix(`/.well-known/acme-challenge/`)" + service: lab-younerd-acme + + azionelab-acme: + entryPoints: + - web + rule: "(Host(`azionelab.org`) || Host(`www.azionelab.org`)) && PathPrefix(`/.well-known/acme-challenge/`)" + service: azionelab-acme + + services: + lab-younerd-acme: + loadBalancer: + servers: + - url: "http://host.docker.internal:8080" + + azionelab-acme: + loadBalancer: + servers: + - url: "http://host.docker.internal:9080" + tcp: routers: lab-younerd: diff --git a/config/traefik.yml b/config/traefik.yml index 9b0f08b..5ae64a7 100644 --- a/config/traefik.yml +++ b/config/traefik.yml @@ -3,6 +3,8 @@ global: sendAnonymousUsage: false entryPoints: + web: + address: ":8080" tls: address: ":8443" ping: diff --git a/docs/adr/0001-traefik-tcp-tls-passthrough.md b/docs/adr/0001-traefik-tcp-tls-passthrough.md index ecf9166..cce3d24 100644 --- a/docs/adr/0001-traefik-tcp-tls-passthrough.md +++ b/docs/adr/0001-traefik-tcp-tls-passthrough.md @@ -12,11 +12,13 @@ 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 service. +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. + +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. ## Consequences -TLS certificates, private keys, and certificate renewal remain the responsibility of each backend. Traefik can route based on the TLS SNI value but cannot inspect HTTP headers or paths because TLS is passed through unchanged. +TLS certificates, private keys, and certificate renewal remain the responsibility of each backend. Traefik can route TLS traffic based on the TLS SNI value but cannot inspect HTTP headers or paths because TLS is passed through unchanged. Backend health is limited to TCP reachability unless protocol-specific health checks are added later. Configuration changes in `config/dynamic/` are watched by Traefik and can be applied without recreating the container. @@ -28,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. +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. ## Operational impact -Operators update backend IP addresses and ports in `config/dynamic/tcp-services.yml`. The Compose deployment publishes only `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`. The Compose deployment publishes `80/tcp` and `443/tcp`; the ping entrypoint is internal to the container and used by the healthcheck. ## Rollback diff --git a/docs/architecture.md b/docs/architecture.md index b8d2d57..77f8c31 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -5,8 +5,8 @@ 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. -- `config/traefik.yml`: static Traefik configuration for entrypoints, file provider, ping, and logging. -- `config/dynamic/tcp-services.yml`: dynamic TCP router and backend load-balancer service. +- `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 @@ -16,6 +16,13 @@ This project runs Traefik Proxy as a Dockerized TCP load balancer. 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. + ## Persistence Traefik configuration is stored in the host `./config` directory and mounted read-only at `/etc/traefik`. diff --git a/docs/deployment.md b/docs/deployment.md index 7a1ab2f..08fb1cd 100644 --- a/docs/deployment.md +++ b/docs/deployment.md @@ -28,6 +28,8 @@ tcp: If a backend listens on the Docker host, use `host.docker.internal:PORT`. Compose maps that name through Docker's `host-gateway` feature. +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. + The `./config` directory is the host-based configuration volume. It is mounted read-only into the container: ```yaml @@ -37,6 +39,7 @@ 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. diff --git a/docs/operations.md b/docs/operations.md index 492dd66..76e8360 100644 --- a/docs/operations.md +++ b/docs/operations.md @@ -50,8 +50,11 @@ To remove a backend from rotation, edit `config/dynamic/tcp-services.yml` and re 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. +For HTTP-01 challenges, update the `*-acme` HTTP services in `config/dynamic/tcp-services.yml` to match the local challenge responder ports. + ## 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. diff --git a/docs/runbook.md b/docs/runbook.md index e1adf0a..e4c99e9 100644 --- a/docs/runbook.md +++ b/docs/runbook.md @@ -41,7 +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 `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 the Traefik container. For services on the Docker host, configure backends as `host.docker.internal: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. ### Wrong certificate is presented diff --git a/docs/security.md b/docs/security.md index d3050cb..c96164c 100644 --- a/docs/security.md +++ b/docs/security.md @@ -13,7 +13,9 @@ Backend services are responsible for: ## Network exposure -Only `443/tcp` is published by Compose. The Traefik ping entrypoint listens inside the container for healthchecks and is not published on the host. +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. + +The HTTP routers only match `/.well-known/acme-challenge/` paths for configured hostnames. ## Container hardening diff --git a/docs/testing.md b/docs/testing.md index 0be67b6..3e1966f 100644 --- a/docs/testing.md +++ b/docs/testing.md @@ -12,7 +12,15 @@ docker compose config ## Smoke test -After replacing the example backend addresses and starting the stack, verify TCP connectivity from a client: +After replacing the example backend addresses and starting the stack, verify HTTP-01 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 +``` + +Then verify TCP TLS connectivity: ```bash openssl s_client -connect LOAD_BALANCER_HOST:443 -servername EXPECTED_DNS_NAME @@ -32,4 +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`. - TLS passthrough smoke test: `openssl s_client`.