From 365062f925e9393a8649222178c424ad54307a57 Mon Sep 17 00:00:00 2001 From: tahodev <85001874+tahodev@users.noreply.github.com> Date: Fri, 23 Oct 2026 01:07:49 +0900 Subject: [PATCH 1/4] feat: support custom CA certificates from the data volume Any *.crt files placed in /app/data/ssl/ca-certificates are combined with the system CA bundle into a runtime-writable bundle at container start, and Python (requests), OpenSSL, libcurl and git are pointed at it via REQUESTS_CA_BUNDLE, SSL_CERT_FILE, CURL_CA_BUNDLE and GIT_SSL_CAINFO. Generating a fresh bundle (instead of installing into the system trust store) works with the image's unprivileged runtime user, where /usr/local/share/ca-certificates and /etc/ssl/certs are not writable, and it is rebuilt from scratch on every start so removed certificates never linger. No-op when the directory is absent or empty. Fixes #1525 --- start | 29 +++++++++++++ tests/run | 3 ++ tests/test-custom-ca-certificates | 68 +++++++++++++++++++++++++++++++ 3 files changed, 100 insertions(+) create mode 100755 tests/test-custom-ca-certificates diff --git a/start b/start index 5f9b5ca9d..75174b281 100755 --- a/start +++ b/start @@ -58,6 +58,9 @@ service_runs_migrations() { } NGINX_PID_FILE=/run/nginx.pid +CUSTOM_CA_CERTS_DIR=/app/data/ssl/ca-certificates +SYSTEM_CA_BUNDLE=/etc/ssl/certs/ca-certificates.crt +CUSTOM_CA_BUNDLE=/tmp/custom-ca-certificates.crt fail_dep() { >&2 echo "$1 not running!" @@ -274,6 +277,31 @@ ensure_saml_certificate() { fi } +install_custom_ca_certificates() { + # Build a combined CA bundle (system certificates plus user-provided + # ones from the data volume) in a runtime-writable location, then point + # Python (requests), OpenSSL, libcurl and git at it. The system trust + # store is not writable for the container's unprivileged user, and the + # bundle is rebuilt from scratch on every start, so stale certificates + # never linger. + local certs=() + if [[ -d $CUSTOM_CA_CERTS_DIR ]]; then + shopt -s nullglob + certs=("$CUSTOM_CA_CERTS_DIR"/*.crt) + shopt -u nullglob + fi + if ((${#certs[@]} == 0)); then + return 0 + fi + echo "Adding ${#certs[@]} custom CA certificate(s) from $CUSTOM_CA_CERTS_DIR to the runtime CA bundle..." + cat "$SYSTEM_CA_BUNDLE" "${certs[@]}" > "$CUSTOM_CA_BUNDLE" + chmod 0644 "$CUSTOM_CA_BUNDLE" + export REQUESTS_CA_BUNDLE="$CUSTOM_CA_BUNDLE" + export SSL_CERT_FILE="$CUSTOM_CA_BUNDLE" + export CURL_CA_BUNDLE="$CUSTOM_CA_BUNDLE" + export GIT_SSL_CAINFO="$CUSTOM_CA_BUNDLE" +} + prepare_runtime_files() { prepare_ssh_keys ensure_data_volume_writable @@ -283,6 +311,7 @@ prepare_runtime_files() { wait_for_django_secret fi ensure_saml_certificate + install_custom_ca_certificates } ensure_runtime_user() { diff --git a/tests/run b/tests/run index a1fced04a..a51a07a0b 100755 --- a/tests/run +++ b/tests/run @@ -14,3 +14,6 @@ echo 'Running Docker build arguments tests' echo 'Running image signing tests' ./tests/test-sign-image-tags + +echo 'Running custom CA certificate tests' +./tests/test-custom-ca-certificates diff --git a/tests/test-custom-ca-certificates b/tests/test-custom-ca-certificates new file mode 100755 index 000000000..d3bd5b985 --- /dev/null +++ b/tests/test-custom-ca-certificates @@ -0,0 +1,68 @@ +#!/bin/bash +# Tests for install_custom_ca_certificates in the start script: the function +# is extracted verbatim and exercised against stub directories. + +set -eu + +test_dir=$(mktemp -d) +trap 'rm -rf "$test_dir"' EXIT HUP INT TERM + +# Extract the path variables and the function itself from start. +awk '/^CUSTOM_CA_CERTS_DIR=|^SYSTEM_CA_BUNDLE=|^CUSTOM_CA_BUNDLE=/' start > "$test_dir/vars.sh" +awk '/^install_custom_ca_certificates\(\) \{/,/^\}/' start > "$test_dir/function.sh" +grep -q 'GIT_SSL_CAINFO' "$test_dir/function.sh" + +# Stub "system" bundle used as the base of the combined bundle. +printf 'system-ca\n' > "$test_dir/system.crt" + +# Evaluate vars + function in a subshell per case. env -u strips inherited +# CA variables so a proxy or custom CA configured on the host cannot leak +# into the assertions. +run_case() { + env -u REQUESTS_CA_BUNDLE -u SSL_CERT_FILE -u CURL_CA_BUNDLE -u GIT_SSL_CAINFO bash -c " + set -eu + . '$test_dir/vars.sh' + . '$test_dir/function.sh' + CUSTOM_CA_CERTS_DIR='$1' + SYSTEM_CA_BUNDLE='$test_dir/system.crt' + CUSTOM_CA_BUNDLE='$test_dir/bundle.crt' + install_custom_ca_certificates + echo \"exports: \${REQUESTS_CA_BUNDLE:-unset} \${SSL_CERT_FILE:-unset} \${CURL_CA_BUNDLE:-unset} \${GIT_SSL_CAINFO:-unset}\" + " | tail -n 1 +} + +fail=0 +check() { # $1 = label, $2 = expected, $3 = actual + if [ "$2" = "$3" ]; then + echo "ok: $1" + else + echo "FAIL: $1 (expected [$2], got [$3])" + fail=1 + fi +} + +# Case 1: no source directory -> no-op, no exports, no bundle. +rm -f "$test_dir/bundle.crt" +out=$(run_case "$test_dir/no-such-dir") +check 'no dir: no exports' 'exports: unset unset unset unset' "$out" +check 'no dir: no bundle written' 'missing' "$(cat "$test_dir/bundle.crt" 2> /dev/null || echo missing)" + +# Case 2: two certificates -> combined bundle written, exports set. +mkdir -p "$test_dir/src2" +printf 'cert-a\n' > "$test_dir/src2/internal-a.crt" +printf 'cert-b\n' > "$test_dir/src2/internal-b.crt" +touch "$test_dir/src2/ignored.pem" +rm -f "$test_dir/bundle.crt" +out=$(run_case "$test_dir/src2") +check 'two certs: exports point at bundle' "exports: $test_dir/bundle.crt $test_dir/bundle.crt $test_dir/bundle.crt $test_dir/bundle.crt" "$out" +check 'two certs: bundle = system + custom' 'system-ca cert-a cert-b' "$(tr '\n' ' ' < "$test_dir/bundle.crt" | sed 's/ $//')" +check 'two certs: bundle is world-readable' '644' "$(stat -c '%a' "$test_dir/bundle.crt")" + +# Case 3: empty source directory -> no-op. +mkdir -p "$test_dir/src3" +rm -f "$test_dir/bundle.crt" +out=$(run_case "$test_dir/src3") +check 'empty dir: no exports' 'exports: unset unset unset unset' "$out" +check 'empty dir: no bundle written' 'missing' "$(cat "$test_dir/bundle.crt" 2> /dev/null || echo missing)" + +exit "$fail" From 3f000800b363d4a63c4e661b8f017a6996fbd620 Mon Sep 17 00:00:00 2001 From: tahodev <85001874+tahodev@users.noreply.github.com> Date: Fri, 23 Oct 2026 16:05:19 +0900 Subject: [PATCH 2/4] fix: separate PEM blocks when building the custom CA bundle cat-ing the system bundle and user .crt files together glued PEM blocks whenever an input lacked a trailing newline (END CERTIFICATE---BEGIN CERTIFICATE---), producing a bundle that OpenSSL/Python reject. Write each input followed by an explicit newline instead. Extend the test with a no-trailing-newline case and verify the old and new behavior against openssl with real self-signed certificates (old: "error reading the file"; new: bundle parses). --- start | 13 ++++++++++++- tests/test-custom-ca-certificates | 12 +++++++++++- 2 files changed, 23 insertions(+), 2 deletions(-) diff --git a/start b/start index 75174b281..2d4588022 100755 --- a/start +++ b/start @@ -285,6 +285,7 @@ install_custom_ca_certificates() { # bundle is rebuilt from scratch on every start, so stale certificates # never linger. local certs=() + local cert if [[ -d $CUSTOM_CA_CERTS_DIR ]]; then shopt -s nullglob certs=("$CUSTOM_CA_CERTS_DIR"/*.crt) @@ -294,7 +295,17 @@ install_custom_ca_certificates() { return 0 fi echo "Adding ${#certs[@]} custom CA certificate(s) from $CUSTOM_CA_CERTS_DIR to the runtime CA bundle..." - cat "$SYSTEM_CA_BUNDLE" "${certs[@]}" > "$CUSTOM_CA_BUNDLE" + # Separate inputs with newlines: a source file without a trailing newline + # would otherwise glue PEM blocks together (e.g. "-----END CERTIFICATE----------BEGIN CERTIFICATE-----"), + # producing a bundle that OpenSSL/Python reject. + { + cat "$SYSTEM_CA_BUNDLE" + printf '\n' + for cert in "${certs[@]}"; do + cat "$cert" + printf '\n' + done + } > "$CUSTOM_CA_BUNDLE" chmod 0644 "$CUSTOM_CA_BUNDLE" export REQUESTS_CA_BUNDLE="$CUSTOM_CA_BUNDLE" export SSL_CERT_FILE="$CUSTOM_CA_BUNDLE" diff --git a/tests/test-custom-ca-certificates b/tests/test-custom-ca-certificates index d3bd5b985..17b975c17 100755 --- a/tests/test-custom-ca-certificates +++ b/tests/test-custom-ca-certificates @@ -55,9 +55,19 @@ touch "$test_dir/src2/ignored.pem" rm -f "$test_dir/bundle.crt" out=$(run_case "$test_dir/src2") check 'two certs: exports point at bundle' "exports: $test_dir/bundle.crt $test_dir/bundle.crt $test_dir/bundle.crt $test_dir/bundle.crt" "$out" -check 'two certs: bundle = system + custom' 'system-ca cert-a cert-b' "$(tr '\n' ' ' < "$test_dir/bundle.crt" | sed 's/ $//')" +check 'two certs: bundle = system + custom' 'system-ca cert-a cert-b' "$(tr '\n' ' ' < "$test_dir/bundle.crt" | tr -s ' ' | sed 's/ $//')" check 'two certs: bundle is world-readable' '644' "$(stat -c '%a' "$test_dir/bundle.crt")" +# Case 4: a certificate without a trailing newline must not glue PEM blocks. +mkdir -p "$test_dir/src4" +printf 'cert-no-newline' > "$test_dir/src4/internal-a.crt" +printf 'cert-after\n' > "$test_dir/src4/internal-b.crt" +rm -f "$test_dir/bundle.crt" +run_case "$test_dir/src4" > /dev/null +check 'no trailing newline: first cert on its own line' '1' "$(grep -xc 'cert-no-newline' "$test_dir/bundle.crt"; true)" +check 'no trailing newline: second cert on its own line' '1' "$(grep -xc 'cert-after' "$test_dir/bundle.crt"; true)" +check 'no trailing newline: no glued content' '0' "$(grep -c 'cert-no-newlinecert-after' "$test_dir/bundle.crt" || true)" + # Case 3: empty source directory -> no-op. mkdir -p "$test_dir/src3" rm -f "$test_dir/bundle.crt" From a0e038a3f20797d2cf187ff73c8df24ec434cb3a Mon Sep 17 00:00:00 2001 From: "pre-commit-ci[bot]" <66853113+pre-commit-ci[bot]@users.noreply.github.com> Date: Fri, 23 Oct 2026 07:05:33 +0000 Subject: [PATCH 3/4] [pre-commit.ci] auto fixes from pre-commit.com hooks for more information, see https://pre-commit.ci --- tests/test-custom-ca-certificates | 10 ++++++++-- 1 file changed, 8 insertions(+), 2 deletions(-) diff --git a/tests/test-custom-ca-certificates b/tests/test-custom-ca-certificates index 17b975c17..787b7686c 100755 --- a/tests/test-custom-ca-certificates +++ b/tests/test-custom-ca-certificates @@ -64,8 +64,14 @@ printf 'cert-no-newline' > "$test_dir/src4/internal-a.crt" printf 'cert-after\n' > "$test_dir/src4/internal-b.crt" rm -f "$test_dir/bundle.crt" run_case "$test_dir/src4" > /dev/null -check 'no trailing newline: first cert on its own line' '1' "$(grep -xc 'cert-no-newline' "$test_dir/bundle.crt"; true)" -check 'no trailing newline: second cert on its own line' '1' "$(grep -xc 'cert-after' "$test_dir/bundle.crt"; true)" +check 'no trailing newline: first cert on its own line' '1' "$( + grep -xc 'cert-no-newline' "$test_dir/bundle.crt" + true +)" +check 'no trailing newline: second cert on its own line' '1' "$( + grep -xc 'cert-after' "$test_dir/bundle.crt" + true +)" check 'no trailing newline: no glued content' '0' "$(grep -c 'cert-no-newlinecert-after' "$test_dir/bundle.crt" || true)" # Case 3: empty source directory -> no-op. From 10518dcc39f172371109cb9e53c6218796cdfd1e Mon Sep 17 00:00:00 2001 From: tahodev <85001874+tahodev@users.noreply.github.com> Date: Fri, 23 Oct 2026 23:06:15 +0900 Subject: [PATCH 4/4] fix: export the custom CA bundle as PGSSLROOTCERT for libpq libpq does not honor SSL_CERT_FILE; psql looks up the CA through sslrootcert (default ~/.postgresql/root.crt). With POSTGRES_SSL_MODE set to verify-ca or verify-full and PostgreSQL signed only by a mounted custom CA, check_postgres_version would reject the server before startup. Point PGSSLROOTCERT at the combined bundle too. --- start | 4 ++++ tests/test-custom-ca-certificates | 11 ++++++----- 2 files changed, 10 insertions(+), 5 deletions(-) diff --git a/start b/start index 2d4588022..3441f1f00 100755 --- a/start +++ b/start @@ -311,6 +311,10 @@ install_custom_ca_certificates() { export SSL_CERT_FILE="$CUSTOM_CA_BUNDLE" export CURL_CA_BUNDLE="$CUSTOM_CA_BUNDLE" export GIT_SSL_CAINFO="$CUSTOM_CA_BUNDLE" + # libpq (psql) ignores SSL_CERT_FILE; without PGSSLROOTCERT the + # check_postgres_version probe rejects servers signed by a custom CA + # when POSTGRES_SSL_MODE=verify-ca/verify-full. + export PGSSLROOTCERT="$CUSTOM_CA_BUNDLE" } prepare_runtime_files() { diff --git a/tests/test-custom-ca-certificates b/tests/test-custom-ca-certificates index 787b7686c..f70b3cd54 100755 --- a/tests/test-custom-ca-certificates +++ b/tests/test-custom-ca-certificates @@ -11,6 +11,7 @@ trap 'rm -rf "$test_dir"' EXIT HUP INT TERM awk '/^CUSTOM_CA_CERTS_DIR=|^SYSTEM_CA_BUNDLE=|^CUSTOM_CA_BUNDLE=/' start > "$test_dir/vars.sh" awk '/^install_custom_ca_certificates\(\) \{/,/^\}/' start > "$test_dir/function.sh" grep -q 'GIT_SSL_CAINFO' "$test_dir/function.sh" +grep -q 'PGSSLROOTCERT' "$test_dir/function.sh" # Stub "system" bundle used as the base of the combined bundle. printf 'system-ca\n' > "$test_dir/system.crt" @@ -19,7 +20,7 @@ printf 'system-ca\n' > "$test_dir/system.crt" # CA variables so a proxy or custom CA configured on the host cannot leak # into the assertions. run_case() { - env -u REQUESTS_CA_BUNDLE -u SSL_CERT_FILE -u CURL_CA_BUNDLE -u GIT_SSL_CAINFO bash -c " + env -u REQUESTS_CA_BUNDLE -u SSL_CERT_FILE -u CURL_CA_BUNDLE -u GIT_SSL_CAINFO -u PGSSLROOTCERT bash -c " set -eu . '$test_dir/vars.sh' . '$test_dir/function.sh' @@ -27,7 +28,7 @@ run_case() { SYSTEM_CA_BUNDLE='$test_dir/system.crt' CUSTOM_CA_BUNDLE='$test_dir/bundle.crt' install_custom_ca_certificates - echo \"exports: \${REQUESTS_CA_BUNDLE:-unset} \${SSL_CERT_FILE:-unset} \${CURL_CA_BUNDLE:-unset} \${GIT_SSL_CAINFO:-unset}\" + echo \"exports: \${REQUESTS_CA_BUNDLE:-unset} \${SSL_CERT_FILE:-unset} \${CURL_CA_BUNDLE:-unset} \${GIT_SSL_CAINFO:-unset} \${PGSSLROOTCERT:-unset}\" " | tail -n 1 } @@ -44,7 +45,7 @@ check() { # $1 = label, $2 = expected, $3 = actual # Case 1: no source directory -> no-op, no exports, no bundle. rm -f "$test_dir/bundle.crt" out=$(run_case "$test_dir/no-such-dir") -check 'no dir: no exports' 'exports: unset unset unset unset' "$out" +check 'no dir: no exports' 'exports: unset unset unset unset unset' "$out" check 'no dir: no bundle written' 'missing' "$(cat "$test_dir/bundle.crt" 2> /dev/null || echo missing)" # Case 2: two certificates -> combined bundle written, exports set. @@ -54,7 +55,7 @@ printf 'cert-b\n' > "$test_dir/src2/internal-b.crt" touch "$test_dir/src2/ignored.pem" rm -f "$test_dir/bundle.crt" out=$(run_case "$test_dir/src2") -check 'two certs: exports point at bundle' "exports: $test_dir/bundle.crt $test_dir/bundle.crt $test_dir/bundle.crt $test_dir/bundle.crt" "$out" +check 'two certs: exports point at bundle' "exports: $test_dir/bundle.crt $test_dir/bundle.crt $test_dir/bundle.crt $test_dir/bundle.crt $test_dir/bundle.crt" "$out" check 'two certs: bundle = system + custom' 'system-ca cert-a cert-b' "$(tr '\n' ' ' < "$test_dir/bundle.crt" | tr -s ' ' | sed 's/ $//')" check 'two certs: bundle is world-readable' '644' "$(stat -c '%a' "$test_dir/bundle.crt")" @@ -78,7 +79,7 @@ check 'no trailing newline: no glued content' '0' "$(grep -c 'cert-no-newlinecer mkdir -p "$test_dir/src3" rm -f "$test_dir/bundle.crt" out=$(run_case "$test_dir/src3") -check 'empty dir: no exports' 'exports: unset unset unset unset' "$out" +check 'empty dir: no exports' 'exports: unset unset unset unset unset' "$out" check 'empty dir: no bundle written' 'missing' "$(cat "$test_dir/bundle.crt" 2> /dev/null || echo missing)" exit "$fail"