Skip to content

Commit f2b10d8

Browse files
authored
atenet: route agentgateway traffic through worker CONNECT (#25)
Signed-off-by: Eitan Yarmush <eitan.yarmush@solo.io>
1 parent 38293be commit f2b10d8

4 files changed

Lines changed: 193 additions & 29 deletions

File tree

Lines changed: 141 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,141 @@
1+
# Copyright 2026 Google LLC
2+
#
3+
# Licensed under the Apache License, Version 2.0 (the "License");
4+
# you may not use this file except in compliance with the License.
5+
# You may obtain a copy of the License at
6+
#
7+
# http://www.apache.org/licenses/LICENSE-2.0
8+
#
9+
# Unless required by applicable law or agreed to in writing, software
10+
# distributed under the License is distributed on an "AS IS" BASIS,
11+
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12+
# See the License for the specific language governing permissions and
13+
# limitations under the License.
14+
15+
name: Build AgentGateway image
16+
17+
on:
18+
workflow_dispatch:
19+
inputs:
20+
repository:
21+
description: AgentGateway repository
22+
required: true
23+
default: agentgateway/agentgateway
24+
sha:
25+
description: AgentGateway commit SHA
26+
required: true
27+
28+
permissions:
29+
contents: read
30+
packages: write
31+
32+
jobs:
33+
source:
34+
runs-on: ubuntu-24.04
35+
outputs:
36+
sha: ${{ steps.source.outputs.sha }}
37+
short_sha: ${{ steps.source.outputs.short_sha }}
38+
steps:
39+
- name: Checkout AgentGateway
40+
uses: actions/checkout@v4
41+
with:
42+
repository: ${{ inputs.repository }}
43+
ref: ${{ inputs.sha }}
44+
persist-credentials: false
45+
46+
- name: Resolve source revision
47+
id: source
48+
run: |
49+
echo "sha=$(git rev-parse HEAD)" >> "$GITHUB_OUTPUT"
50+
echo "short_sha=$(git rev-parse --short=12 HEAD)" >> "$GITHUB_OUTPUT"
51+
52+
build:
53+
needs: source
54+
strategy:
55+
fail-fast: false
56+
matrix:
57+
include:
58+
- platform: linux/amd64
59+
runner: ubuntu-24.04
60+
artifact: linux-amd64
61+
- platform: linux/arm64
62+
runner: ubuntu-24.04-arm
63+
artifact: linux-arm64
64+
runs-on: ${{ matrix.runner }}
65+
steps:
66+
- name: Checkout AgentGateway
67+
uses: actions/checkout@v4
68+
with:
69+
repository: ${{ inputs.repository }}
70+
ref: ${{ needs.source.outputs.sha }}
71+
persist-credentials: false
72+
73+
- name: Log in to GHCR
74+
uses: docker/login-action@v3
75+
with:
76+
registry: ghcr.io
77+
username: ${{ github.actor }}
78+
password: ${{ secrets.GITHUB_TOKEN }}
79+
80+
- name: Set up Docker Buildx
81+
uses: docker/setup-buildx-action@v3
82+
83+
- name: Build and push by digest
84+
id: build
85+
uses: docker/build-push-action@v6
86+
with:
87+
context: .
88+
platforms: ${{ matrix.platform }}
89+
tags: ghcr.io/${{ github.repository }}/agentgateway
90+
outputs: type=image,push-by-digest=true,name-canonical=true,push=true
91+
build-args: |
92+
VERSION=0.0.0-alpha.${{ needs.source.outputs.short_sha }}
93+
GIT_REVISION=${{ needs.source.outputs.sha }}
94+
95+
- name: Export digest
96+
run: |
97+
mkdir -p "$RUNNER_TEMP/digests"
98+
digest='${{ steps.build.outputs.digest }}'
99+
touch "$RUNNER_TEMP/digests/${digest#sha256:}"
100+
101+
- name: Upload digest
102+
uses: actions/upload-artifact@v4
103+
with:
104+
name: digest-${{ matrix.artifact }}
105+
path: ${{ runner.temp }}/digests/*
106+
if-no-files-found: error
107+
retention-days: 1
108+
109+
push:
110+
needs:
111+
- source
112+
- build
113+
runs-on: ubuntu-24.04
114+
steps:
115+
- name: Download digests
116+
uses: actions/download-artifact@v4
117+
with:
118+
path: ${{ runner.temp }}/digests
119+
pattern: digest-linux-*
120+
merge-multiple: true
121+
122+
- name: Log in to GHCR
123+
uses: docker/login-action@v3
124+
with:
125+
registry: ghcr.io
126+
username: ${{ github.actor }}
127+
password: ${{ secrets.GITHUB_TOKEN }}
128+
129+
- name: Set up Docker Buildx
130+
uses: docker/setup-buildx-action@v3
131+
132+
- name: Push multi-architecture image
133+
working-directory: ${{ runner.temp }}/digests
134+
env:
135+
IMAGE: ghcr.io/${{ github.repository }}/agentgateway:${{ needs.source.outputs.short_sha }}
136+
run: |
137+
docker buildx imagetools create \
138+
--tag "$IMAGE" \
139+
$(printf 'ghcr.io/${{ github.repository }}/agentgateway@sha256:%s ' *)
140+
digest=$(docker buildx imagetools inspect "$IMAGE" --format '{{json .}}' | jq -r '.manifest.digest')
141+
echo "$IMAGE@$digest" >> "$GITHUB_STEP_SUMMARY"

cmd/atenet/internal/router/ingress/ingress.go

Lines changed: 15 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -47,15 +47,20 @@ import (
4747
// defaultActorPort is the actor's port when a request names no other one.
4848
const defaultActorPort = 80
4949

50+
const connectProxyPort = "444"
51+
5052
const (
5153
// OriginalDstMetadataKey is the dynamic-metadata namespace carrying the
52-
// resolved worker address and port. xds.go's ORIGINAL_DST cluster reads
53-
// it to pick the upstream.
54+
// resolved Envoy and AgentGateway routing targets.
5455
OriginalDstMetadataKey = "envoy.filters.listener.original_dst"
5556
// OriginalDstAddressKey is the resolved worker atunnel address (IP:443).
5657
OriginalDstAddressKey = "local"
5758
// OriginalDstPortKey is the actor's target port.
5859
OriginalDstPortKey = "port"
60+
// ConnectProxyAddressKey is the worker atunnel CONNECT endpoint.
61+
ConnectProxyAddressKey = "connect_proxy"
62+
// ConnectDestinationAddressKey is the actor authority reached through the CONNECT proxy.
63+
ConnectDestinationAddressKey = "connect_destination"
5964

6065
// AuthorityFilterStateKey is the filter-state key holding the request's
6166
// :authority, set by xds.go's authorityFilterStateFilter.
@@ -156,15 +161,19 @@ func (h *Handler) HandleRequestHeaders(ctx context.Context, md *extproc.RequestM
156161
// targetPort on the actor; the router's client cert comes from the
157162
// ORIGINAL_DST cluster's upstream TLS context (xds.go).
158163
targetAddr := net.JoinHostPort(workerIP, "443")
164+
connectProxyAddr := net.JoinHostPort(workerIP, connectProxyPort)
165+
connectDestinationAddr := net.JoinHostPort(resources.ActorDNSName(actorRef), strconv.Itoa(targetPort))
159166

160167
slog.InfoContext(ctx, "Route ok", slog.Any("actor", actorRef), slog.String("targetAddr", targetAddr))
161168

162-
// Envoy and agentgateway both pick the upstream from dynamic metadata,
163-
// so the resolved address and port go there.
169+
// Envoy reads local and port; AgentGateway reads the CONNECT proxy and
170+
// destination targets.
164171
dynamicMetadata, err := structpb.NewStruct(map[string]any{
165172
OriginalDstMetadataKey: map[string]any{
166-
OriginalDstAddressKey: targetAddr,
167-
OriginalDstPortKey: strconv.Itoa(targetPort),
173+
OriginalDstAddressKey: targetAddr,
174+
OriginalDstPortKey: strconv.Itoa(targetPort),
175+
ConnectProxyAddressKey: connectProxyAddr,
176+
ConnectDestinationAddressKey: connectDestinationAddr,
168177
},
169178
})
170179
if err != nil {

cmd/atenet/internal/router/ingress/ingress_test.go

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -85,6 +85,10 @@ func dynamicMetadataPort(dynamicMetadata *structpb.Struct) string {
8585
return dynamicMetadata.GetFields()[OriginalDstMetadataKey].GetStructValue().GetFields()[OriginalDstPortKey].GetStringValue()
8686
}
8787

88+
func dynamicMetadataAddress(dynamicMetadata *structpb.Struct, key string) string {
89+
return dynamicMetadata.GetFields()[OriginalDstMetadataKey].GetStructValue().GetFields()[key].GetStringValue()
90+
}
91+
8892
func TestHandleRequestHeadersDoesNotLogSensitiveData(t *testing.T) {
8993
const testUUID = "123e4567-e89b-12d3-a456-426614174000"
9094
const secret = "do-not-log-me"
@@ -292,6 +296,12 @@ func TestHandleRequestHeaders(t *testing.T) {
292296
if got := dynamicMetadataPort(res.DynamicMetadata); got != tc.expectedTargetPort {
293297
t.Errorf("dynamic metadata port = %q, want %q", got, tc.expectedTargetPort)
294298
}
299+
if got, want := dynamicMetadataAddress(res.DynamicMetadata, ConnectProxyAddressKey), "10.0.0.52:444"; got != want {
300+
t.Errorf("CONNECT proxy = %q, want %q", got, want)
301+
}
302+
if got, want := dynamicMetadataAddress(res.DynamicMetadata, ConnectDestinationAddressKey), tc.authority+":"+tc.expectedTargetPort; got != want {
303+
t.Errorf("CONNECT destination = %q, want %q", got, want)
304+
}
295305
})
296306
}
297307
}
@@ -336,6 +346,12 @@ func TestHandleRequestHeadersHandlesConnectMethod(t *testing.T) {
336346
if got := dynamicMetadataPort(res.DynamicMetadata); got != "9090" {
337347
t.Errorf("dynamic metadata port = %q, want %q", got, "9090")
338348
}
349+
if got, want := dynamicMetadataAddress(res.DynamicMetadata, ConnectProxyAddressKey), "10.0.0.52:444"; got != want {
350+
t.Errorf("CONNECT proxy = %q, want %q", got, want)
351+
}
352+
if got := dynamicMetadataAddress(res.DynamicMetadata, ConnectDestinationAddressKey); got != authority {
353+
t.Errorf("CONNECT destination = %q, want %q", got, authority)
354+
}
339355
}
340356

341357
// TestHandleRequestHeaders_ParkingLotFull verifies that when the parking lot is at capacity

manifests/ate-install/components/agentgateway/configmap.yaml

Lines changed: 21 additions & 23 deletions
Original file line numberDiff line numberDiff line change
@@ -34,6 +34,17 @@ data:
3434
# OTEL_TRACES_SAMPLER override must adjust it in step.
3535
randomSampling: 0.01
3636
37+
backends:
38+
- name: worker-connect-proxy
39+
dynamic:
40+
target: extproc["envoy.filters.listener.original_dst"]["connect_proxy"]
41+
policies:
42+
backendTLS:
43+
cert: /run/podidentity.podcert.ate.dev/credential-bundle.pem
44+
key: /run/podidentity.podcert.ate.dev/credential-bundle.pem
45+
root: /run/podidentity.podcert.ate.dev/trust-bundle.pem
46+
insecureHost: true
47+
3748
# http/https serve direct (non-CONNECT) actor traffic only. connect.mode is
3849
# a single global setting with no per-gateway override, and Tunnel mode's
3950
# CONNECT interception happens below the per-request pipeline (at raw
@@ -80,23 +91,12 @@ data:
8091
"filter_state['dev.ate.authority']": request.host
8192
backends:
8293
- dynamic:
83-
# The router reports the resolved worker atunnel address (host:443)
84-
# as dynamic metadata rather than rewriting :authority -- see
85-
# OriginalDstMetadataKey/OriginalDstAddressKey in xds.go. Reading it
86-
# here means :authority/Host stays the actor's real DNS name the
87-
# whole way through, so atunnel authorizes it directly with no
88-
# restore-the-original-Host header needed.
89-
target: extproc["envoy.filters.listener.original_dst"]["local"]
94+
target: extproc["envoy.filters.listener.original_dst"]["connect_destination"]
9095
policies:
91-
# atunnel serves HTTPS on each worker pod IP. Verify its certificate
92-
# against the podidentity CA and present the router's podidentity
93-
# credential. Worker SPIFFE IDs vary with their workload namespace,
94-
# so skip DNS/IP hostname matching while retaining CA verification.
95-
backendTLS:
96-
cert: /run/podidentity.podcert.ate.dev/credential-bundle.pem
97-
key: /run/podidentity.podcert.ate.dev/credential-bundle.pem
98-
root: /run/podidentity.podcert.ate.dev/trust-bundle.pem
99-
insecureHost: true
96+
backendTunnel:
97+
proxy:
98+
backend: /worker-connect-proxy
99+
mode: connect
100100
101101
# CONNECT ingress, on its own dedicated ports (matching Envoy's
102102
# connect/connect-tls listeners) because tunnelProtocol only applies to a
@@ -160,14 +160,12 @@ data:
160160
"filter_state['dev.ate.authority']": source.connectHeaders["host"]
161161
backends:
162162
- dynamic:
163-
# See substrate-actors' backend above.
164-
target: extproc["envoy.filters.listener.original_dst"]["local"]
163+
target: extproc["envoy.filters.listener.original_dst"]["connect_destination"]
165164
policies:
166-
backendTLS:
167-
cert: /run/podidentity.podcert.ate.dev/credential-bundle.pem
168-
key: /run/podidentity.podcert.ate.dev/credential-bundle.pem
169-
root: /run/podidentity.podcert.ate.dev/trust-bundle.pem
170-
insecureHost: true
165+
backendTunnel:
166+
proxy:
167+
backend: /worker-connect-proxy
168+
mode: connect
171169
---
172170
apiVersion: v1
173171
kind: ConfigMap

0 commit comments

Comments
 (0)