diff --git a/.github/workflows/cd.yaml b/.github/workflows/cd.yaml index 0f0608c..d9949c3 100644 --- a/.github/workflows/cd.yaml +++ b/.github/workflows/cd.yaml @@ -120,3 +120,27 @@ jobs: secrets: REGISTRY_USERNAME: ${{ github.actor }} REGISTRY_PASSWORD: ${{ secrets.GITHUB_TOKEN }} + + main4: + name: CD + needs: + - meta + - main1 + uses: + ./.github/workflows/docker_release.yml + with: + registry: ghcr.io + image: flobernd/haproxy-acme-tlsalpn01 + context: ./haproxy-acme-tlsalpn01/data + build-args: | + BASE_IMAGE=ghcr.io/flobernd/haproxy-acme:${{ inputs.version-major }}.${{ inputs.version-minor }} + platforms: '["linux/386", "linux/amd64", "linux/arm/v5", "linux/arm/v7", "linux/arm64", "linux/mips64le", "linux/ppc64le", "linux/s390x"]' + tags: | + ghcr.io/flobernd/haproxy-acme-tlsalpn01:${{ inputs.version-major }}.${{ inputs.version-minor }} + ghcr.io/flobernd/haproxy-acme-tlsalpn01:${{ inputs.version-major }} + ${{ inputs.tag-latest && format('{0}:latest', 'ghcr.io/flobernd/haproxy-acme-tlsalpn01') || '' }} + labels: ${{ needs.meta.outputs.labels }} + annotations: ${{ needs.meta.outputs.annotations }} + secrets: + REGISTRY_USERNAME: ${{ github.actor }} + REGISTRY_PASSWORD: ${{ secrets.GITHUB_TOKEN }} diff --git a/.github/workflows/ci.yaml b/.github/workflows/ci.yaml index 4410085..2e1798c 100644 --- a/.github/workflows/ci.yaml +++ b/.github/workflows/ci.yaml @@ -16,6 +16,7 @@ env: REGISTRY_IMAGE_HAPROXY_ACME: localhost:5000/flobernd/haproxy-acme REGISTRY_IMAGE_HAPROXY_ACME_HTTP01: localhost:5000/flobernd/haproxy-acme-http01 REGISTRY_IMAGE_HAPROXY_ACME_DNS01: localhost:5000/flobernd/haproxy-acme-dns01 + REGISTRY_IMAGE_HAPROXY_ACME_TLSALPN01: localhost:5000/flobernd/haproxy-acme-tlsalpn01 jobs: build: @@ -120,3 +121,31 @@ jobs: tags: ${{ steps.meta-haproxy-acme-dns01.outputs.tags }} labels: ${{ steps.meta-haproxy-acme-dns01.outputs.labels }} annotations: ${{ steps.meta-haproxy-acme-dns01.outputs.annotations }} + + - name: Extract metadata for 'haproxy-acme-tlsalpn01' + id: meta-haproxy-acme-tlsalpn01 + uses: docker/metadata-action@v5 + with: + images: ${{ env.REGISTRY_IMAGE_HAPROXY_ACME_TLSALPN01 }} + # flavor: | + # latest=false + # tags: | + # type=ref,event=branch + # type=ref,event=pr + # type=semver,pattern={{version}} + # type=semver,pattern={{major}}.{{minor}} + + - name: Build 'haproxy-acme-tlsalpn01' + id: build-haproxy-acme-tlsalpn01 + uses: docker/build-push-action@v5 + with: + context: ./haproxy-acme-tlsalpn01/data + cache-from: type=gha + cache-to: type=gha,mode=max + push: true + # platforms: linux/386,linux/amd64,linux/arm/v5,linux/arm/v7,linux/arm64,linux/mips64le,linux/ppc64le,linux/s390x + build-args: | + BASE_IMAGE=${{ fromJSON(steps.build-haproxy-acme.outputs.metadata)['image.name'] }} + tags: ${{ steps.meta-haproxy-acme-tlsalpn01.outputs.tags }} + labels: ${{ steps.meta-haproxy-acme-tlsalpn01.outputs.labels }} + annotations: ${{ steps.meta-haproxy-acme-tlsalpn01.outputs.annotations }} diff --git a/README.md b/README.md index 94564b3..ceaaad0 100644 --- a/README.md +++ b/README.md @@ -301,6 +301,146 @@ Optional directives for communicating with the internal service. For example `ss --- +## `haproxy-acme-tlsalpn01` + +The `haproxy-acme-tlsalpn01` image is a ready-to-run image for local SSL termination and has the following core features: + +- Issues a SSL certificate on startup + - Configurable ACME provider (`Let's Encrypt`, `ZeroSSL`, ...) + - Configurable key length (`2048`, `4096`, `ec-256`, ...) + - Supports SAN certificates +- Automatic certificate renewal + - HAProxy [Hitless Reload](https://www.haproxy.com/blog/hitless-reloads-with-haproxy-howto) (zero downtime) + - Configurable certificate renewal notifications (WiP) +- Support for [zero downtime TLS-ALPN authentication](https://github.com/acmesh-official/acme.sh/wiki/TLS-ALPN-without-downtime) + +### Example + +```bash +docker run -d --name haproxy-acme-tlsalpn01 \ + -e "HAPROXY_HTTP_PORT=80" \ + -e "HAPROXY_HTTPS_PORT=443" \ + -e "HAPROXY_HTTPS_REASSIGN_PORT=8443" \ + -e "ACME_MAIL=mail@domain.com" \ + -e "ACME_DOMAIN=domain.com" \ + -e "ACME_TLSALPN_PORT=10443" \ + -v /docker_data/acme:/var/lib/acme:rw \ + -p 80:80 \ + -p 443:443 \ + ghcr.io/flobernd/haproxy-acme-tlsalpn01 +``` + +### Docker Compose Example + +```yaml +services: + haproxy-acme: + image: ghcr.io/flobernd/haproxy-acme-tlsalpn01:latest + container_name: haproxy-acme-tlsalpn01 + restart: unless-stopped + environment: + - "HAPROXY_HTTP_PORT=80" + - "HAPROXY_HTTPS_PORT=443" + - "HAPROXY_HTTPS_REASSIGN_PORT=8443" + - "ACME_MAIL=mail@domain.com" + - "ACME_DOMAIN=domain.com" + - "ACME_TLSALPN_PORT=10443" + volumes: + - /docker_data/acme:/var/lib/acme:rw + ports: + - 80:80 + - 443:443 +``` + +### Persistence + +It is strongly recommended to specify an external volume for the `/var/lib/acme` directory. Most ACME servers enforce a rate limit for issuing and renewing certificates. If you recreate the container without preserving the internal state of `acme.sh`, a new certificate will also be created each time. + +### HAProxy Configuration + +The container creates a default configuration file `haproxy.cfg` in the `/usr/local/etc/haproxy` directory. + +By mapping the aforementioned path, the primary `haproxy.cfg` can be freely customized. Alternatively, additional configurations can be placed in the `include` directory, which are then loaded after the primary configuration in alphabetical order. + +As long as the default config has not been modified or overwritten, the `SERVER_ADDRESS` (*required*), `SERVER_PORT` (default `80`), `HAPROXY_HTTP_PORT` (default `80`), `HAPROXY_HTTPS_PORT` (default `443`), `HAPROXY_HTTPS_REASSIGN_PORT` (default `8443`) environment variables must be set. Otherwise the container will fail to start. + +> [!IMPORTANT] +> When overwriting the default configuration, make sure that the `stats socket` directive is retained. Otherwise, the deployment of certificates will fail. + +``` +global + # Allow 'acme.sh' to deploy new certificates without reloading + stats socket /var/lib/haproxy/admin.sock level admin mode 660 +``` + +### Environment + +#### `ACME_DEBUG` + +Set to `1` in order to enable verbose `acme.sh` debug output. + +#### `ACME_UPGRADE` + +Set to `1` in order to automatically update `acme.sh` to the latest version on container startup. This requires an active internet connection (default: `0`). + +#### `ACME_CRON` + +Set to `1` in order to enable the certificate renewal cronjob (default: `1`). + +#### `ACME_SERVER` + +The ACME server to use (default: `letsencrypt`). + +Supported values: `letsencrypt`, `letsencrypt_test`, `buypass`, `buypass_test`, `zerossl`, `sslcom`, `google`, `googletest` or an explicit ACME server directory URL like e.g. `https://acme-v02.api.letsencrypt.org/directory` + +See also: https://github.com/acmesh-official/acme.sh/wiki/Server + +#### `ACME_MAIL` + +The mail address for the ACME account registration (*required*). + +#### `ACME_DOMAIN` + +The domain to issue the certificate for (*required*). To issue a multi-domain certificate (SAN), enter additional domains separated by a space character after the primary domain. + +For example: `sub.domain.com` (single), `domain1.com domain2.com` (SAN) + +#### `ACME_KEYLENGTH` + +The desired domain key length (default: `ec-256`). + +Supported values (depending on the ACME server capabilities): `2048`, `3072`, `4096`, `8192`, `ec-256`, `ec-384`, `ec-521`. + +#### `ACME_TLSALPN_PORT` + +The port on which the internal stateful `acme.sh` server should run to handle the `acme-tls/1` request (default: `10443`). + +#### `HAPROXY_HTTP_PORT` + +The internal `haproxy` HTTP listening port. Allows changing the internal port to a non-privileged one (default: `80`). Do not forget to adjust the Docker port mapping accordingly (e.g. `-p 80:8080`). + +#### `HAPROXY_HTTPS_PORT` + +The internal `haproxy` HTTPS listening port. Allows changing the internal port to a non-privileged one (default: `443`). Do not forget to adjust the Docker port mapping accordingly (e.g. `-p 443:8443`). + +#### `HAPROXY_HTTPS_REASSIGN_PORT` + +The internal `haproxy` port to which the other frontends listening on `443` are reassigned (default: `8443`). The frontend listening on `443` instead proxies requests to this frontend or the `acme.sh` server. + +#### `SERVER_ADDRESS` + +The hostname or IP-address of the internal service for which SSL terminiation should be provided (*required*). + +#### `SERVER_PORT` + +The port of the internal service for which SSL terminiation should be provided (default: `80`). + +#### `SERVER_DIRECTIVES` + +Optional directives for communicating with the internal service. For example `ssl` must be specified here, if the internal service uses HTTPS. Check the `haproxy` documentation for more directives. + +--- + ## `haproxy-acme` The base image `haproxy-acme` is based on the [Docker "Official Image"](https://github.com/docker-library/haproxy) for [haproxy](https://www.haproxy.org/) and the [acme.sh](https://github.com/acmesh-official/acme.sh) Bash script. It serves as a generic template, providing some hook points for customization: diff --git a/haproxy-acme-tlsalpn01/attach.sh b/haproxy-acme-tlsalpn01/attach.sh new file mode 100755 index 0000000..7e6e748 --- /dev/null +++ b/haproxy-acme-tlsalpn01/attach.sh @@ -0,0 +1,6 @@ +#!/bin/bash + +docker exec \ + -it \ + haproxy-acme-tlsalpn01 \ + /bin/bash diff --git a/haproxy-acme-tlsalpn01/build.sh b/haproxy-acme-tlsalpn01/build.sh new file mode 100755 index 0000000..62ef339 --- /dev/null +++ b/haproxy-acme-tlsalpn01/build.sh @@ -0,0 +1,7 @@ +#!/bin/bash + +SCRIPT_DIR=$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" &> /dev/null && pwd) + +docker build \ + -t flobernd/haproxy-acme-tlsalpn01:latest \ + "$SCRIPT_DIR/data" diff --git a/haproxy-acme-tlsalpn01/build_and_run.sh b/haproxy-acme-tlsalpn01/build_and_run.sh new file mode 100755 index 0000000..e6cfb3c --- /dev/null +++ b/haproxy-acme-tlsalpn01/build_and_run.sh @@ -0,0 +1,5 @@ +#!/bin/bash + +set -e + +./build.sh && ./run.sh diff --git a/haproxy-acme-tlsalpn01/build_and_run_compose.sh b/haproxy-acme-tlsalpn01/build_and_run_compose.sh new file mode 100755 index 0000000..e744ff5 --- /dev/null +++ b/haproxy-acme-tlsalpn01/build_and_run_compose.sh @@ -0,0 +1,5 @@ +#!/bin/bash + +set -e + +./build.sh && docker compose up && docker compose down diff --git a/haproxy-acme-tlsalpn01/data/Dockerfile b/haproxy-acme-tlsalpn01/data/Dockerfile new file mode 100644 index 0000000..770183c --- /dev/null +++ b/haproxy-acme-tlsalpn01/data/Dockerfile @@ -0,0 +1,33 @@ +ARG BASE_IMAGE=flobernd/haproxy-acme:latest + +FROM $BASE_IMAGE + +# Environment + +ENV HAPROXY_HTTP_PORT=80 +ENV HAPROXY_HTTPS_PORT=443 +ENV HAPROXY_HTTPS_REASSIGN_PORT=8443 + +ENV ACME_SERVER=letsencrypt +ENV ACME_MAIL= +ENV ACME_DOMAIN= +ENV ACME_KEYLENGTH=ec-256 +ENV ACME_TLSALPN_PORT=10433 + +ENV SERVER_ADDRESS= +ENV SERVER_PORT=80 +ENV SERVER_DIRECTIVES= + +# Set up 'haproxy' certificate directory + +RUN mkdir -p /etc/haproxy/certs && chown haproxy:haproxy /etc/haproxy/certs && chmod 0700 /etc/haproxy/certs + +# Copy 'haproxy' configuration template + +COPY --chown=haproxy:haproxy ./haproxy.cfg /etc/haproxy/haproxy.cfg.template + +# Copy scripts + +COPY acmeinit.early.sh /usr/local/bin/ +COPY acmeinit.late.sh /usr/local/bin/ +COPY initialize.sh /usr/local/bin/ diff --git a/haproxy-acme-tlsalpn01/data/acmeinit.early.sh b/haproxy-acme-tlsalpn01/data/acmeinit.early.sh new file mode 100755 index 0000000..027a225 --- /dev/null +++ b/haproxy-acme-tlsalpn01/data/acmeinit.early.sh @@ -0,0 +1,35 @@ +#!/bin/bash +set -e + +echo "Registering ACME account for '$ACME_MAIL' on '$ACME_SERVER' ..." + +args=( + "--server" "$ACME_SERVER" + "--register-account" + "-m" "$ACME_MAIL" +) + +if [ $ACME_DEBUG -eq 1 ]; then + args+=("--debug") +fi + +input=$(acme.sh "${args[@]}") || (echo "$input" && exit 1) + +pattern="^.*ACCOUNT_THUMBPRINT='([a-zA-Z0-9_-]+)'$" + +if [[ $input =~ $pattern ]]; then + export ACME_ACCOUNT_THUMBPRINT="${BASH_REMATCH[1]}" + + if [[ $input == *"Already registered"* ]]; then + echo "Already registered" + else + echo "Account created" + fi + + echo "ACME account thumbprint:" + echo "$ACME_ACCOUNT_THUMBPRINT" +else + echo "Failed to extract ACME account thumbprint:" + echo "$input" + exit 1 +fi diff --git a/haproxy-acme-tlsalpn01/data/acmeinit.late.sh b/haproxy-acme-tlsalpn01/data/acmeinit.late.sh new file mode 100755 index 0000000..c356bb4 --- /dev/null +++ b/haproxy-acme-tlsalpn01/data/acmeinit.late.sh @@ -0,0 +1,52 @@ +#!/bin/bash +set -e +set -f + +echo "Issuing certificate for '$ACME_DOMAIN' ..." + +domains=() +for d in $ACME_DOMAIN +do + domains+=("$d") +done + +args=( + "--issue" + "--server" "$ACME_SERVER" + "--keylength" "$ACME_KEYLENGTH" + "--alpn" + "--tlsport" "$ACME_TLSALPN_PORT" + "-d" "${domains[0]}" +) + +for d in "${domains[@]:1}" +do + args+=("-d" "$d") +done + +if [ $ACME_DEBUG -eq 1 ]; then + args+=("--debug") +fi + +result=0 +acme.sh "${args[@]}" || result=$? + +if [ $result -ne 0 ] && [ $result -ne 2 ]; then + # 0 = Certificate issued + # 2 = Certificate is still valid and does not require renewal + exit $result +fi + +echo "Deploying certificate ..." + +export DEPLOY_HAPROXY_HOT_UPDATE=yes +export DEPLOY_HAPROXY_STATS_SOCKET=/var/lib/haproxy/admin.sock +export DEPLOY_HAPROXY_PEM_PATH=/etc/haproxy/certs + +args=( + "--deploy" + "--deploy-hook" "haproxy" + "-d" "${domains[0]}" +) + +acme.sh "${args[@]}" diff --git a/haproxy-acme-tlsalpn01/data/haproxy.cfg b/haproxy-acme-tlsalpn01/data/haproxy.cfg new file mode 100644 index 0000000..d7ab91a --- /dev/null +++ b/haproxy-acme-tlsalpn01/data/haproxy.cfg @@ -0,0 +1,64 @@ +global + log stdout format raw local0 + # Allow 'acme.sh' to deploy new certificates without reloading + stats socket /var/lib/haproxy/admin.sock level admin mode 660 + maxconn 100000 + nbthread 4 + ssl-default-bind-ciphersuites TLS_AES_128_GCM_SHA256:TLS_AES_256_GCM_SHA384:TLS_CHACHA20_POLY1305_SHA256 + ssl-default-bind-options prefer-client-ciphers no-sslv3 no-tlsv10 no-tlsv11 no-tlsv12 no-tls-tickets + ssl-default-server-ciphersuites TLS_AES_128_GCM_SHA256:TLS_AES_256_GCM_SHA384:TLS_CHACHA20_POLY1305_SHA256 + ssl-default-server-options no-sslv3 no-tlsv10 no-tlsv11 no-tlsv12 no-tls-tickets + ssl-server-verify none + tune.ssl.default-dh-param 4096 + tune.bufsize 65536 + +defaults + maxconn 10000 + retries 3 + timeout http-request 10s + timeout queue 1m + timeout connect 10s + timeout client 1m + timeout server 1m + timeout http-keep-alive 10s + timeout check 10s + log global + option httplog + option dontlognull + +frontend http + mode http + bind ":${HAPROXY_HTTP_PORT}" name http + # Redirect to HTTPS + http-request redirect scheme https + +# TCP frontend on 443 to load balance ALPN +frontend fe_alpn + mode tcp + option tcplog + bind ":${HAPROXY_HTTPS_PORT}" name bind_fe_alpn + tcp-request inspect-delay 5s + tcp-request content accept if { req_ssl_hello_type 1 } + use_backend bk_acmesh if { req.ssl_alpn acme-tls/1 } + default_backend bk_https + +# A backend to send requests to acme.sh +backend bk_acmesh + server acmesh "127.0.0.1:${ACME_TLSALPN_PORT}" + +# A backend to send requests to the regular HTTPS frontend. Use PROXY protocol to retain the original clients source IP +backend bk_https + server https "127.0.0.1:${HAPROXY_HTTPS_REASSIGN_PORT}" send-proxy-v2 + +frontend https + mode http + bind ":${HAPROXY_HTTPS_REASSIGN_PORT}" name bind_fe_https ssl crt /etc/haproxy/certs/ strict-sni accept-proxy alpn h2,http/1.1 + option forwardfor if-none + default_backend main + +resolvers default + parse-resolv-conf + +backend main + mode http + server main "${SERVER_ADDRESS}:${SERVER_PORT}" "$SERVER_DIRECTIVES" resolvers default diff --git a/haproxy-acme-tlsalpn01/data/initialize.sh b/haproxy-acme-tlsalpn01/data/initialize.sh new file mode 100755 index 0000000..5696c37 --- /dev/null +++ b/haproxy-acme-tlsalpn01/data/initialize.sh @@ -0,0 +1,52 @@ +#!/bin/bash +set -e + +# Adjust permissions for 'haproxy' certificate directory + +mkdir -p /etc/haproxy/certs +chown -R haproxy:haproxy /etc/haproxy/certs +chmod 0700 /etc/haproxy/certs +chmod 0600 /etc/haproxy/certs/* 2> /dev/null || true + +# Copy 'haproxy' configuration template + +haproxy_cfg_template=/etc/haproxy/haproxy.cfg.template +haproxy_cfg=/usr/local/etc/haproxy/haproxy.cfg +if [ ! -f "$haproxy_cfg" ]; then + cp "$haproxy_cfg_template" "$haproxy_cfg" + chown haproxy:haproxy "$haproxy_cfg" + chmod 0600 "$haproxy_cfg" +fi + +# Check mandatory environment variables + +mandatory=( + "ACME_SERVER" + "ACME_MAIL" + "ACME_DOMAIN" + "ACME_KEYLENGTH" + "ACME_TLSALPN_PORT" +) + +if cmp -s "$haproxy_cfg" "$haproxy_cfg_template"; then + mandatory+=( + "HAPROXY_HTTP_PORT" + "HAPROXY_HTTPS_PORT" + "HAPROXY_HTTPS_REASSIGN_PORT" + "SERVER_ADDRESS" + "SERVER_PORT" + ) +fi + +missing=false +for value in "${mandatory[@]}" +do + if [ -z "${!value}" ]; then + missing=true + echo "Missing mandatory environment variable: '$value'" + fi +done + +if [ "$missing" = true ]; then + exit 1 +fi diff --git a/haproxy-acme-tlsalpn01/docker-compose.yml b/haproxy-acme-tlsalpn01/docker-compose.yml new file mode 100644 index 0000000..39188af --- /dev/null +++ b/haproxy-acme-tlsalpn01/docker-compose.yml @@ -0,0 +1,30 @@ +services: + haproxy-acme: + image: flobernd/haproxy-acme-tlsalpn01:latest + container_name: haproxy-acme-tlsalpn01 + restart: unless-stopped + environment: + - "HAPROXY_HTTP_PORT=80" + - "HAPROXY_HTTPS_PORT=443" + - "HAPROXY_HTTPS_REASSIGN_PORT=8443" + - "ACME_DEBUG=0" + - "ACME_UPGRADE=1" + - "ACME_CRON=1" + - "ACME_MAIL=mail@domain.com" + - "ACME_SERVER=letsencrypt_test" + - "ACME_DOMAIN=domain.com" + - "ACME_KEYLENGTH=ec-256" + - "ACME_TLSALPN_PORT=10433" + - "SERVER_ADDRESS=whoami" + - "SERVER_PORT=80" + - "SERVER_DIRECTIVES=" + volumes: + - ./volume/acme:/var/lib/acme:rw + ports: + - 80:80 + - 443:443 + + whoami: + image: traefik/whoami + container_name: whoami + restart: unless-stopped diff --git a/haproxy-acme-tlsalpn01/run.sh b/haproxy-acme-tlsalpn01/run.sh new file mode 100755 index 0000000..0dbf2b9 --- /dev/null +++ b/haproxy-acme-tlsalpn01/run.sh @@ -0,0 +1,24 @@ +#!/bin/bash + +SCRIPT_DIR=$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" &> /dev/null && pwd) + +docker run -it --rm --name haproxy-acme-tlsalpn01 \ + -v "$SCRIPT_DIR/volume/acme:/var/lib/acme:rw" \ + -e "HAPROXY_HTTP_PORT=80" \ + -e "HAPROXY_HTTPS_PORT=443" \ + -e "HAPROXY_HTTPS_REASSIGN_PORT=8443" \ + -e "ACME_DEBUG=0" \ + -e "ACME_UPGRADE=1" \ + -e "ACME_CROM=1" \ + -e "ACME_MAIL=mail@domain.com" \ + -e "ACME_SERVER=letsencrypt_test" \ + -e "ACME_DOMAIN=domain.com" \ + -e "ACME_KEYLENGTH=ec-256" \ + -e "ACME_TLSALPN_PORT=10443" \ + -e "SERVER_ADDRESS=whoami" \ + -e "SERVER_PORT=80" \ + -e "SERVER_DIRECTIVES=" \ + -p 80:80 \ + -p 443:443 \ + --sysctl net.ipv4.ip_unprivileged_port_start=0 \ + flobernd/haproxy-acme-tlsalpn01 diff --git a/haproxy-acme-tlsalpn01/run_interactive.sh b/haproxy-acme-tlsalpn01/run_interactive.sh new file mode 100755 index 0000000..5c93e9d --- /dev/null +++ b/haproxy-acme-tlsalpn01/run_interactive.sh @@ -0,0 +1,8 @@ +#!/bin/bash + +SCRIPT_DIR=$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" &> /dev/null && pwd) + +docker run -it --rm --name haproxy-acme-tlsalpn01 \ + -v "$SCRIPT_DIR/volume/acme:/var/lib/acme:rw" \ + flobernd/haproxy-acme-tlsalpn01 \ + /bin/bash