diff --git a/Dockerfile b/Dockerfile index cc3149b..9a4ab01 100644 --- a/Dockerfile +++ b/Dockerfile @@ -57,13 +57,14 @@ RUN apk del tar && \ rm -f /var/cache/apk/* ADD haproxy.cfg /etc/haproxy/haproxy.cfg +ADD haproxy-edge-terminated-tls.cfg /etc/haproxy/haproxy-edge-terminated-tls.cfg ADD certs /etc/haproxy/certs ADD cli.ini /root/.config/letsencrypt/ ADD entrypoint.sh / RUN chmod +x /entrypoint.sh -HEALTHCHECK --interval=5s --timeout=3s --start-period=5s --retries=10 CMD curl --fail --silent http://127.0.0.1/docker-health || exit 1 +HEALTHCHECK --interval=5s --timeout=3s --start-period=5s --retries=10 CMD curl --fail --silent "http://127.0.0.1:${HTTP_PORT}/docker-health" || exit 1 RUN chown -R haproxy:haproxy /etc/haproxy diff --git a/README.md b/README.md index 5128123..320a34d 100644 --- a/README.md +++ b/README.md @@ -21,7 +21,7 @@ requested (this is a multi-value alternative to DOMAINNAME) * `PROXY_LOGLEVEL` - Log level for HAProxy (default: `notice`) * `HTTP_PORT` - The container binds to this port for handling HTTP requests (default: `80`) * `HTTPS_PORT` - The container binds to this port for handling HTTPS requests (default: `443`) -* `HTTPS_FORWARDED_PORT` - The port set in the `X-Forwarded-Port` header of requests send to the Manager/Keycloak (default: `%[dst_port]` this is the HAProxy port) +* `HTTPS_FORWARDED_PORT` - The port set in the `X-Forwarded-Port` header of requests sent to the Manager/Keycloak (default: `%[dst_port]` this is the HAProxy port) * `NAMESERVER` - The nameserver hostname and port used for resolving the Manager/Keycloak hosts (default: `127.0.0.11:53`) * `MANAGER_HOST` - Hostname of OpenRemote Manager (default: `manager`) * `MANAGER_WEB_PORT` - Web server port of OpenRemote Manager (default `8080`) @@ -33,6 +33,7 @@ requested (this is a multi-value alternative to DOMAINNAME) * `LOGFILE` - Location of log file for entrypoint script to write to in addition to stdout (default `none`) * `AWS_ROUTE53_ROLE` - AWS Route53 Role ARN to be assumed when trying to generate wildcard certificates using Route53 DNS zone, specifically for cross account updates (default `none`) * `LE_EXTRA_ARGS` - Can be used to add additional arguments to the certbot command (default `none`) +* `DISABLE_ACME` - Disable certbot/ACME initialization and renewal logic in the entrypoint; useful when TLS is terminated externally such as with ACM on an AWS load balancer (accepted true values: `1`, `true`, `yes`, `on`) * `SISH_HOST` - Defines the destination hostname for forwarding requests that begin with `gw-` used in combination with `SISH_PORT` * `SISH_PORT` - Defined the destination port for forwarding requests tha begin with `gw-` used in combination with `SISH_HOST` * `MQTT_RATE_LIMIT` - Enable rate limiting for MQTT connections (connections/s) @@ -57,3 +58,21 @@ If you use an Ingress, reconfigure the `HTTPS_FORWARDED_PORT` to the HTTPS port You will also need to set the `NAMESERVER` environment variable to the cluster DNS (usually 10.96.0.10:53). The cluster DNS typically only resolves fully qualified hostnames, so make sure to set these using the `MANAGER_HOST` and `KEYCLOAK_HOST` environment variables (e.g. `manager.default.svc.cluster.local`). + +## Edge-Terminated TLS + +If TLS is terminated upstream before traffic reaches this pod, for example by an AWS NLB with ACM, an ALB, an ingress controller, or another reverse proxy, then: + +* Set `DISABLE_ACME=true` to disable certbot initialization and renewal in the container +* Use `HAPROXY_CONFIG=/etc/haproxy/haproxy-edge-terminated-tls.cfg` +* Set `HTTP_PORT` to a non-privileged container port such as `8080` +* Set `HTTPS_FORWARDED_PORT=443` so upstream services see the original external HTTPS port +* Configure the upstream load balancer or proxy to forward decrypted HTTP traffic to the pod `HTTP_PORT` + +For MQTT in the same setup, if MQTT TLS is also terminated upstream: + +* Terminate TLS on the upstream listener (for example external port `8883`) +* Forward plaintext TCP traffic from that listener to the pod's MQTT port +* The provided `haproxy-edge-terminated-tls.cfg` listens for MQTT on `MANAGER_MQTT_PORT` and forwards it to the configured manager MQTT backend + +The `haproxy-edge-terminated-tls.cfg` file removes local TLS certificate usage from the pod and preserves the usual `X-Forwarded-*` HTTP headers for upstream applications. Do not use this config if HTTPS or MQTT TLS is still passed through to the pod. diff --git a/entrypoint.sh b/entrypoint.sh index d3279d9..43de05b 100644 --- a/entrypoint.sh +++ b/entrypoint.sh @@ -15,6 +15,17 @@ fi LE_CMD="certbot certonly -n --logs-dir - -w ${CHROOT_DIR} ${LE_EXTRA_ARGS}" +acme_enabled() { + case "${DISABLE_ACME}" in + 1|true|TRUE|yes|YES|on|ON) + return 1 + ;; + *) + return 0 + ;; + esac +} + # Configure haproxy HAPROXY_CMD="haproxy -W -db -f ${HAPROXY_CONFIG} ${HAPROXY_USER_PARAMS}" HAPROXY_RESTART_CMD="kill -s HUP 1" @@ -73,21 +84,26 @@ run_proxy() { log_info "AWS_ROUTE53_ROLE: ${AWS_ROUTE53_ROLE}" if check_proxy; then - - log_info "Starting crond" - crond - - if [ -n "${AWS_ROUTE53_ROLE}" ]; then - log_info "Creating AWS CLI config file" - mkdir ~/.aws - rm -f ~/.aws/config 2> /dev/null - echo "[default]" >> ~/.aws/config - echo "role_arn = ${AWS_ROUTE53_ROLE}" >> ~/.aws/config - echo "credential_source = Ec2InstanceMetadata" >> ~/.aws/config - echo "" >> ~/.aws/config + start_monitor + + if acme_enabled; then + log_info "Starting crond" + crond + + if [ -n "${AWS_ROUTE53_ROLE}" ]; then + log_info "Creating AWS CLI config file" + mkdir ~/.aws + rm -f ~/.aws/config 2> /dev/null + echo "[default]" >> ~/.aws/config + echo "role_arn = ${AWS_ROUTE53_ROLE}" >> ~/.aws/config + echo "credential_source = Ec2InstanceMetadata" >> ~/.aws/config + echo "" >> ~/.aws/config + fi + + cert_init& + else + log_info "ACME is disabled; skipping certbot initialization and renewal" fi - - cert_init& log_info "HAProxy starting" exec su haproxy -s /bin/sh -c "$HAPROXY_CMD $HAPROXY_START_OPTIONS" @@ -129,6 +145,14 @@ restart() { fi } +start_monitor() { + log_info "Starting monitoring process" + ( + sleep 5 + monitor + )& +} + add() { if [ $# -lt 1 ] then @@ -234,6 +258,10 @@ renew() { } auto_renew() { + if ! acme_enabled; then + log_info "ACME is disabled; skipping auto renew" + return 0 + fi log_info "Executing auto renew at $(date -R)" certbot renew --deploy-hook "/entrypoint.sh sync-haproxy" } @@ -390,9 +418,6 @@ cert_init() { log_info "HAProxy certs have been modified so restarting" restart fi - - log_info "Starting monitoring process" - monitor } sync_haproxy() { diff --git a/haproxy-edge-terminated-tls.cfg b/haproxy-edge-terminated-tls.cfg new file mode 100644 index 0000000..4dd9e35 --- /dev/null +++ b/haproxy-edge-terminated-tls.cfg @@ -0,0 +1,95 @@ +# +# Use this config when TLS is terminated externally before traffic reaches +# this pod. HAProxy receives plain HTTP and plain TCP MQTT. +# +global + log stdout format raw local0 "${PROXY_LOGLEVEL}" + + tune.ssl.default-dh-param 4096 + # Works around breaking change in docker 23+ - just uses the old docker default value + fd-hard-limit 1048576 + +defaults + log global + mode http + log-format "%T %ft %ci:%cp %s %TR/%Tw/%Tc/%Tr/%Ta %{+Q}r %ST %ac/%fc/%bc/%sc/%rc %sq/%bq" + timeout connect 30s + timeout client 60s + timeout server 60s + timeout tunnel 720m + # never fail on address resolution + default-server init-addr none + +resolvers docker_resolver + nameserver dns "${NAMESERVER}" + +frontend stats + bind *:8404 + http-request use-service prometheus-exporter if { path /metrics } + stats enable + stats uri /stats + stats refresh 10s + +frontend http + bind *:"${HTTP_PORT}" + + # Static health endpoint for docker healthcheck (don't log it) + acl url_docker_health path /docker-health + http-request set-log-level silent if url_docker_health + http-request return status 200 if url_docker_health + + # TLS was already terminated upstream, so propagate the original scheme. + option forwardfor + http-request add-header X-Forwarded-Proto https + http-request set-header X-Forwarded-Host %[req.hdr(Host)] + http-request add-header X-Forwarded-Port "${HTTPS_FORWARDED_PORT}" + http-response add-header Strict-Transport-Security max-age=15768000 + http-response add-header X-Robots-Tag noindex + + # Gateway tunnelling config + .if defined(SISH_HOST) && defined(SISH_PORT) + acl gateway_sub_domain hdr_beg(host) gw- + use_backend sish if gateway_sub_domain + .endif + + acl auth path_beg "${KEYCLOAK_PATH_PREFIX}/auth" + use_backend keycloak_backend if auth + + use_backend manager_backend + +listen mqtt + bind *:"${MANAGER_MQTT_PORT}" + mode tcp + + .if defined(MQTT_RATE_LIMIT) + # Rate limiting + acl too_fast fe_sess_rate ge "${MQTT_RATE_LIMIT}" + tcp-request connection reject if too_fast + .endif + + option clitcpka + timeout client 3h + timeout server 3h + option logasap + log-format "%T %ft CLIENT=%ci:%cp BACKEND=%bi:%bp %ts %ac/%fc/%bc/%sc/%rc %sq/%bq" + balance leastconn + + server manager "${MANAGER_HOST}":"${MANAGER_MQTT_PORT}" resolvers docker_resolver + +backend manager_backend + compression algo gzip deflate + compression type text/html text/css application/javascript application/json image/svg+xml + compression offload + server manager "${MANAGER_HOST}":"${MANAGER_WEB_PORT}" resolvers docker_resolver + .if defined(MANAGER_PATH_PREFIX) + http-request replace-path ^"${MANAGER_PATH_PREFIX}"(/.*)?$ \1 + .endif + +backend keycloak_backend + server keycloak "${KEYCLOAK_HOST}":"${KEYCLOAK_PORT}" resolvers docker_resolver + +# Gateway tunnelling config +.if defined(SISH_HOST) && defined(SISH_PORT) + backend sish + server sish "${SISH_HOST}":"${SISH_PORT}" resolvers docker_resolver +.endif