Skip to content

axum: serve_tls helper + mtls-identity example#80

Merged
iainmcgin merged 3 commits intomainfrom
iain/axum-serve-tls
May 7, 2026
Merged

axum: serve_tls helper + mtls-identity example#80
iainmcgin merged 3 commits intomainfrom
iain/axum-serve-tls

Conversation

@iainmcgin
Copy link
Copy Markdown
Collaborator

Closes #49.

What

Two commits, in the order #49 suggests landing them:

1. connectrpc::axum::serve_tls — a TLS-aware counterpart to axum::serve(listener, router) that owns the rustls accept loop and stamps PeerAddr / PeerCerts into request extensions, the same convention the standalone Server::with_tls uses. Handler code that reads ctx.extensions.get::<PeerCerts>() is now portable between the standalone Server and an axum app.

The returned ServeTls mirrors axum::serve::Serve's shape: a #[must_use] IntoFuture<Output = io::Result<()>> builder with .tls_handshake_timeout() and .with_graceful_shutdown() knobs. The accept loop reuses the standalone Server's conventions: slowloris-bounded handshake timeout (default DEFAULT_TLS_HANDSHAKE_TIMEOUT), transient accept() error skip, TCP_NODELAY, and hyper-util GracefulShutdown drain. Module docs spell out the differences from axum::serve (concrete Router only, ALPN setup, PeerCerts is conditional, no automatic panic catching).

2. examples/mtls-identity — the mTLS twin of examples/middleware: same secret-store-with-ACL shape, but the credential is the client certificate the TLS handshake verified, not a Bearer token. The handler reads PeerCerts from extensions, parses the leaf cert's DNS SAN with x509-parser, derives a single-label workload name under workloads.example.com, and gates an ACL on it.

Single self-contained binary: in-memory rcgen PKI (CA, server leaf, two workload client leafs), serve, call with both identities, graceful shutdown. No PEM files touch disk. The lib.rs exposes the proto, handler, identity extraction, and PKI helpers so tests/e2e.rs reuses them rather than duplicating cert generation.

Side fixes

  • tokio/macros added to the server feature. server.rs and the new axum.rs both use tokio::select!, but the macro feature was previously only reachable via dev-dep unification. A downstream crate that enabled server without itself enabling tokio/macros would fail to compile; CI never caught it because both --all-targets (Check) and cargo test pull in dev-deps.
  • docs/guide.md TLS-hosting drift fix. The guide claimed eliza wraps axum with tokio_rustls::TlsAcceptor for TLS; eliza actually switches to the standalone Server for the TLS path. Replaced with the new serve_tls snippet.

Tests

connectrpc/src/axum.rs (#[cfg(test)]):

  • mTLS round-trip: PeerAddr + PeerCerts reach the handler with the verified leaf
  • handshake timeout releases the per-connection watcher so graceful drain completes
  • handshake error (garbage bytes) doesn't kill the accept loop; a subsequent valid client succeeds
  • graceful shutdown anchors an in-flight request until the handler returns

examples/mtls-identity (tests/e2e.rs + lib.rs unit tests):

  • WhoAmI reflects cert SAN and remote addr
  • authorized read succeeds with x-served-by trailer
  • permission denied for another workload's secret
  • TLS client without a cert is rejected at the handshake
  • extract_identity rejects no-cert, empty-prefix, multi-label-prefix, and unrelated-domain SANs

Notes for review

  • pub mod axum shadows the extern axum crate within connectrpc's crate-root scope only. There's an in-source comment explaining the constraint (don't use axum::... in lib.rs); downstream use connectrpc::axum::serve_tls; is unambiguous. I considered connectrpc::axum_tls but the issue spec's connectrpc::axum::serve_tls reads better, and the shadowing is contained.
  • serve_tls accepts a concrete axum::Router, not the make-service forms axum::serve is generic over. The module docs explain why (PeerAddr replaces ConnectInfo<SocketAddr>, so into_make_service_with_connect_info has no analogue). Generalising the bound would be possible but adds API surface I'd rather defer until someone needs it.
  • The diff is over the soft 250-line target, split across two commits as examples/mtls-identity: demonstrate cert-SAN identity with axum + a helper for parity with standalone Server #49 suggests. Most of the example's lib.rs is the in-memory PKI helper, which is heavily commented because it doubles as a "what a deployment would load from a secret store" model.

Reviewer findings deferred (Low / Advisory)

Flagging these for a decision rather than addressing them in this PR:

  • ServeTls doesn't expose an http1_keep_alive(bool) knob like BoundServer does. axum::serve doesn't either, so serve_tls is at parity with the thing it replaces. Easy to add later if anyone hits the stale-connection race on axum.
  • No accept-rate / max-connections cap on the spawned per-connection tasks. Same shape as the standalone Server and axum::serve, both unbounded. Could grow a Semaphore-backed knob if it becomes a need.
  • PeerAddr could derive Copy (it wraps a Copy SocketAddr). Public API change in server.rs, out of scope here.
  • The axum.rs test fixture duplicates the pki() helper from server.rs's tests. They differ in what they return; hoisting to a shared #[cfg(test)] module is possible but increases coupling.

@github-actions
Copy link
Copy Markdown

github-actions Bot commented May 5, 2026

All contributors have signed the CLA ✍️ ✅
Posted by the CLA Assistant Lite bot.

@iainmcgin iainmcgin marked this pull request as ready for review May 7, 2026 15:24
@iainmcgin iainmcgin requested review from asacamano May 7, 2026 15:24
asacamano
asacamano previously approved these changes May 7, 2026
Comment thread examples/mtls-identity/src/lib.rs Outdated
// Reject `.workloads.example.com` (empty label) and
// `a.b.workloads.example.com` (would alias as `a.b`), which an
// attacker-controlled CA could otherwise mint to spoof an ACL
// entry.
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I realize this is an example, but I wonder about this...

If an attacker-controlled CA is accepted by the mTLS exchange, could they just spoof everything? Not just "a.b", but also "trustedjob", and also maybe "a.b.workloads.example.com", or "trustedjob.workloads.example.com"?

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yeah, this looks like nonsense, will fix

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[claude code] You're right — if the trust store accepts an attacker-controlled CA, the attacker can mint a cert for any SAN, including trustedjob.workloads.example.com, so the comment was overstating what this guard buys you. The empty/multi-label checks are about parsing intent (only direct subdomains map to ACL names), not a CA-compromise mitigation. Reworded the comment in 2f5777b to just state the intent and drop the spoofing framing.

asacamano
asacamano previously approved these changes May 7, 2026
iainmcgin added 3 commits May 7, 2026 16:46
axum::serve takes a plain TcpListener with no hook for terminating TLS,
so axum + mTLS deployments must hand-roll the rustls accept loop and
per-connection extension plumbing to give handlers the same
PeerAddr/PeerCerts view the standalone Server provides automatically.

connectrpc::axum::serve_tls is a drop-in axum::serve replacement that
owns the accept loop, terminates TLS with a slowloris-bounded handshake
timeout, captures the remote address and verified client cert chain
once per connection, and stamps both into request extensions. Handler
code that reads ctx.extensions.get::<PeerCerts>() is now portable
between the standalone Server and an axum app.

The returned ServeTls mirrors axum::serve::Serve: a configurable
IntoFuture builder with .tls_handshake_timeout() and
.with_graceful_shutdown() knobs.

Also adds tokio/macros to the server feature set: the accept loop in
both server.rs and axum.rs uses tokio::select!, but the macro feature
was previously only reachable via dev-dep unification, so a downstream
crate enabling server without tokio/macros would fail to compile.

Refs #49.
The mTLS twin of examples/middleware: same secret-store-with-ACL shape,
but identity comes from the verified client certificate instead of a
Bearer token. Hosted on axum behind connectrpc::axum::serve_tls; the
handler reads PeerCerts from request extensions, parses the leaf cert's
DNS SAN with x509-parser, and enforces an ACL keyed on the cert-derived
workload name.

Single self-contained binary: in-memory rcgen PKI (CA, server leaf, two
workload client leafs), serve, call with both identities, graceful
shutdown. No PEM files touch disk. The shared lib.rs exposes the proto,
handler, identity extraction, and PKI helpers so tests/e2e.rs reuses
them rather than duplicating cert generation.

x509-parser is already a transitive dep via rcgen, so no new crates land
in the lockfile.

Closes #49.
State the intent (only direct subdomains of the workload domain are
accepted) rather than describing one specific spoofing scenario, which
understates what an attacker-controlled CA could do.
@iainmcgin iainmcgin force-pushed the iain/axum-serve-tls branch from 2f5777b to 516882a Compare May 7, 2026 16:49
@iainmcgin iainmcgin enabled auto-merge (squash) May 7, 2026 16:51
@iainmcgin iainmcgin merged commit 0509526 into main May 7, 2026
12 checks passed
@github-actions github-actions Bot locked and limited conversation to collaborators May 7, 2026
Sign up for free to subscribe to this conversation on GitHub. Already have an account? Sign in.

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

examples/mtls-identity: demonstrate cert-SAN identity with axum + a helper for parity with standalone Server

2 participants