Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion Cargo.toml
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
[workspace]
members = ["connectrpc", "connectrpc-codegen", "connectrpc-build", "conformance", "examples/eliza", "examples/middleware", "examples/multiservice", "examples/streaming-tour", "examples/wasm-client", "tests/streaming", "benches/rpc", "benches/rpc-tonic"]
members = ["connectrpc", "connectrpc-codegen", "connectrpc-build", "conformance", "examples/eliza", "examples/middleware", "examples/mtls-identity", "examples/multiservice", "examples/streaming-tour", "examples/wasm-client", "tests/streaming", "benches/rpc", "benches/rpc-tonic"]
resolver = "2"

[workspace.package]
Expand Down
4 changes: 4 additions & 0 deletions connectrpc/Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -102,6 +102,10 @@ server = [
"dep:libc",
"dep:tower-http",
"tokio/net",
# The accept loop uses `tokio::select!`. Surfaced only when downstream
# consumers enable `server` without also enabling `tokio/macros`
# themselves; `--all-targets` (CI) hides it via dev-dep unification.
"tokio/macros",
]

# HTTP client with connection pooling
Expand Down
599 changes: 599 additions & 0 deletions connectrpc/src/axum.rs

Large diffs are not rendered by default.

9 changes: 9 additions & 0 deletions connectrpc/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -171,6 +171,15 @@ pub mod client;
#[cfg(feature = "server")]
pub mod server;

// Optional: TLS-aware `axum::serve` counterpart with peer-identity passthrough.
//
// Note: this module shadows the extern-prelude `axum` crate within the crate
// root scope only. Don't add `use axum::...` here in `lib.rs`; use
// `::axum::...` if a root-level reference to the external crate is ever needed.
#[cfg(all(feature = "axum", feature = "server-tls"))]
#[cfg_attr(docsrs, doc(cfg(all(feature = "axum", feature = "server-tls"))))]
pub mod axum;

// ============================================================================
// Primary exports - Tower-first API
// ============================================================================
Expand Down
17 changes: 9 additions & 8 deletions connectrpc/src/server.rs
Original file line number Diff line number Diff line change
Expand Up @@ -62,8 +62,9 @@ use crate::service::ConnectRpcService;

/// Remote socket address of the connected peer.
///
/// Inserted into every request's extensions by the built-in server's accept
/// loop. Handlers read it via `ctx.extensions.get::<PeerAddr>()`.
/// Inserted into every request's extensions by the built-in [`Server`]'s
/// accept loop and by `connectrpc::axum::serve_tls`. Handlers read it via
/// `ctx.extensions.get::<PeerAddr>()`.
///
/// Callers using a different HTTP stack (axum, raw hyper) in front of
/// [`ConnectRpcService`] can insert this same type
Expand All @@ -73,11 +74,11 @@ pub struct PeerAddr(pub SocketAddr);

/// TLS client certificate chain presented by the peer (leaf first).
///
/// Inserted by the built-in server's TLS accept loop when the
/// [`rustls::ServerConfig`] requests client authentication and the peer
/// presents a valid chain. Absent on plaintext connections or when the
/// client presents no certificate. Handlers read it via
/// `ctx.extensions.get::<PeerCerts>()`.
/// Inserted by the built-in [`Server`]'s TLS accept loop and by
/// `connectrpc::axum::serve_tls` when the [`rustls::ServerConfig`] requests
/// client authentication and the peer presents a valid chain. Absent on
/// plaintext connections or when the client presents no certificate.
/// Handlers read it via `ctx.extensions.get::<PeerCerts>()`.
///
/// The `Arc` makes per-request insertion cheap: all requests on a
/// connection share one chain, so this is a refcount bump, not a copy.
Expand Down Expand Up @@ -681,7 +682,7 @@ fn panic_handler(err: Box<dyn Any + Send + 'static>) -> Response<Full<Bytes>> {
/// - `EMFILE` / `ENFILE`: Too many open files (file descriptor exhaustion)
/// - `ECONNABORTED`: Connection was aborted before accept completed
/// - `EINTR`: Interrupted system call
fn is_transient_accept_error(err: &std::io::Error) -> bool {
pub(crate) fn is_transient_accept_error(err: &std::io::Error) -> bool {
use std::io::ErrorKind;

matches!(
Expand Down
26 changes: 23 additions & 3 deletions docs/guide.md
Original file line number Diff line number Diff line change
Expand Up @@ -676,14 +676,33 @@ Server::new(connect_router)
.await?;
```

For the axum path, wrap the listener with `tokio_rustls::TlsAcceptor`
yourself (this is what the eliza example does).
For the axum path, `connectrpc::axum::serve_tls` (requires both the
`axum` and `server-tls` features) is a drop-in replacement for
`axum::serve` that owns the rustls accept loop and stamps `PeerAddr` /
`PeerCerts` into request extensions exactly as the standalone `Server`
does, so handler code that reads `ctx.extensions.get::<PeerCerts>()`
is portable across both hosting paths:

```rust
let app = axum::Router::new()
.route("/health", axum::routing::get(|| async { "OK" }))
.fallback_service(connect_router.into_axum_service());

let listener = tokio::net::TcpListener::bind("0.0.0.0:8443").await?;
connectrpc::axum::serve_tls(listener, app, server_config)
.with_graceful_shutdown(shutdown_signal)
.await?;
```

The eliza example
([`examples/eliza/README.md`](../examples/eliza/README.md)) walks
through generating self-signed certificates with openssl, configuring
mTLS via `--client-ca`, and the rustls strict-PKI requirement that
your CA cert must be distinct from the server leaf cert.
your CA cert must be distinct from the server leaf cert. The
mtls-identity example
([`examples/mtls-identity/README.md`](../examples/mtls-identity/README.md))
demonstrates `serve_tls` end-to-end with cert-SAN identity extraction
and an ACL keyed on it.

## Clients

Expand Down Expand Up @@ -905,6 +924,7 @@ let service = ConnectRpcService::new(router).with_compression(registry);
|---|---|
| [`streaming-tour/`](../examples/streaming-tour) | All four RPC types (unary, server stream, client stream, bidi) on a trivial NumberService. Smallest demo of handler signatures and client invocation patterns. |
| [`middleware/`](../examples/middleware) | Server-side tower middleware composition: an `axum::middleware::from_fn` bearer-token auth, identity passthrough via `RequestContext::extensions`, response trailers via `Response::with_trailer`. Client demos `ClientConfig::default_header` and `CallOptions::with_timeout`. |
| [`mtls-identity/`](../examples/mtls-identity) | mTLS twin of `middleware/`: axum hosted behind `connectrpc::axum::serve_tls`, identity from the client cert's DNS SAN via `PeerCerts` instead of a bearer token, ACL keyed on the cert-derived identity. In-memory `rcgen` PKI; no PEM files. |
| [`eliza/`](../examples/eliza) | Production-shaped streaming app: a port of the `connectrpc/examples-go` ELIZA demo. Server-streaming Introduce + bidi-streaming Converse, TLS, mTLS, CORS, IPv6, both server and client binaries, interoperates with the hosted Go reference at `demo.connectrpc.com`. |
| [`multiservice/`](../examples/multiservice) | Multiple proto packages compiled together with `buf generate`, multiple services on one server, well-known type usage. |
| [`wasm-client/`](../examples/wasm-client) | Browser fetch transport: same generated client used from `wasm32-unknown-unknown` with a custom `ClientTransport` backed by `web-sys::fetch`. |
Expand Down
50 changes: 50 additions & 0 deletions examples/mtls-identity/Cargo.toml
Original file line number Diff line number Diff line change
@@ -0,0 +1,50 @@
[package]
name = "mtls-identity-example"
version = "0.1.0"
edition.workspace = true
rust-version.workspace = true
license.workspace = true
publish = false

[lib]
name = "mtls_identity_example"
path = "src/lib.rs"

[[bin]]
name = "mtls-identity"
path = "src/main.rs"

[dependencies]
connectrpc = { path = "../../connectrpc", features = ["axum", "client", "client-tls", "server-tls"] }

# Protobuf
buffa = { workspace = true }
buffa-types = { workspace = true }

# Serialization (for generated message types)
serde = { workspace = true }
serde_json = { workspace = true }

# HTTP types (for generated client bounds)
http-body = { workspace = true }
http = { workspace = true }

# Async
tokio = { workspace = true, features = ["full"] }
futures = { workspace = true }

# Web framework
axum = { workspace = true }

# TLS (test PKI generated at runtime; no PEM files in the repo)
rcgen = { workspace = true }
rustls-pki-types = { workspace = true }
# Parse the leaf cert's SubjectAlternativeName in the handler. Already in
# the lockfile transitively via rcgen, so this adds nothing to the build.
x509-parser = "0.18"

[build-dependencies]
connectrpc-build = { path = "../../connectrpc-build" }

[lints]
workspace = true
109 changes: 109 additions & 0 deletions examples/mtls-identity/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,109 @@
# mTLS identity example

Demonstrates cert-SAN-based identity for an axum-hosted ConnectRPC
service. The server is hosted on axum behind
`connectrpc::axum::serve_tls`, which terminates TLS, captures the
verified client certificate chain and remote address, and stamps them
into request extensions as `PeerCerts` / `PeerAddr` — the same
convention the standalone `connectrpc::Server::with_tls` uses. The
handler parses the leaf cert's DNS SAN to derive a workload identity
and enforces an ACL against it.

This is the mTLS twin of [`examples/middleware/`](../middleware): same
secret-store-with-ACL shape, but the credential is a client certificate
instead of a `Bearer` token. The handler-side code that reads
`ctx.extensions.get::<T>()` is unchanged; only what the layer/accept
loop puts into extensions differs.

## Run it

The demo is a single self-contained binary: it generates an in-memory
PKI (CA, server cert, two workload client certs) with `rcgen`, starts
the server, makes a few calls with each identity, and shuts down. No
PEM files touch disk.

```bash
cargo run -p mtls-identity-example
```

Expected output (port and source ports vary):

```
IdentityService listening on https://127.0.0.1:PORT (mTLS required)

[alice] WhoAmI -> identity="alice" san="alice.workloads.example.com" from="127.0.0.1:..."
[bob] WhoAmI -> identity="bob" san="bob.workloads.example.com" from="127.0.0.1:..."

[alice] GetSecret( shared) -> "the value of teamwork" (x-served-by: alice)
[alice] GetSecret(alice-only) -> "alice's diary entry" (x-served-by: alice)
[bob] GetSecret( shared) -> "the value of teamwork" (x-served-by: bob)
[bob] GetSecret(alice-only) -> permission_denied: workload "bob" (bob.workloads.example.com) cannot read "alice-only"
```

## What to look at

### `serve_tls` instead of `axum::serve` (`src/lib.rs::serve`)

`axum::serve` accepts a plain `TcpListener` with no hook for
terminating TLS, so an axum + mTLS deployment normally has to write a
rustls accept loop by hand. `connectrpc::axum::serve_tls` is a drop-in
replacement that owns that loop and stamps `PeerAddr` / `PeerCerts`
into request extensions:

```rust
let app = axum::Router::new().fallback_service(connect_router.into_axum_service());
connectrpc::axum::serve_tls(listener, app, server_config)
.with_graceful_shutdown(shutdown)
.await?;
```

Handler code that reads `ctx.extensions.get::<PeerCerts>()` is then
portable between the standalone `Server::with_tls` and an axum app.

### Cert-SAN identity (`src/lib.rs::extract_identity`)

The handler reads the leaf cert from `PeerCerts`, parses its DNS SAN
with `x509-parser`, and derives a short workload name from a SAN under
`workloads.example.com`. A real deployment would typically match a
SPIFFE ID (a URI SAN) instead, or hand the whole step to an
authorization framework — the shape is the same: read `PeerCerts`,
parse the leaf, derive an identity.

Two failure modes both surface as `Unauthenticated`:

- No client cert presented: only reachable if the server's
`ClientCertVerifier` made client auth optional. This example uses
`WebPkiClientVerifier`, which *requires* a verified chain, so this
path is dead in practice — kept as defense in depth.
- A cert is presented but no SAN matches the workload domain.

### In-memory PKI (`src/lib.rs::pki`)

`pki::generate(&["alice", "bob"])` builds a CA, a server leaf
(`SAN = localhost`), and one client leaf per workload
(`SAN = <name>.workloads.example.com`), all in memory via `rcgen`. A
deployment would load these from a secret store; the rustls types are
identical.

The server config requires *and verifies* client certs against the
demo CA, so the chain that reaches the handler is always verified —
the SAN parsing only has to decide *which* trusted client this is, not
whether to trust it.

## Integration test

`tests/e2e.rs` exercises four paths: identity reflection (`WhoAmI`),
authorized read with response trailer, permission denied (bob reading
alice's secret), and a TLS client without a cert being rejected at the
handshake before the request reaches HTTP.

```bash
cargo test -p mtls-identity-example
```

## Where to go next

- See [`examples/middleware`](../middleware) for the bearer-token
equivalent of this example.
- See [`examples/eliza`](../eliza) for loading certs from PEM files
with `--cert`/`--key`/`--client-ca` CLI flags.
8 changes: 8 additions & 0 deletions examples/mtls-identity/build.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
fn main() {
connectrpc_build::Config::new()
.files(&["proto/anthropic/connectrpc/mtls_identity/v1/identity.proto"])
.includes(&["proto/"])
.include_file("_connectrpc.rs")
.compile()
.unwrap();
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,35 @@
edition = "2023";

package anthropic.connectrpc.mtls_identity.v1;

// IdentityService demonstrates mTLS cert-SAN-based identity. The server
// is hosted on axum behind connectrpc::axum::serve_tls, which terminates
// TLS and stamps PeerAddr/PeerCerts into request extensions. The handler
// parses the leaf cert's DNS SAN to derive a workload identity rather
// than reading a bearer token, then enforces an ACL against it.
service IdentityService {
// Echo back what the server knows about the caller from the TLS layer.
rpc WhoAmI(WhoAmIRequest) returns (WhoAmIResponse);

// Read a secret, gated on the caller's cert-derived identity.
rpc GetSecret(GetSecretRequest) returns (GetSecretResponse);
}

message WhoAmIRequest {}

message WhoAmIResponse {
// Workload identity parsed from the leaf cert's DNS SAN, e.g. "alice".
string identity = 1;
// Full DNS SAN as presented, e.g. "alice.workloads.example.com".
string san = 2;
// Caller's remote socket address as observed by the accept loop.
string remote_addr = 3;
}

message GetSecretRequest {
string name = 1;
}

message GetSecretResponse {
string value = 1;
}
Loading
Loading