Ensure Docker images ship CA trust and add TLS smoke tests (#6918)

This commit is contained in:
Sipke Schoorstra 2025-09-19 11:57:30 +02:00 committed by GitHub
parent d045e495f8
commit 42ac08c575
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
10 changed files with 399 additions and 39 deletions

72
.github/workflows/docker-ca.yml vendored Normal file
View file

@ -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

View file

@ -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)

View file

@ -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"]
ENTRYPOINT ["/entrypoint.sh"]
CMD ["dotnet", "Elsa.Server.Web.dll"]

View file

@ -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"]

View file

@ -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"]

View file

@ -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"]

103
docker/entrypoint.sh Executable file
View file

@ -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 "$@"

95
scripts/test-ca-trust.sh Executable file
View file

@ -0,0 +1,95 @@
#!/usr/bin/env bash
set -euo pipefail
if [ $# -lt 2 ]; then
echo "Usage: $0 <image> <tls_app_dir> [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 <<CERTEXT > "$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

28
test/TlsSmoke/Program.cs Normal file
View file

@ -0,0 +1,28 @@
using System.Net.Http;
if (args.Length == 0)
{
Console.Error.WriteLine("Usage: TlsSmoke <url>");
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;
}

View file

@ -0,0 +1,8 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<OutputType>Exe</OutputType>
<TargetFramework>net9.0</TargetFramework>
<ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable>
</PropertyGroup>
</Project>