Inject the corporate CA certificate into containers #18

Merged
jknapp merged 2 commits from feature/corporate-ca into main 2026-08-10 18:48:54 +00:00
Owner

A user behind a TLS-terminating corporate VPN had to install their root CA by hand inside a container — lost on Reset, repeated per project. The image had no mechanism at all: no update-ca-certificates call, no NODE_EXTRA_CA_CERTS, nothing in the entrypoint. That breaks npm, git, curl, pip and Claude Code's own API calls, not just browsers.

One setting — a certificate file or a directory of them — mounted read-only like the existing SSH and AWS credentials, applied by the entrypoint on every start, so it survives recreation, migration and Reset.

Fanned out to four consumers

The system trust store is not sufficient on its own:

Consumer Mechanism
curl, git, apt update-ca-certificates
Node, npm, Claude Code NODE_EXTRA_CA_CERTS
Python / pip / requests REQUESTS_CA_BUNDLE, SSL_CERT_FILE
Chrome / Chromium its own NSS db at ~/.pki/nssdb via certutil (libnss3-tools added)

The env vars are set from Rust at container creation, never exported by the entrypoint — entrypoint exports do not reach docker exec sessions, which is where terminals live. That is the same trap that forced BROWSER to become an image-level ENV for the URL shim.

Details that would otherwise fail silently

  • update-ca-certificates only reads .crt; a .pem is ignored with no error. Files are renamed, and the UI shows the resulting .crt names so the rename is visible rather than mysterious.
  • Corporate bundles are usually root + intermediates in one PEM. update-ca-certificates wants one certificate per file, so chains are split.
  • Clearing the setting actively removes the certs. /usr/local/share rides the project's snapshot image, so simply stopping to emit them would leave them installed forever.
  • The recreation label fingerprints the path and every certificate's bytes, so a rotated CA at the same path triggers a rebuild.
  • Env vars are cleared to empty rather than omitted, because docker commit bakes env into the snapshot image.

Verification

Real containers, real self-signed CA, against an openssl s_server with a leaf signed by it:

  • A directory containing Acme Corp Root CA.pem (space, .pem), a multi-cert chain.CRT and a README.md produced exactly the right three .crt files, correctly split and filtered.
  • From a docker exec session all three env vars were present — the precise failure mode the design exists to avoid.
  • curl 200, node 200, python 200, git reached the HTTP layer.
  • Negative control: identical container without the CA — curl (60) unable to get local issuer certificate, node UNABLE_TO_VERIFY_LEAF_SIGNATURE, python CERTIFICATE_VERIFY_FAILED — while public TLS still worked, confirming empty vars are harmless.
  • Idempotent on restart; removing the certs cleans the store and leaves public TLS intact.

Verified by reasoning only: Windows/macOS host paths, DER-encoded certificates (warned and skipped), and the certutil-absent degradation path.

217 Rust tests, 283 frontend tests.

Note: expect a one-line container/Dockerfile conflict with #17, which is also editing the apt layer.

🤖 Generated with Claude Code

https://claude.ai/code/session_01KSP2KNPhuWKQ4DL5TZEn3k

A user behind a TLS-terminating corporate VPN had to install their root CA by hand inside a container — lost on Reset, repeated per project. The image had **no mechanism at all**: no `update-ca-certificates` call, no `NODE_EXTRA_CA_CERTS`, nothing in the entrypoint. That breaks npm, git, curl, pip and **Claude Code's own API calls**, not just browsers. One setting — a certificate file or a directory of them — mounted read-only like the existing SSH and AWS credentials, applied by the entrypoint on every start, so it survives recreation, migration **and** Reset. ## Fanned out to four consumers The system trust store is not sufficient on its own: | Consumer | Mechanism | |---|---| | curl, git, apt | `update-ca-certificates` | | Node, npm, Claude Code | `NODE_EXTRA_CA_CERTS` | | Python / pip / requests | `REQUESTS_CA_BUNDLE`, `SSL_CERT_FILE` | | Chrome / Chromium | its own NSS db at `~/.pki/nssdb` via `certutil` (`libnss3-tools` added) | **The env vars are set from Rust at container creation, never exported by the entrypoint** — entrypoint exports do not reach `docker exec` sessions, which is where terminals live. That is the same trap that forced `BROWSER` to become an image-level `ENV` for the URL shim. ## Details that would otherwise fail silently - `update-ca-certificates` only reads `.crt`; a `.pem` is ignored with no error. Files are renamed, and the UI shows the resulting `.crt` names so the rename is visible rather than mysterious. - Corporate bundles are usually root + intermediates in one PEM. `update-ca-certificates` wants one certificate per file, so chains are **split**. - Clearing the setting **actively removes** the certs. `/usr/local/share` rides the project's snapshot image, so simply stopping to emit them would leave them installed forever. - The recreation label fingerprints the path **and every certificate's bytes**, so a rotated CA at the same path triggers a rebuild. - Env vars are cleared to empty rather than omitted, because `docker commit` bakes env into the snapshot image. ## Verification Real containers, real self-signed CA, against an `openssl s_server` with a leaf signed by it: - A directory containing `Acme Corp Root CA.pem` (space, `.pem`), a multi-cert `chain.CRT` and a `README.md` produced exactly the right three `.crt` files, correctly split and filtered. - **From a `docker exec` session** all three env vars were present — the precise failure mode the design exists to avoid. - curl 200, node 200, python 200, git reached the HTTP layer. - **Negative control:** identical container without the CA — curl `(60) unable to get local issuer certificate`, node `UNABLE_TO_VERIFY_LEAF_SIGNATURE`, python `CERTIFICATE_VERIFY_FAILED` — while public TLS still worked, confirming empty vars are harmless. - Idempotent on restart; removing the certs cleans the store and leaves public TLS intact. Verified by reasoning only: Windows/macOS host paths, DER-encoded certificates (warned and skipped), and the `certutil`-absent degradation path. 217 Rust tests, 283 frontend tests. **Note:** expect a one-line `container/Dockerfile` conflict with #17, which is also editing the apt layer. 🤖 Generated with [Claude Code](https://claude.com/claude-code) https://claude.ai/code/session_01KSP2KNPhuWKQ4DL5TZEn3k
jknapp added 1 commit 2026-08-10 17:41:51 +00:00
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
7a5823cb2b
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
jknapp added 1 commit 2026-08-10 18:21:04 +00:00
Merge remote-tracking branch 'origin/main' into feature/corporate-ca
Build App / compute-version (pull_request) Successful in 4s
Build App / build-macos (pull_request) Successful in 2m28s
Build App / build-windows (pull_request) Successful in 5m20s
Build Container / build-container (pull_request) Successful in 9m58s
Build App / build-linux (pull_request) Successful in 5m12s
Build App / create-tag (pull_request) Skipped
Build App / sync-to-github (pull_request) Skipped
77567ac2ae
# Conflicts:
#	app/src/lib/tauri-commands.ts
jknapp merged commit 57b6b71772 into main 2026-08-10 18:48:54 +00:00
Sign in to join this conversation.
No Reviewers
No labels
1 Participants
Notifications
Due Date
No due date set.
Dependencies

No dependencies set.

Reference: CyberCoveLLC/Triple-C#18