README
¶
Egress Demo — Pluggable Egress Networking
This demo shows an Actor's outbound traffic being transparently tunneled through an egress gateway and authenticated by actor identity, end to end. The same demo runs with either Envoy or agentgateway.
The Actor is a tiny service that accepts {"url":"..."}, performs an HTTP GET, and returns
the upstream response. The Actor believes it is dialing plain HTTP directly — but its egress is
intercepted and carried over mTLS to a gateway that verifies who is making the request.
What it demonstrates
┌──────────────── ateom worker pod ─────────────────┐
│ Actor (gVisor) │
│ GET http://<dst-ip>:80/ (plain HTTP) │
│ │ │
│ ▼ nftables REDIRECT │
│ atunnel egress ──(mTLS with the actor's own │
│ │ certificate + bare CONNECT) │
└────────┼────────────────────────────────────────────┘
▼
┌──────────── atenet-egress pod ───────────────────┐
│ Egress Gateway │
│ • downstream mTLS, trusted by actor-id CA │
│ • terminates HTTP CONNECT │
│ • verifies Actor identity with the ATE API │
│ • forwards to the CONNECT authority │
│ │ │
└───────────┼───────────────────────────────────────┘
▼
real destination (the CONNECT authority, an IP:port)
- Guide 1 - gateway accepts CONNECT + mTLS.
atenet-egressterminates the actor's mTLSCONNECTand tunnels to the requested destination. - Guide 2 — transparent interception.
nftablesREDIRECTs actor TCP egress intoatunnel, which wraps it in mTLS +CONNECT. - Guide 3 — HTTP-only actors, identity carried by the certificate. The Actor only dials plain
HTTP. atunnel presents the actor's own certificate — minted per actor by ateapi off the
actor-identity CA, carrying an
ActorIdentityX.509 extension — and sends a bareCONNECTwith no identity headers at all. - Identity authentication. The gateway requires a client certificate signed by the
actor-identity CA, so a non-actor client is refused at the handshake. It authorizes the
certificate against the ATE API and rejects it unless the certified UID matches a real,
RUNNINGactor.
Choose a dataplane
The dataplane is selected when installing ate-system, not when installing this demo. The Actor,
ActorTemplate, worker pool, test, and manual walkthrough are otherwise the same.
# Envoy (default)
./hack/install-ate-kind.sh --deploy-ate-system
# agentgateway
./hack/install-ate-kind.sh --deploy-ate-system --atenet-router=agentgateway
| Envoy | agentgateway | |
|---|---|---|
| Select with | --atenet-router=envoy (default) |
--atenet-router=agentgateway |
| Egress routing | Dynamic forward proxy | Dynamic backend from CONNECT authority |
| Actor authentication | Co-located atenet ext_proc |
Built-in substrateEgress policy |
| Configuration | Envoy bootstrap in atenet-egress.yaml |
Static agentgateway ConfigMap overlay |
| Access log | Text beginning with [egress], including actor SAN |
Structured log including substrate.connect.authority |
| MITM mode | Supported with --experimental-use-sdsmint |
Supported with --experimental-use-sdsmint |
The experimental additional egress ext_proc service currently requires Envoy; the installer
rejects that option with agentgateway rather than silently omitting it.
Components
- Egress app (
main.go) — the Actor:POST /with{"url":"..."}→ fetches it → returns status + body. It also servesPOST /grpc, described below. - Egress gateway — the
atenet-egressDeployment. Envoy uses a co-located atenetext_proccontainer started with--mode=egress; agentgateway uses its built-insubstrateEgresspolicy and does not need that sidecar. The installer renders the matching configuration and container. - Egress opt-in —
ate-api-server --egress-gateway-address=atenet-egress.ate-system.svc:443(set inmanifests/ate-install/ate-api-server.yaml). ateapi stamps the address onto every ateletRun/Restore, which turns on tunneled egress cluster-wide. - Actor-identity trust — the gateway mounts the
actor-id-ca-certsSecret, a cert-only copy of the actor-identity CA root thathack/install-ate.shderives fromactor-id-ca-pool(which also holds the CA signing key and is deliberately not mounted here).
Prerequisites
- A kind cluster with Agent Substrate installed (
hack/create-kind-cluster.sh, followed by one of the dataplane installation commands above). Egress is enabled by the ateapi flag above. ko,kubectl, andkubectl-ate(go install ./cmd/kubectl-ate).
Deploy the demo fixture
./hack/install-ate.sh --deploy-demo-egress
The install applies the worker pool, creates the ate-demo-egress atespace and
the egress ActorTemplate (a substrate resource, not a CRD) through the ate
API, and blocks until the template's golden snapshot is built:
kubectl ate get actor-template egress -a ate-demo-egress
Run the automated test (easiest)
./demos/egress/test-egress.sh
It detects the deployed dataplane, deploys an in-cluster HTTP target, creates and resumes an Actor, then asserts:
- positive — a real Actor's egress reaches the target (
HTTP 200) through the gateway (the target sees the gateway's IP as its client), and the gateway logs the CONNECT. Envoy's log includes the actor certificate SAN; agentgateway's structured log includes the authority; - negative — a pod holding a valid pod identity but no actor certificate cannot open a tunnel at all: the gateway trusts the actor-identity CA, so the mTLS handshake is refused before any CONNECT is answered.
Add --cleanup to remove everything the script created.
Manual walkthrough
# 1. An in-cluster target the Actor will fetch (any HTTP server works).
kubectl create namespace egress-target
kubectl -n egress-target create deployment whoami --image=traefik/whoami
kubectl -n egress-target expose deployment whoami --port=80
TARGET_IP=$(kubectl -n egress-target get svc whoami -o jsonpath='{.spec.clusterIP}')
# 2. Create and resume an Actor in the demo's atespace: --template-ref
# resolves the template by name within the actor's own atespace.
kubectl ate create actor egress-demo -a ate-demo-egress --template-ref egress
kubectl ate resume actor egress-demo -a ate-demo-egress # wait for ACTOR_STATE_RUNNING
# 3. Drive the Actor's egress through the ingress gateway.
kubectl -n ate-system port-forward service/atenet-router 8000:80 &
curl -s -X POST http://localhost:8000/ \
-H 'Host: egress-demo.ate-demo-egress.actors.resources.substrate.ate.dev' \
-H 'Content-Type: application/json' \
-d "{\"url\":\"http://${TARGET_IP}:80/\"}"
What to observe
With Envoy:
# The egress gateway logs each tunneled CONNECT against the verified peer certificate:
kubectl -n ate-system logs deploy/atenet-egress -c envoy | grep '\[egress\]'
# [egress] authority=<TARGET_IP>:80 peer_san=spiffe://substrate-actor.local/atespace/ate-demo-egress/actor/egress-demo … code=200 …
# The co-located ext_proc sidecar logs the identity decision, including the UID it authorized on:
kubectl -n ate-system logs deploy/atenet-egress -c ext-proc | grep -i 'egress identity\|egress denied'
# egress identity authenticated atespace=ate-demo-egress actor=egress-demo actorUid=… destination=<TARGET_IP>:80
With agentgateway:
# The structured access log includes the CONNECT authority:
kubectl -n ate-system logs deploy/atenet-egress -c agentgateway \
| grep 'substrate.connect.authority'
The whoami body shows RemoteAddr: <atenet-egress pod IP> — proof the request egressed
through the gateway rather than directly.
gRPC over the same tunnel
POST /grpc with {"target":"<ip>:<port>","message":"hello","streamCount":2,"bidiCount":2} makes
the Actor dial that address as a cleartext-HTTP/2 gRPC server and return what came back:
{
"message": "hello",
"stream": [{"message":"hello","index":0},{"message":"hello","index":1}],
"bidi": [{"message":"hello-0","index":0},{"message":"hello-1","index":1}],
"code": "OK"
}
One RPC per streaming shape, because each fails differently over a network path:
- unary
Echo— always run. Its status arrives in trailers, after the response body, which is whatcodereports and what a path that dropped trailers or downgraded to HTTP/1.1 could not produce. - server-streaming
EchoStream— whenstreamCountis positive. Many frames over one held-open connection. - bidirectional
EchoBidi— whenbidiCountis positive. The Actor sends each message only after reading the response to the previous one, then half-closes the request direction while the response direction is still open. A path that serialized the two directions hangs here rather than answering short.
The gateway terminates the CONNECT and relays opaque TCP, so all of this crosses it untouched. A
failed RPC answers 502 and still carries its gRPC code in the same field.
internal/e2e/suites/networking drives this endpoint against the testserver fixture's grpc
subcommand (internal/e2e/fixtures/testserver, deployed by e2e.DeployServerPod from the shared
server-pod manifest) in TestActorEgressGRPC; the same fixture, or any h2c gRPC server reachable
from the cluster, works for a manual run.
Notes / limitations
- This milestone authenticates identity (is this a real, running actor?). Authorizing
egress by destination and injecting upstream credentials/tokens is a follow-up, implemented in
the same
ext_proc(policy API TBD). - Identity comes entirely from the actor certificate: the atespace, actor name, and UID are read
out of the
ActorIdentityextension and the UID is matched against the live actor, so a certificate cannot survive its actor being deleted and recreated under the same name. Nothing the actor can write into the CONNECT contributes to the decision.
Cleanup
./demos/egress/test-egress.sh --cleanup
./hack/install-ate.sh --delete-demo-egress