Skip to content

fix(stream): support upstream client certificate (mTLS) in L4 proxy#13596

Draft
AlinsRan wants to merge 7 commits into
apache:masterfrom
AlinsRan:feat/12472-stream-mtls
Draft

fix(stream): support upstream client certificate (mTLS) in L4 proxy#13596
AlinsRan wants to merge 7 commits into
apache:masterfrom
AlinsRan:feat/12472-stream-mtls

Conversation

@AlinsRan

@AlinsRan AlinsRan commented Jun 22, 2026

Copy link
Copy Markdown
Contributor

Description

Fixes #12472

The stream (L4 TCP/TLS) subsystem could not present a client certificate (mTLS) when APISIX proxies to a TLS upstream, unlike the http subsystem which honors upstream tls.client_cert/client_key/client_cert_id. apisix/upstream.lua set the stream upstream TLS (SNI/enable) but never applied the client certificate.

Approach

The http subsystem injects the client cert per-request through the apisix-nginx-module C API ngx_http_apisix_upstream_set_cert_and_key. The stream subsystem now has the equivalent ngx_stream_apisix_upstream_set_cert_and_key, shipped in APISIX-Runtime 1.3.8 (apisix-nginx-module 1.19.6, api7/apisix-nginx-module#114). This PR uses it so the stream path mirrors http exactly:

  • apisix/upstream.lua: in the stream scheme == "tls" branch, resolve the cert/key from up_conf.tls.client_cert/client_key (inline) or from the ssl object referenced by tls.client_cert_id, parse + decrypt them once via apisix_ssl.fetch_cert/fetch_pkey (cached), and apply them with resty.apisix.stream.upstream.set_cert_and_key. The decrypted private key is held only as an opaque parsed object and never stringified into an nginx variable.
  • apisix/init.lua: extract the client_cert_id -> api_ctx.upstream_ssl resolution into a shared resolve_upstream_client_cert helper, called from stream_preread_phase too (previously http-only).
  • .requirements / ci/linux-install-openresty.sh: bump APISIX_RUNTIME 1.3.6 -> 1.3.8 (and the runtime-debug .deb checksums).
  • Tests: t/stream-node/upstream-mtls.t — real mTLS handshake against a ssl_verify_client on upstream: success with inline cert and with client_cert_id, and rejection when no client cert is presented.
  • Docs: note stream support in docs/en/latest/mtls.md.

No schema change needed — tls.client_cert/client_key/client_cert_id already exist on the upstream schema.

bug-triage-2026-06

AlinsRan added 4 commits June 23, 2026 04:44
The stream (L4) subsystem could not present a client certificate when
proxying to a TLS upstream, unlike the http subsystem. The http path
injects the client cert via the apisix-nginx-module C API
(set_cert_and_key), which has no stream counterpart.

Instead, wire the native nginx stream proxy_ssl_certificate /
proxy_ssl_certificate_key directives with variables, filled in the
preread phase with the upstream tls.client_cert/client_key (or the ssl
object referenced by tls.client_cert_id) using the inline data: PEM
scheme. An empty value means no client certificate is presented.

Fixes apache#12472
The stream upstream-mtls test referenced the upstream server certs via
`../t/certs/...`, which resolves to `t/servroot/conf/../t/certs/...`
(a non-existent path) and made nginx fail to start. Use the same
`../../certs/...` prefix as other .t tests (e.g. healthcheck-https.t),
which resolves to the repo `t/certs/` directory.

The new `proxy_ssl_certificate` directive in every stream server block
made the per-port PROXY protocol cli test mis-match: its plain-vs-TLS
`grep -E "ssl_certificate "` matched the substring inside
`proxy_ssl_certificate `. Anchor the grep on a leading whitespace
boundary so it only matches the downstream `ssl_certificate` directive.
The stream mTLS path fed `tls.client_key` (or the ssl object key)
directly into the `data:` proxy_ssl_certificate_key variable. That key
is stored AES-encrypted at rest, so nginx received base64 ciphertext
instead of PEM and failed with "cannot load certificate key". Decrypt
it with aes_decrypt_pkey (a no-op for plaintext PEM) before building the
data: value, matching the http path which decrypts via fetch_pkey.

Also correct the no-client-cert test assertion: an mTLS upstream that
rejects a certless handshake logs "client sent no required SSL
certificate", not "upstream SSL certificate verify error" (which only
applies to proxy_ssl_verify server-cert checks).
AlinsRan added 3 commits June 25, 2026 09:50
Replace the interim proxy_ssl_certificate data: plumbing with the new
stream set_cert_and_key C-API shipped in APISIX-Runtime 1.3.8
(apisix-nginx-module 1.19.6). The cert/key are parsed and cached once
and applied at the upstream SSL handshake, so the decrypted private key
is never stringified into an nginx variable.

Bump APISIX_RUNTIME 1.3.6 -> 1.3.8.
- init.lua: log only client ssl id/type instead of delay-encoding the whole
  ssl object, which could emit certificate/key material at info level.
- upstream.lua: guard set_tls and set_cert_and_key independently so an older
  runtime exposing the stream module without set_cert_and_key falls back
  gracefully instead of calling a nil value.
Add repeat_each(1)/no_long_string()/no_shuffle()/no_root_location() per the
test harness convention; no_shuffle keeps the setup-then-hit ordering stable.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

bug: mTLS stream data to external server, APISIX not sending client certificate

1 participant