From 42ac08c5752df8a6f2bf470df69089657dcd90e5 Mon Sep 17 00:00:00 2001 From: Sipke Schoorstra Date: Fri, 19 Sep 2025 11:57:30 +0200 Subject: [PATCH] Ensure Docker images ship CA trust and add TLS smoke tests (#6918) --- .github/workflows/docker-ca.yml | 72 ++++++++++++++++++ README.md | 26 +++++++ docker/ElsaServer-Datadog.Dockerfile | 48 ++++++------ docker/ElsaServer.Dockerfile | 23 ++++-- docker/ElsaServerAndStudio.Dockerfile | 23 ++++-- docker/ElsaStudio.Dockerfile | 12 ++- docker/entrypoint.sh | 103 ++++++++++++++++++++++++++ scripts/test-ca-trust.sh | 95 ++++++++++++++++++++++++ test/TlsSmoke/Program.cs | 28 +++++++ test/TlsSmoke/TlsSmoke.csproj | 8 ++ 10 files changed, 399 insertions(+), 39 deletions(-) create mode 100644 .github/workflows/docker-ca.yml create mode 100755 docker/entrypoint.sh create mode 100755 scripts/test-ca-trust.sh create mode 100644 test/TlsSmoke/Program.cs create mode 100644 test/TlsSmoke/TlsSmoke.csproj diff --git a/.github/workflows/docker-ca.yml b/.github/workflows/docker-ca.yml new file mode 100644 index 000000000..e8a737d48 --- /dev/null +++ b/.github/workflows/docker-ca.yml @@ -0,0 +1,72 @@ +name: Docker certificate smoke tests + +on: + pull_request: + branches: + - main + paths: + - 'docker/**' + - 'scripts/test-ca-trust.sh' + - 'test/TlsSmoke/**' + - '.github/workflows/docker-ca.yml' + push: + branches: + - main + paths: + - 'docker/**' + - 'scripts/test-ca-trust.sh' + - 'test/TlsSmoke/**' + - '.github/workflows/docker-ca.yml' + +jobs: + smoke: + runs-on: ubuntu-latest + strategy: + fail-fast: false + matrix: + include: + - name: elsa-server + dockerfile: docker/ElsaServer.Dockerfile + image: elsa-server-smoke + skip_otel: '0' + - name: elsa-server-and-studio + dockerfile: docker/ElsaServerAndStudio.Dockerfile + image: elsa-server-and-studio-smoke + skip_otel: '0' + - name: elsa-studio + dockerfile: docker/ElsaStudio.Dockerfile + image: elsa-studio-smoke + skip_otel: '0' + - name: elsa-server-datadog + dockerfile: docker/ElsaServer-Datadog.Dockerfile + image: elsa-server-datadog-smoke + skip_otel: '1' + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Set up .NET + uses: actions/setup-dotnet@v4 + with: + dotnet-version: 9.0.x + + - name: Publish TLS smoke test app + run: dotnet publish test/TlsSmoke/TlsSmoke.csproj -c Release -o artifacts/tls-smoke + + - name: Set up Docker Buildx + uses: docker/setup-buildx-action@v3 + + - name: Build ${{ matrix.name }} image + run: docker build -f ${{ matrix.dockerfile }} -t ${{ matrix.image }} . + + - name: Run TLS smoke tests + env: + SKIP_OTEL: ${{ matrix.skip_otel }} + run: | + set -euo pipefail + TLS_APP_DIR="${{ github.workspace }}/artifacts/tls-smoke" + if [ "$SKIP_OTEL" = "1" ]; then + scripts/test-ca-trust.sh "${{ matrix.image }}" "$TLS_APP_DIR" -e ELSA_SKIP_OTEL_AUTO=1 + else + scripts/test-ca-trust.sh "${{ matrix.image }}" "$TLS_APP_DIR" + fi diff --git a/README.md b/README.md index eefa67ecd..51b28db45 100644 --- a/README.md +++ b/README.md @@ -39,6 +39,32 @@ By default, you can access http://localhost:13000 and log in with: Password: password ``` +### TLS and custom certificate authorities + +All Elsa Docker images now ship with the operating system's certificate authority bundle baked in at build time. This means you can call public HTTPS endpoints such as `https://example.com` without any additional configuration. + +If you need to trust a private or corporate CA, mount the certificate bundle into the container and reference it via `EXTRA_CA_CERT`: + +```bash +docker run \ + -v /path/to/company-ca.crt:/certs/company-ca.crt:ro \ + -e EXTRA_CA_CERT=/certs/company-ca.crt \ + elsaworkflows/elsa-server-and-studio-v3:latest +``` + +On startup, the container copies the certificate into `/usr/local/share/ca-certificates` and runs `update-ca-certificates`, making the trust available to .NET, OpenSSL, curl, and other system components. Multiple certificates can be provided by pointing `EXTRA_CA_CERT` at a directory containing `.crt` or `.pem` files. + +In highly restricted environments where you cannot modify the system trust store, you can instead rely on the standard `SSL_CERT_FILE` or `SSL_CERT_DIR` environment variables: + +```bash +docker run \ + -v /path/to/company-ca-bundle.pem:/certs/custom.pem:ro \ + -e SSL_CERT_FILE=/certs/custom.pem \ + elsaworkflows/elsa-server-and-studio-v3:latest +``` + +> ℹ️ Installing the CA bundle adds roughly 300KB to the Debian-based images. No package managers run at container startup; all trust updates happen immutably at build time or via the mounted certificates shown above. + ## Table of Contents - [Documentation](#documentation) diff --git a/docker/ElsaServer-Datadog.Dockerfile b/docker/ElsaServer-Datadog.Dockerfile index 7d5022c63..b7f1a3f4b 100644 --- a/docker/ElsaServer-Datadog.Dockerfile +++ b/docker/ElsaServer-Datadog.Dockerfile @@ -1,5 +1,5 @@ # Version: 1 -# Description: Dockerfile for building and running Elsa Server +# Description: Dockerfile for building and running Elsa Server with Datadog and OpenTelemetry auto-instrumentation FROM --platform=$BUILDPLATFORM mcr.microsoft.com/dotnet/sdk:9.0-bookworm-slim AS build WORKDIR /source @@ -22,36 +22,40 @@ FROM mcr.microsoft.com/dotnet/aspnet:9.0-bookworm-slim AS base WORKDIR /app COPY --from=build /app/publish ./ -# Install Python 3.11 -RUN apt-get update && apt-get install -y --no-install-recommends \ - python3.11 \ - python3.11-dev \ - libpython3.11 \ - python3-pip && \ - rm -rf /var/lib/apt/lists/* +# Install runtime dependencies, including CA certificates. +RUN apt-get update \ + && apt-get install -y --no-install-recommends \ + ca-certificates \ + curl \ + libpython3.11 \ + python3.11 \ + python3.11-dev \ + python3-pip \ + unzip \ + wget \ + && update-ca-certificates \ + && rm -rf /var/lib/apt/lists/* + +COPY docker/entrypoint.sh /entrypoint.sh +RUN chmod +x /entrypoint.sh # Set PYTHONNET_PYDLL environment variable ENV PYTHONNET_PYDLL=/usr/lib/aarch64-linux-gnu/libpython3.11.so -# Install dependencies -RUN apt-get update && apt-get install -y wget unzip curl - # Set environment variables for OpenTelemetry Auto-Instrumentation -ENV OTEL_DOTNET_AUTO_HOME=/otel -ENV OTEL_LOG_LEVEL="debug" +ENV OTEL_DOTNET_AUTO_HOME=/otel \ + OTEL_LOG_LEVEL="debug" # Download and extract OpenTelemetry Auto-Instrumentation ARG OTEL_VERSION=1.7.0 -RUN mkdir /otel -RUN curl -L -o /otel/otel-dotnet-install.sh https://github.com/open-telemetry/opentelemetry-dotnet-instrumentation/releases/download/v${OTEL_VERSION}/otel-dotnet-auto-install.sh -RUN chmod +x /otel/otel-dotnet-install.sh -RUN /bin/bash /otel/otel-dotnet-install.sh - -# Provide necessary permissions for the script to execute -RUN chmod +x /otel/instrument.sh +RUN mkdir -p /otel \ + && curl -L -o /otel/otel-dotnet-install.sh "https://github.com/open-telemetry/opentelemetry-dotnet-instrumentation/releases/download/v${OTEL_VERSION}/otel-dotnet-auto-install.sh" \ + && chmod +x /otel/otel-dotnet-install.sh \ + && /bin/bash /otel/otel-dotnet-install.sh \ + && chmod +x /otel/instrument.sh EXPOSE 8080/tcp EXPOSE 443/tcp -# Instrument the application and start it -ENTRYPOINT ["/bin/bash", "-c", "source /otel/instrument.sh && dotnet Elsa.Server.Web.dll"] \ No newline at end of file +ENTRYPOINT ["/entrypoint.sh"] +CMD ["dotnet", "Elsa.Server.Web.dll"] diff --git a/docker/ElsaServer.Dockerfile b/docker/ElsaServer.Dockerfile index b31307eb9..2695ca4de 100644 --- a/docker/ElsaServer.Dockerfile +++ b/docker/ElsaServer.Dockerfile @@ -19,17 +19,24 @@ FROM mcr.microsoft.com/dotnet/aspnet:9.0-bookworm-slim AS base WORKDIR /app COPY --from=build /app/publish ./ -# Install Python 3.11 -RUN apt-get update && apt-get install -y --no-install-recommends \ - python3.11 \ - python3.11-dev \ - libpython3.11 \ - python3-pip && \ - rm -rf /var/lib/apt/lists/* +# Install runtime dependencies, including CA certificates. +RUN apt-get update \ + && apt-get install -y --no-install-recommends \ + ca-certificates \ + libpython3.11 \ + python3.11 \ + python3.11-dev \ + python3-pip \ + && update-ca-certificates \ + && rm -rf /var/lib/apt/lists/* + +COPY docker/entrypoint.sh /entrypoint.sh +RUN chmod +x /entrypoint.sh # Set PYTHONNET_PYDLL environment variable ENV PYTHONNET_PYDLL=/usr/lib/aarch64-linux-gnu/libpython3.11.so EXPOSE 8080/tcp EXPOSE 443/tcp -ENTRYPOINT ["dotnet", "Elsa.Server.Web.dll"] +ENTRYPOINT ["/entrypoint.sh"] +CMD ["dotnet", "Elsa.Server.Web.dll"] diff --git a/docker/ElsaServerAndStudio.Dockerfile b/docker/ElsaServerAndStudio.Dockerfile index 27b33e992..aaadba910 100644 --- a/docker/ElsaServerAndStudio.Dockerfile +++ b/docker/ElsaServerAndStudio.Dockerfile @@ -20,17 +20,24 @@ FROM mcr.microsoft.com/dotnet/aspnet:9.0-bookworm-slim AS base WORKDIR /app COPY --from=build /app/publish ./ -# Install Python 3.11 -RUN apt-get update && apt-get install -y --no-install-recommends \ - python3.11 \ - python3.11-dev \ - libpython3.11 \ - python3-pip && \ - rm -rf /var/lib/apt/lists/* +# Install runtime dependencies, including CA certificates. +RUN apt-get update \ + && apt-get install -y --no-install-recommends \ + ca-certificates \ + libpython3.11 \ + python3.11 \ + python3.11-dev \ + python3-pip \ + && update-ca-certificates \ + && rm -rf /var/lib/apt/lists/* + +COPY docker/entrypoint.sh /entrypoint.sh +RUN chmod +x /entrypoint.sh # Set PYTHONNET_PYDLL environment variable ENV PYTHONNET_PYDLL=/usr/lib/aarch64-linux-gnu/libpython3.11.so EXPOSE 8080/tcp EXPOSE 443/tcp -ENTRYPOINT ["dotnet", "Elsa.ServerAndStudio.Web.dll"] +ENTRYPOINT ["/entrypoint.sh"] +CMD ["dotnet", "Elsa.ServerAndStudio.Web.dll"] diff --git a/docker/ElsaStudio.Dockerfile b/docker/ElsaStudio.Dockerfile index 7f2eba83c..6e76acb3d 100644 --- a/docker/ElsaStudio.Dockerfile +++ b/docker/ElsaStudio.Dockerfile @@ -20,6 +20,16 @@ FROM mcr.microsoft.com/dotnet/aspnet:9.0-bookworm-slim AS base WORKDIR /app COPY --from=build /app/publish ./ +# Install CA certificates so HTTPS works out of the box. +RUN apt-get update \ + && apt-get install -y --no-install-recommends ca-certificates \ + && update-ca-certificates \ + && rm -rf /var/lib/apt/lists/* + +COPY docker/entrypoint.sh /entrypoint.sh +RUN chmod +x /entrypoint.sh + EXPOSE 8080/tcp EXPOSE 443/tcp -ENTRYPOINT ["dotnet", "Elsa.Studio.Web.dll"] +ENTRYPOINT ["/entrypoint.sh"] +CMD ["dotnet", "Elsa.Studio.Web.dll"] diff --git a/docker/entrypoint.sh b/docker/entrypoint.sh new file mode 100755 index 000000000..c3f492692 --- /dev/null +++ b/docker/entrypoint.sh @@ -0,0 +1,103 @@ +#!/bin/sh +set -e + +log() { + printf '%s\n' "$*" >&2 +} + +normalise_dest_name() { + name=$(basename "$1") + case "$name" in + *.crt|*.pem) printf '%s\n' "$name" ;; + *) printf '%s.crt\n' "$name" ;; + esac +} + +install_extra_certificates() { + cert_source="$1" + if [ ! -e "$cert_source" ]; then + log "EXTRA_CA_CERT path '$cert_source' does not exist; skipping installation." + return + fi + + target_root="/usr/local/share/ca-certificates/extra" + mkdir -p "$target_root" + rm -f "$target_root"/* 2>/dev/null || true + + copied=0 + if [ -f "$cert_source" ]; then + dest_name=$(normalise_dest_name "$cert_source") + cp "$cert_source" "$target_root/$dest_name" + copied=1 + elif [ -d "$cert_source" ]; then + for file in "$cert_source"/*.crt "$cert_source"/*.pem; do + [ -f "$file" ] || continue + dest_name=$(normalise_dest_name "$file") + cp "$file" "$target_root/$dest_name" + copied=1 + done + else + log "EXTRA_CA_CERT path '$cert_source' is neither a file nor a directory; skipping installation." + return + fi + + if [ "$copied" -eq 0 ]; then + log "No certificate files found at '$cert_source'; skipping installation." + return + fi + + if command -v update-ca-certificates >/dev/null 2>&1; then + if ! update-ca-certificates >/dev/null 2>&1; then + update-ca-certificates + fi + log "Installed custom certificate(s) from '$cert_source'." + elif command -v trust >/dev/null 2>&1; then + # Shellcheck disable because we intentionally glob. + # shellcheck disable=SC2086 + for cert in "$target_root"/*.crt; do + [ -f "$cert" ] || continue + trust anchor "$cert" + done + log "Installed custom certificate(s) using 'trust' utility." + else + log "No known certificate installation tool found; custom CA may not be applied." + fi +} + +maybe_instrument_with_otel() { + if [ "${ELSA_SKIP_OTEL_AUTO:-0}" = "1" ]; then + return + fi + + if [ -z "${OTEL_DOTNET_AUTO_HOME:-}" ]; then + return + fi + + instrument_script="${OTEL_DOTNET_AUTO_HOME%/}/instrument.sh" + if [ ! -f "$instrument_script" ]; then + return + fi + + if ! command -v bash >/dev/null 2>&1; then + log "OpenTelemetry auto-instrumentation requested but bash is unavailable; skipping." + return + fi + + tmp_wrapper="/tmp/elsa-otel-wrapper.sh" + cat <<'WRAPPER' > "$tmp_wrapper" +#!/usr/bin/env bash +set -e +. "${OTEL_DOTNET_AUTO_HOME%/}/instrument.sh" +exec "$@" +WRAPPER + chmod +x "$tmp_wrapper" + exec "$tmp_wrapper" "$@" +} + +if [ -n "${EXTRA_CA_CERT:-}" ]; then + install_extra_certificates "$EXTRA_CA_CERT" +fi + +maybe_instrument_with_otel "$@" + +exec "$@" diff --git a/scripts/test-ca-trust.sh b/scripts/test-ca-trust.sh new file mode 100755 index 000000000..7c078606b --- /dev/null +++ b/scripts/test-ca-trust.sh @@ -0,0 +1,95 @@ +#!/usr/bin/env bash +set -euo pipefail + +if [ $# -lt 2 ]; then + echo "Usage: $0 [docker-run-arg ...]" >&2 + exit 1 +fi + +IMAGE="$1" +TLS_APP_DIR="$2" +shift 2 || true +DOCKER_ARGS=("$@") + +SMOKE_DLL=/tls/TlsSmoke.dll +PUBLIC_URL="https://example.com" +LOCAL_PORT=9443 + +run_smoke() { + local url="$1" + shift + docker run --rm \ + -v "${TLS_APP_DIR}:/tls:ro" \ + "${DOCKER_ARGS[@]}" \ + "$@" \ + "$IMAGE" \ + dotnet "$SMOKE_DLL" "$url" +} + +run_public_test() { + echo "[CA] Validating public trust store against ${PUBLIC_URL}" >&2 + run_smoke "$PUBLIC_URL" +} + +start_local_ca_server() { + CERT_WORKDIR=$(mktemp -d) + + openssl req -x509 -newkey rsa:2048 -days 2 -nodes -keyout "$CERT_WORKDIR/ca.key" -out "$CERT_WORKDIR/ca.crt" -subj "/CN=ElsaTestCA" >/dev/null 2>&1 + openssl req -newkey rsa:2048 -nodes -keyout "$CERT_WORKDIR/server.key" -out "$CERT_WORKDIR/server.csr" -subj "/CN=host.docker.internal" >/dev/null 2>&1 + + cat < "$CERT_WORKDIR/server.ext" +subjectAltName = DNS:localhost,DNS:host.docker.internal,IP:127.0.0.1 +extendedKeyUsage = serverAuth +keyUsage = digitalSignature, keyEncipherment +CERTEXT + + openssl x509 -req -in "$CERT_WORKDIR/server.csr" -CA "$CERT_WORKDIR/ca.crt" -CAkey "$CERT_WORKDIR/ca.key" -CAcreateserial -out "$CERT_WORKDIR/server.crt" -days 2 -sha256 -extfile "$CERT_WORKDIR/server.ext" >/dev/null 2>&1 + + openssl s_server -quiet -accept "$LOCAL_PORT" -www -cert "$CERT_WORKDIR/server.crt" -key "$CERT_WORKDIR/server.key" >/dev/null 2>&1 & + SERVER_PID=$! + + for _ in {1..20}; do + if nc -z localhost "$LOCAL_PORT" >/dev/null 2>&1; then + break + fi + sleep 0.2 + done + + echo "$CERT_WORKDIR" +} + +run_extra_ca_test() { + local cert_dir + cert_dir=$(start_local_ca_server) + trap "kill ${SERVER_PID:-0} >/dev/null 2>&1 || true; rm -rf '$cert_dir'" EXIT + + echo "[CA] Validating EXTRA_CA_CERT flow against local CA" >&2 + run_smoke "https://host.docker.internal:${LOCAL_PORT}" \ + --add-host host.docker.internal:host-gateway \ + -v "${cert_dir}:/certs:ro" \ + -e EXTRA_CA_CERT=/certs/ca.crt + + echo "[CA] Validating SSL_CERT_FILE fallback" >&2 + run_smoke "https://host.docker.internal:${LOCAL_PORT}" \ + --add-host host.docker.internal:host-gateway \ + -v "${cert_dir}:/certs:ro" \ + -e SSL_CERT_FILE=/certs/ca.crt + + mkdir -p "$cert_dir/dir" + cp "$cert_dir/ca.crt" "$cert_dir/dir/custom-ca.crt" + openssl rehash "$cert_dir/dir" >/dev/null 2>&1 + + echo "[CA] Validating SSL_CERT_DIR fallback" >&2 + run_smoke "https://host.docker.internal:${LOCAL_PORT}" \ + --add-host host.docker.internal:host-gateway \ + -v "${cert_dir}:/certs:ro" \ + -e SSL_CERT_DIR=/certs/dir + + kill "${SERVER_PID:-0}" >/dev/null 2>&1 || true + wait "${SERVER_PID:-0}" 2>/dev/null || true + rm -rf "$cert_dir" + trap - EXIT +} + +run_public_test +run_extra_ca_test diff --git a/test/TlsSmoke/Program.cs b/test/TlsSmoke/Program.cs new file mode 100644 index 000000000..851bab845 --- /dev/null +++ b/test/TlsSmoke/Program.cs @@ -0,0 +1,28 @@ +using System.Net.Http; + +if (args.Length == 0) +{ + Console.Error.WriteLine("Usage: TlsSmoke "); + return 1; +} + +var url = args[0]; + +using var cts = new CancellationTokenSource(TimeSpan.FromSeconds(15)); +using var client = new HttpClient +{ + Timeout = TimeSpan.FromSeconds(10) +}; + +try +{ + using var response = await client.GetAsync(url, cts.Token); + response.EnsureSuccessStatusCode(); + Console.WriteLine($"Successfully fetched {url} with status {(int)response.StatusCode}."); + return 0; +} +catch (Exception ex) +{ + Console.Error.WriteLine($"Request to {url} failed: {ex.Message}"); + return 1; +} diff --git a/test/TlsSmoke/TlsSmoke.csproj b/test/TlsSmoke/TlsSmoke.csproj new file mode 100644 index 000000000..1cad418e1 --- /dev/null +++ b/test/TlsSmoke/TlsSmoke.csproj @@ -0,0 +1,8 @@ + + + Exe + net9.0 + enable + enable + +