Enabling man-in-the-middle (MITM) interception for Actor Egress policy

September 4, 2026 · View on GitHub

Under an sdsmint install, the egress gateway terminates every TLS connection an actor opens and re-originates it. The certificate the actor sees is therefore not the origin's — it is a per-SNI leaf the gateway minted, which chains to the gateway's own CA and to no public root. An actor that validates against only the public roots rejects it, and every HTTPS request the actor makes fails with a certificate error.

This guide covers how to project the gateway's CA into an actor's filesystem and how to point the actor's TLS client at it.

When you need this

You need it when all of the following hold:

  • The cluster runs the sdsmint egress gateway (hack/install-ate.sh --deploy-atenet --experimental-use-sdsmint).
  • The actor makes HTTPS (or any TLS) requests.

If you configure this on a non-sdsmint install you will break the actor: the steps below make the gateway CA the actor's only trust anchor, and without a MITM gateway in front of it nothing the actor dials will chain to that CA.

Project the bundle

Add a systemInfo volume with a trustBundle data source, and mount it:

apiVersion: ate.dev/v1alpha1
kind: ActorTemplate
metadata:
  name: my-actor
  namespace: my-namespace
spec:
  volumes:
  - name: system-info
    systemInfo:
      dataSources:
      # The trust anchors for the per-SNI leaves the egress gateway mints.
      - trustBundle:
          name: egress-mitm.ate.dev
          path: trust-bundle.pem
  containers:
  - name: app
    image: ...
    volumeMounts:
    - name: system-info
      mountPath: /run/ate   # the bundle lands at /run/ate/trust-bundle.pem

trustBundle.name selects a bundle substrate knows how to fetch.

trustBundle.path is relative to the root of the volume, so the file's absolute path is mountPath + path. It must be a clean relative Unix path: no leading or trailing /, no //, ., or .. segments, and at most 16 segments. A systemInfo volume takes at most 8 data sources and their paths must not repeat.

The projected PEM contains CERTIFICATE blocks only, deduplicated and deliberately shuffled — order carries no meaning, so do not write anything that depends on the first block being a particular certificate.

Point the runtime at it

Projecting the file is not enough; each TLS stack has to be told to use it.

Go, and anything linked against OpenSSL

    env:
    - name: SSL_CERT_FILE
      value: /run/ate/trust-bundle.pem
    - name: SSL_CERT_DIR
      value: /run/ate

Set both. SSL_CERT_FILE replaces the default certificate file list, but the default certificate directory list is still scanned, and most base images keep their public roots in /etc/ssl/certs. With SSL_CERT_FILE alone the actor trusts the gateway CA in addition to every public CA. Pointing SSL_CERT_DIR at the projection as well makes the anchor set exactly the gateway CA — so a successful HTTPS fetch proves the projected bundle is what validated the minted leaf, rather than a public root happening to work.

Under sdsmint the public roots are useless anyway: every TLS origin the actor can reach is fronted by the gateway.

Other runtimes

RuntimeVariableNote
Node.jsNODE_EXTRA_CA_CERTS=/run/ate/trust-bundle.pemAdds to Node's bundled roots rather than replacing them; the actor keeps trusting public CAs.
Python requestsREQUESTS_CA_BUNDLE=/run/ate/trust-bundle.pemrequests defaults to certifi and ignores SSL_CERT_FILE.
Python ssl / urllibSSL_CERT_FILE, SSL_CERT_DIRHonored via OpenSSL's default verify paths.
Python httpx, other Certifi-based clientsNo environment variable; Certifi is pinned in code. Pass the path explicitly, e.g. httpx.Client(verify="/run/ate/trust-bundle.pem").
curlCURL_CA_BUNDLE=/run/ate/trust-bundle.pem
git over HTTPSGIT_SSL_CAINFO=/run/ate/trust-bundle.pem
JavaNo environment variable. Convert the PEM to a PKCS#12 or JKS truststore at startup and pass -Djavax.net.ssl.trustStore.

Verify

demos/egress/egress-mitm.yaml.tmpl is a complete working template that does exactly this. Deploy it against an sdsmint install:

./hack/install-ate.sh --deploy-demo-egress-mitm

Then drive an actor's egress at an HTTPS URL and confirm it returns a response rather than a certificate error. Because the demo sets SSL_CERT_DIR as well, a 200 is positive evidence that the projected bundle did the validating.

Operational notes

A bundle that does not resolve fails the actor start. If the name is not on the allowlist, the backing ClusterTrustBundle is missing, or the bundle is empty or unparseable, the actor does not start — an actor that declared a trust bundle must not run without one.

atelet logs it on the node that was going to host the actor, as the err field of the interceptor's Handle RPC record, at INFO, with method=/atelet.AteomHerder/Run (or /atelet.AteomHerder/Restore when a suspended actor is coming back):

while populating system-info volume "system-info": system-info projection "trust-bundle.pem": trust bundle "egress-mitm.ate.dev": ClusterTrustBundle "egress-mitm.ate.dev:mitm:primary-bundle" not found

ateapi surfaces the same text to the caller that asked for the actor, wrapped once by the resume step and once by gRPC:

while creating workload from spec: rpc error: code = Internal desc = while populating system-info volume "system-info": system-info projection "trust-bundle.pem": trust bundle "egress-mitm.ate.dev": ClusterTrustBundle "egress-mitm.ate.dev:mitm:primary-bundle" not found

That is the common case: projecting egress-mitm.ate.dev on an install without --experimental-use-sdsmint, where nothing creates the egress-mitm-ca-pool Secret the bundle derives from. system-info is the volume's name from your template and trust-bundle.pem its path, so those two vary with what you wrote. The other failure modes differ only in the innermost clause:

CauseInnermost clause
Name not on the allowlisttrust bundle "my-own-bundle" is not supported by this deployment (supported: egress-mitm.ate.dev)
Bundle present but empty or unparseabletrust bundle "egress-mitm.ate.dev": unusable ClusterTrustBundle "egress-mitm.ate.dev:mitm:primary-bundle": …

Rotation is picked up on the next resume. atelet re-resolves the bundle on both Run and Restore, so a suspended actor gets the current anchors when it comes back. A long-running actor that never suspends keeps the copy made when it started — and a process that has already loaded the file into memory (Go caches its system pool after first use) will not see a change on disk either way. Plan CA rotation around a resume, with an overlap window that covers the actors that do not suspend.

The bundle is not a substitute for authenticating the actor. It lets the actor verify the gateway. It says nothing to an origin about which actor is calling; see cmd/atenet/internal/router/README.md for that direction.

See also

  • API Configuration Guide — the full systemInfo volume reference.
  • demos/egress/README.md — how tunneled egress and actor-identity authentication fit together.
  • cmd/atenet/internal/router/README.md — the gateway side of the MITM leg.