Inject the corporate CA certificate into containers
Build App / compute-version (pull_request) Successful in 6s
Build App / build-macos (pull_request) Successful in 2m30s
Build App / build-windows (pull_request) Successful in 5m16s
Build Container / build-container (pull_request) Successful in 10m15s
Build App / build-linux (pull_request) Successful in 6m35s
Build App / create-tag (pull_request) Skipped
Build App / sync-to-github (pull_request) Skipped

Behind a TLS-terminating corporate proxy every HTTPS call inside a container
fails — npm, pip, git, curl, the browser-view pane, and Claude Code's own API
requests. There was no mechanism at all: installing the certificate by hand
inside a container is lost on Reset and had to be repeated per project.

A global CA path in AppSettings with a per-project override on Project, taking
either a single certificate file or a directory. It is bind-mounted read-only
at /tmp/.host-ca (mirroring /tmp/.host-ssh and /tmp/.host-aws) and applied by
entrypoint.sh on every start, so it survives recreation, migration and Reset.

Four things this gets right that are easy to get wrong:

* update-ca-certificates globs *.crt case-sensitively, so a .pem that is merely
  copied in is ignored in silence. Certificates are renamed, by
  container_cert_name() in Rust and a mirrored few lines of shell.
* The system store only serves curl/git/apt. Node — and so Claude Code itself —
  needs NODE_EXTRA_CA_CERTS, Python needs REQUESTS_CA_BUNDLE/SSL_CERT_FILE, and
  Chromium reads neither: it wants ~/.pki/nssdb, seeded with certutil
  (libnss3-tools, added to the image).
* Those vars are set from Rust at creation, never exported by the entrypoint —
  a terminal is a docker exec and sees nothing the entrypoint exported. They are
  emitted empty when no CA is configured, since docker commit bakes env into the
  snapshot image.
* triple-c.ca-fingerprint hashes the certificate bytes as well as the path, so
  a CA rotated in at the same location still forces a recreation.

Verified end to end against a real container and a self-signed CA: curl, node,
python and git all complete a TLS handshake against a server signed by it and
all three fail in the same container without it; the env vars are visible from
a docker exec session; the store is cleaned when the setting is cleared.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01KSP2KNPhuWKQ4DL5TZEn3k
This commit is contained in:
Claude
2026-08-10 10:40:21 -07:00
parent 584fcdd837
commit 7a5823cb2b
19 changed files with 1367 additions and 5 deletions
+7
View File
@@ -25,6 +25,7 @@ RUN for i in 1 2 3 4 5; do \
jq \
sudo \
ca-certificates \
libnss3-tools \
gnupg \
locales \
unzip \
@@ -35,6 +36,12 @@ RUN for i in 1 2 3 4 5; do \
socat \
&& rm -rf /var/lib/apt/lists/*
# `libnss3-tools` above provides `certutil`. Chrome/Chromium read neither
# /etc/ssl/certs nor $SSL_CERT_FILE — they have their own NSS database at
# ~/.pki/nssdb — so without it the browser-view pane cannot be made to trust a
# corporate CA, no matter what the system trust store says. entrypoint.sh
# degrades to a warning if it is ever missing.
# Remove default ubuntu user to free UID 1000 for host-user remapping
RUN if id ubuntu >/dev/null 2>&1; then userdel -r ubuntu 2>/dev/null || userdel ubuntu; fi \
&& if getent group ubuntu >/dev/null 2>&1; then groupdel ubuntu 2>/dev/null || true; fi
+162 -1
View File
@@ -58,6 +58,167 @@ remap_uid_gid
# Fix ownership of home directory after UID/GID change
chown -R claude:claude /home/claude
# ── Corporate CA certificates ───────────────────────────────────────────────
# The host's CA material is bind-mounted read-only at /tmp/.host-ca. Triple-C
# mounts a *directory* as-is and a *single file* as /tmp/.host-ca/<name>.crt,
# so this only ever has to deal with a directory (the file branch below is
# defensive).
#
# Runs before everything that touches the network — the git credential helper,
# ssh-keyscan, and especially the `claude update` at the bottom of this file,
# which is itself an HTTPS call that fails behind a TLS-terminating proxy
# without this.
#
# Two things are easy to get wrong here:
# 1. `update-ca-certificates` globs /usr/local/share/ca-certificates/*.crt
# case-sensitively. A `.pem` that is merely copied in is ignored in total
# silence, so certificates are *renamed*, not copied.
# 2. Chrome/Chromium read neither /etc/ssl nor $SSL_CERT_FILE; they have
# their own NSS database at ~/.pki/nssdb, seeded below with certutil.
#
# NODE_EXTRA_CA_CERTS / REQUESTS_CA_BUNDLE / SSL_CERT_FILE are deliberately NOT
# exported here. Every terminal is a separate `docker exec`, which inherits the
# container's configured env and sees nothing this script exported — the same
# reason $BROWSER had to become an image-level ENV. Triple-C sets them on the
# container at creation time instead. (They are forwarded into the cron
# environment file further down, because cron jobs start from a bare env.)
CA_SRC="/tmp/.host-ca"
CA_STORE="/usr/local/share/ca-certificates"
CA_PREFIX="triple-c-"
CA_BUNDLE="/etc/ssl/certs/ca-certificates.crt"
CA_STAMP="/var/lib/triple-c/ca.stamp"
CA_NSSDB="/home/claude/.pki/nssdb"
# Mirror of `container_cert_name()` in app/src-tauri/src/docker/ca_certs.rs.
# The two must agree; the Rust side has the unit tests.
ca_normalise_name() {
local name stem
name=$(printf '%s' "$1" | tr -c 'A-Za-z0-9._-' '_')
while [ "${name#.}" != "$name" ]; do name="${name#.}"; done
stem="${name%.*}"
[ -z "$stem" ] && stem="corporate-ca"
printf '%s.crt' "$stem"
}
ca_source_files() {
if [ -d "$CA_SRC" ]; then
find "$CA_SRC" -maxdepth 1 -type f \
\( -iname '*.crt' -o -iname '*.pem' -o -iname '*.cer' \
-o -iname '*.cert' -o -iname '*.ca-bundle' \) 2>/dev/null | sort
elif [ -f "$CA_SRC" ]; then
printf '%s\n' "$CA_SRC"
fi
}
# Seed Chrome/Chromium's NSS database. Tolerant by design: a missing certutil
# or a broken profile must warn, never fail the container start.
# ~/.pki lives in the home volume, so this persists once done; the system store
# lives in the writable layer and is re-applied on every start.
ca_seed_nssdb() {
if ! command -v certutil >/dev/null 2>&1; then
echo "entrypoint: warning — certutil not found (install libnss3-tools); Chrome/Chromium in this container will not trust the corporate CA"
return 0
fi
su -s /bin/bash claude -c '
db="$HOME/.pki/nssdb"
mkdir -p "$db" || exit 1
if [ ! -f "$db/cert9.db" ]; then
certutil -d "sql:$db" -N --empty-password >/dev/null 2>&1 || exit 1
fi
for f in /usr/local/share/ca-certificates/triple-c-*.crt; do
[ -f "$f" ] || continue
nick="triple-c:$(basename "$f" .crt)"
# Delete first so re-running replaces rather than duplicates.
certutil -d "sql:$db" -D -n "$nick" >/dev/null 2>&1
certutil -d "sql:$db" -A -t "C,," -n "$nick" -i "$f" >/dev/null 2>&1 \
|| echo "entrypoint: warning — certutil could not add $nick"
done
' && echo "entrypoint: seeded Chrome/Chromium NSS database with the corporate CA" \
|| echo "entrypoint: warning — NSS database seeding failed (continuing)"
}
install_corporate_ca() {
local files fp stamp f base name count installed
files=$(ca_source_files)
if [ -z "$files" ]; then
# Nothing configured — but /usr/local/share is in the writable layer and
# `docker commit` bakes it into the project's snapshot image, so a cert
# installed by a previous configuration would ride that snapshot into
# every future container. Turning the setting off has to actively undo.
if ls "$CA_STORE/$CA_PREFIX"*.crt >/dev/null 2>&1; then
echo "entrypoint: removing previously installed corporate CA certificates"
rm -f "$CA_STORE/$CA_PREFIX"*.crt
update-ca-certificates --fresh >/dev/null 2>&1 \
|| echo "entrypoint: warning — update-ca-certificates failed while removing certificates"
rm -f "$CA_STAMP"
fi
if [ -e "$CA_SRC" ]; then
echo "entrypoint: warning — $CA_SRC holds no certificate files"
fi
return 0
fi
# Idempotent and cheap: the certs are already installed on a plain restart
# (the writable layer survives stop/start), so hash the sources and skip the
# work when nothing has moved. The NSS database is checked separately
# because it lives in the home volume and can be wiped independently.
fp=$(printf '%s\n' "$files" | xargs -d '\n' -r sha256sum 2>/dev/null | sha256sum | cut -d' ' -f1)
stamp=$(cat "$CA_STAMP" 2>/dev/null)
if [ -n "$fp" ] && [ "$fp" = "$stamp" ] && [ -s "$CA_BUNDLE" ]; then
if [ -f "$CA_NSSDB/cert9.db" ]; then
echo "entrypoint: corporate CA certificates already installed"
return 0
fi
ca_seed_nssdb
return 0
fi
mkdir -p "$CA_STORE" "$(dirname "$CA_STAMP")"
rm -f "$CA_STORE/$CA_PREFIX"*.crt
installed=0
while IFS= read -r f; do
[ -n "$f" ] || continue
base=$(basename "$f")
name="$CA_PREFIX$(ca_normalise_name "$base")"
count=$(grep -c -- '-----BEGIN CERTIFICATE-----' "$f" 2>/dev/null || true)
[ -n "$count" ] || count=0
if [ "$count" -gt 1 ]; then
# A corporate trust chain is usually delivered as one PEM holding
# root + intermediates. update-ca-certificates handles exactly one
# certificate per file, so split it.
awk -v out="$CA_STORE/${name%.crt}" '
/-----BEGIN CERTIFICATE-----/ { n++; f = out "-" n ".crt" }
n > 0 { print > f }
' "$f" && installed=$((installed + count))
elif [ "$count" -eq 1 ]; then
cp -f "$f" "$CA_STORE/$name" && installed=$((installed + 1))
else
echo "entrypoint: warning — $f holds no PEM certificate (DER is not supported), skipping"
fi
done <<< "$files"
chmod 644 "$CA_STORE/$CA_PREFIX"*.crt 2>/dev/null
if [ "$installed" -eq 0 ]; then
echo "entrypoint: warning — no usable certificates found under $CA_SRC"
return 0
fi
if update-ca-certificates >/dev/null 2>&1; then
echo "entrypoint: installed $installed corporate CA certificate(s) into the system trust store"
printf '%s' "$fp" > "$CA_STAMP"
else
echo "entrypoint: warning — update-ca-certificates failed; corporate certificates may not be trusted"
fi
ca_seed_nssdb
}
install_corporate_ca
# ── SSH key setup ──────────────────────────────────────────────────────────
# Host SSH dir is mounted read-only at /tmp/.host-ssh.
# Copy to /home/claude/.ssh so we can fix permissions.
@@ -277,7 +438,7 @@ ENV_FILE="$SCHEDULER_DIR/.env"
: > "$ENV_FILE"
env | while IFS='=' read -r key value; do
case "$key" in
ANTHROPIC_*|AWS_*|CLAUDE_CODE_*|TRIPLE_C_PERMISSION_MODE|PATH|HOME|LANG|TZ|COLORTERM|BROWSER)
ANTHROPIC_*|AWS_*|CLAUDE_CODE_*|TRIPLE_C_PERMISSION_MODE|PATH|HOME|LANG|TZ|COLORTERM|BROWSER|NODE_EXTRA_CA_CERTS|REQUESTS_CA_BUNDLE|SSL_CERT_FILE)
# Escape single quotes in value and write as KEY='VALUE'
escaped_value=$(printf '%s' "$value" | sed "s/'/'\\\\''/g")
printf "%s='%s'\n" "$key" "$escaped_value" >> "$ENV_FILE"