Skip to content

Commit 5b1d66e

Browse files
committed
Add OTLP-only observability controls
Support disabling stdout logging while keeping OTLP export active, and document the TPM cert reissue behavior in the sidecar connection flow.
1 parent 297c272 commit 5b1d66e

9 files changed

Lines changed: 429 additions & 169 deletions

File tree

Cargo.lock

Lines changed: 4 additions & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

README.md

Lines changed: 9 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -32,7 +32,7 @@ Typical first-time flow:
3232
2. `./examples/connect.sh --gateway 127.0.0.1:8443 --gateway-ca certs/server-ca.pem` creates `machine-client-ca.pem` under `~/.local/share/agent-gateway/` (or `$XDG_DATA_HOME`). Append that file to `client_ca_path` (for example, `cat ~/.local/share/agent-gateway/machine-client-ca.pem >> certs/client-ca-bundle.pem`).
3333
3. Start the gateway (`cargo run -- --config config.toml`), then return to the terminal running `connect.sh` and press Enter to start the sidecar and Claude.
3434

35-
On later runs, start the gateway first, run `connect.sh`, and press Enter after confirming the machine CA is still registered. `--regenerate-certs` creates a fresh simulated TPM state and machine client CA, so the new CA must be appended to `client_ca_path`.
35+
On later runs, start the gateway first, run `connect.sh`, and press Enter after confirming the machine CA is still registered. If `connect.sh` finds an existing simulated TPM key but the saved `machine-client.pem` was issued for a different public key, it reissues `machine-client.pem` for the current TPM key using the existing machine client CA. `--regenerate-certs` creates a fresh simulated TPM state and machine client CA, so the new CA must be appended to `client_ca_path`.
3636

3737
Pass a custom policy extension value: `connect.sh ... --extension-value agent-beta`.
3838

@@ -71,6 +71,14 @@ Copy `config.example.toml` to `config.toml` and edit it. Key sections:
7171
| `log_level` | yes | `tracing` filter (e.g. `info`, `debug`, `agent_gateway=debug`) |
7272
| `otlp_endpoint` | no | OTLP gRPC endpoint for distributed tracing |
7373

74+
Set `AGENT_GATEWAY_LOG_STDOUT=false` to disable stdout/stderr formatting while
75+
leaving OTLP export enabled. The sidecar reads observability settings from
76+
environment variables. Use `RUST_LOG` for its tracing filter and set
77+
`OTEL_EXPORTER_OTLP_ENDPOINT` (for example, `http://localhost:4317`) to export
78+
sidecar spans over OTLP. When enabled, the sidecar injects W3C trace-context
79+
headers into the CONNECT request it sends to the gateway, and the gateway
80+
continues the same trace.
81+
7482
## Running
7583

7684
```

examples/connect.sh

Lines changed: 10 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -179,12 +179,17 @@ ensure_tpm_key() {
179179
rm -f "$CERT_DIR/tpm-signing-key.ctx"
180180
}
181181

182-
verify_cert_matches_tpm_key() {
182+
cert_matches_tpm_key() {
183+
[[ -f "$CERT_DIR/machine-client.pem" ]] || return 1
183184
openssl pkey -pubin -in "$CERT_DIR/machine-client-public.pem" -outform DER \
184185
> "$CERT_DIR/machine-client-public.der"
185186
openssl x509 -in "$CERT_DIR/machine-client.pem" -pubkey -noout \
186187
| openssl pkey -pubin -outform DER > "$CERT_DIR/machine-client-cert-public.der"
187-
if ! cmp -s "$CERT_DIR/machine-client-public.der" "$CERT_DIR/machine-client-cert-public.der"; then
188+
cmp -s "$CERT_DIR/machine-client-public.der" "$CERT_DIR/machine-client-cert-public.der"
189+
}
190+
191+
verify_cert_matches_tpm_key() {
192+
if ! cert_matches_tpm_key; then
188193
echo "error: machine-client.pem public key does not match simulated TPM key $TPM_HANDLE" >&2
189194
exit 1
190195
fi
@@ -195,6 +200,9 @@ ensure_tpm_key
195200

196201
if needs_certs; then
197202
generate_certs
203+
elif ! cert_matches_tpm_key; then
204+
echo "==> Reissuing machine client certificate for simulated TPM key $TPM_HANDLE"
205+
generate_certs
198206
fi
199207
verify_cert_matches_tpm_key
200208

sidecar/Cargo.toml

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -20,4 +20,8 @@ bytes = "1"
2020
clap = { version = "4", features = ["derive"] }
2121
tracing = "0.1"
2222
tracing-subscriber = { version = "0.3", features = ["env-filter"] }
23+
tracing-opentelemetry = "0.32"
24+
opentelemetry = "0.31"
25+
opentelemetry_sdk = { version = "0.31", features = ["rt-tokio"] }
26+
opentelemetry-otlp = { version = "0.31", features = ["grpc-tonic"] }
2327
anyhow = "1"

sidecar/src/bridge.rs

Lines changed: 143 additions & 71 deletions
Original file line numberDiff line numberDiff line change
@@ -6,19 +6,23 @@ use std::sync::Arc;
66

77
use anyhow::Context;
88
use bytes::Bytes;
9+
use http::{HeaderMap, HeaderName, HeaderValue};
910
use http_body_util::combinators::BoxBody;
1011
use http_body_util::{BodyExt, Empty, Full};
1112
use hyper::body::Incoming;
1213
use hyper::client::conn::http2 as h2_client;
1314
use hyper::service::Service;
1415
use hyper::{Method, Request, Response, StatusCode};
1516
use hyper_util::rt::{TokioExecutor, TokioIo};
17+
use opentelemetry::global;
18+
use opentelemetry::propagation::Injector;
1619
use rustls::ClientConfig;
1720
use rustls_pki_types::ServerName;
1821
use tokio::io::copy_bidirectional;
1922
use tokio::net::TcpStream;
2023
use tokio::sync::Mutex;
21-
use tracing::{error, info, warn};
24+
use tracing::{Instrument, error, info, warn};
25+
use tracing_opentelemetry::OpenTelemetrySpanExt;
2226

2327
type Body = BoxBody<Bytes, Infallible>;
2428

@@ -198,14 +202,34 @@ fn build_connect_request(authority: &str) -> anyhow::Result<Request<Body>> {
198202
.build()
199203
.context("building CONNECT URI")?;
200204

201-
let req = Request::builder()
205+
let mut req = Request::builder()
202206
.method(Method::CONNECT)
203207
.uri(uri)
204208
.body(Empty::<Bytes>::new().boxed())
205209
.context("building CONNECT request")?;
210+
inject_current_trace_context(req.headers_mut());
206211
Ok(req)
207212
}
208213

214+
struct HeaderInjector<'a>(&'a mut HeaderMap);
215+
216+
impl Injector for HeaderInjector<'_> {
217+
fn set(&mut self, key: &str, value: String) {
218+
if let Ok(name) = HeaderName::from_bytes(key.as_bytes())
219+
&& let Ok(value) = HeaderValue::from_str(&value)
220+
{
221+
self.0.insert(name, value);
222+
}
223+
}
224+
}
225+
226+
fn inject_current_trace_context(headers: &mut HeaderMap) {
227+
let context = tracing::Span::current().context();
228+
global::get_text_map_propagator(|propagator| {
229+
propagator.inject_context(&context, &mut HeaderInjector(headers))
230+
});
231+
}
232+
209233
#[derive(Clone)]
210234
struct SidecarService {
211235
connector: Arc<GatewayConnector>,
@@ -238,106 +262,122 @@ async fn handle(
238262
None => return response(StatusCode::BAD_REQUEST, "missing authority"),
239263
};
240264

241-
info!(
265+
let span = tracing::info_span!(
266+
"sidecar CONNECT",
242267
source_identity = %connector.source_identity(),
243268
source_peer_addr = %source_peer_addr,
244269
dest_authority = %authority,
245270
gateway_endpoint = %connector.gateway_endpoint(),
246-
"CONNECT request"
247271
);
248272

249-
let gw_response = match connector.send_connect(&authority, source_peer_addr).await {
250-
Ok(r) => r,
251-
Err(e) => {
252-
error!(
253-
source_identity = %connector.source_identity(),
254-
source_peer_addr = %source_peer_addr,
255-
dest_authority = %authority,
256-
gateway_endpoint = %connector.gateway_endpoint(),
257-
error = %e,
258-
"gateway connect failed"
259-
);
260-
return response(StatusCode::BAD_GATEWAY, "gateway unreachable");
261-
}
262-
};
263-
264-
let status = gw_response.status();
265-
if status != StatusCode::OK {
266-
warn!(
273+
async move {
274+
info!(
267275
source_identity = %connector.source_identity(),
268276
source_peer_addr = %source_peer_addr,
269277
dest_authority = %authority,
270278
gateway_endpoint = %connector.gateway_endpoint(),
271-
%status,
272-
"gateway rejected CONNECT"
279+
"CONNECT request"
273280
);
274-
return response(status, "gateway denied request");
275-
}
276281

277-
let gw_upgraded = match hyper::upgrade::on(gw_response).await {
278-
Ok(u) => u,
279-
Err(e) => {
280-
error!(
282+
let gw_response = match connector.send_connect(&authority, source_peer_addr).await {
283+
Ok(r) => r,
284+
Err(e) => {
285+
error!(
286+
source_identity = %connector.source_identity(),
287+
source_peer_addr = %source_peer_addr,
288+
dest_authority = %authority,
289+
gateway_endpoint = %connector.gateway_endpoint(),
290+
error = %e,
291+
"gateway connect failed"
292+
);
293+
return response(StatusCode::BAD_GATEWAY, "gateway unreachable");
294+
}
295+
};
296+
297+
let status = gw_response.status();
298+
if status != StatusCode::OK {
299+
warn!(
281300
source_identity = %connector.source_identity(),
282301
source_peer_addr = %source_peer_addr,
283302
dest_authority = %authority,
284303
gateway_endpoint = %connector.gateway_endpoint(),
285-
error = %e,
286-
"gateway upgrade failed"
304+
%status,
305+
"gateway rejected CONNECT"
287306
);
288-
return response(StatusCode::BAD_GATEWAY, "gateway tunnel failed");
307+
return response(status, "gateway denied request");
289308
}
290-
};
291-
292-
let client_upgrade = hyper::upgrade::on(req);
293-
let source_identity = connector.source_identity().to_owned();
294-
let gateway_endpoint = connector.gateway_endpoint().to_owned();
295309

296-
tokio::spawn(async move {
297-
let client_upgraded = match client_upgrade.await {
310+
let gw_upgraded = match hyper::upgrade::on(gw_response).await {
298311
Ok(u) => u,
299312
Err(e) => {
300-
warn!(
301-
source_identity = %source_identity,
313+
error!(
314+
source_identity = %connector.source_identity(),
302315
source_peer_addr = %source_peer_addr,
303316
dest_authority = %authority,
304-
gateway_endpoint = %gateway_endpoint,
317+
gateway_endpoint = %connector.gateway_endpoint(),
305318
error = %e,
306-
"client upgrade failed"
319+
"gateway upgrade failed"
307320
);
308-
return;
321+
return response(StatusCode::BAD_GATEWAY, "gateway tunnel failed");
309322
}
310323
};
311324

312-
let mut client_io = TokioIo::new(client_upgraded);
313-
let mut gw_io = TokioIo::new(gw_upgraded);
325+
let client_upgrade = hyper::upgrade::on(req);
326+
let source_identity = connector.source_identity().to_owned();
327+
let gateway_endpoint = connector.gateway_endpoint().to_owned();
314328

315-
match copy_bidirectional(&mut client_io, &mut gw_io).await {
316-
Ok((up, down)) => {
317-
info!(
318-
source_identity = %source_identity,
319-
source_peer_addr = %source_peer_addr,
320-
dest_authority = %authority,
321-
gateway_endpoint = %gateway_endpoint,
322-
bytes_client_to_dest = up,
323-
bytes_dest_to_client = down,
324-
"tunnel closed"
325-
);
326-
}
327-
Err(e) => {
328-
error!(
329-
source_identity = %source_identity,
330-
source_peer_addr = %source_peer_addr,
331-
dest_authority = %authority,
332-
gateway_endpoint = %gateway_endpoint,
333-
error = %e,
334-
"tunnel error"
335-
);
329+
let tunnel_span = tracing::Span::current();
330+
tokio::spawn(
331+
async move {
332+
let client_upgraded = match client_upgrade.await {
333+
Ok(u) => u,
334+
Err(e) => {
335+
warn!(
336+
source_identity = %source_identity,
337+
source_peer_addr = %source_peer_addr,
338+
dest_authority = %authority,
339+
gateway_endpoint = %gateway_endpoint,
340+
error = %e,
341+
"client upgrade failed"
342+
);
343+
return;
344+
}
345+
};
346+
347+
let mut client_io = TokioIo::new(client_upgraded);
348+
let mut gw_io = TokioIo::new(gw_upgraded);
349+
350+
match copy_bidirectional(&mut client_io, &mut gw_io).await {
351+
Ok((up, down)) => {
352+
info!(
353+
source_identity = %source_identity,
354+
source_peer_addr = %source_peer_addr,
355+
dest_authority = %authority,
356+
gateway_endpoint = %gateway_endpoint,
357+
bytes_client_to_dest = up,
358+
bytes_dest_to_client = down,
359+
"tunnel closed"
360+
);
361+
}
362+
Err(e) => {
363+
error!(
364+
source_identity = %source_identity,
365+
source_peer_addr = %source_peer_addr,
366+
dest_authority = %authority,
367+
gateway_endpoint = %gateway_endpoint,
368+
error = %e,
369+
"tunnel error"
370+
);
371+
}
372+
}
336373
}
337-
}
338-
});
374+
.instrument(tunnel_span),
375+
);
339376

340-
response(StatusCode::OK, "")
377+
response(StatusCode::OK, "")
378+
}
379+
.instrument(span)
380+
.await
341381
}
342382

343383
fn response(status: StatusCode, message: &str) -> Response<Body> {
@@ -478,6 +518,38 @@ mod tests {
478518
assert_eq!(req.uri().authority().unwrap().as_str(), "[::1]:8443");
479519
}
480520

521+
#[test]
522+
fn connect_request_injects_trace_context_headers() {
523+
use opentelemetry::trace::TracerProvider as _;
524+
use opentelemetry_sdk::propagation::TraceContextPropagator;
525+
use opentelemetry_sdk::trace::SdkTracerProvider;
526+
use tracing_subscriber::layer::SubscriberExt;
527+
528+
opentelemetry::global::set_text_map_propagator(TraceContextPropagator::new());
529+
530+
let provider = SdkTracerProvider::builder().build();
531+
let tracer = provider.tracer("test");
532+
let subscriber =
533+
tracing_subscriber::registry().with(tracing_opentelemetry::layer().with_tracer(tracer));
534+
535+
tracing::subscriber::with_default(subscriber, || {
536+
let span = tracing::info_span!("sidecar test span");
537+
let _guard = span.enter();
538+
539+
let req = build_connect_request("example.com:443").unwrap();
540+
let traceparent = req
541+
.headers()
542+
.get("traceparent")
543+
.and_then(|value| value.to_str().ok())
544+
.expect("traceparent should be injected");
545+
546+
assert!(traceparent.starts_with("00-"));
547+
assert_eq!(traceparent.len(), 55);
548+
});
549+
550+
let _ = provider.shutdown();
551+
}
552+
481553
#[test]
482554
fn response_ok_has_empty_body() {
483555
let resp = response(StatusCode::OK, "");

0 commit comments

Comments
 (0)