Skip to content

feat(stream): support upstream mTLS client cert via C-API#114

Merged
AlinsRan merged 5 commits into
mainfrom
feat/stream-upstream-mtls
Jun 24, 2026
Merged

feat(stream): support upstream mTLS client cert via C-API#114
AlinsRan merged 5 commits into
mainfrom
feat/stream-upstream-mtls

Conversation

@AlinsRan

@AlinsRan AlinsRan commented Jun 23, 2026

Copy link
Copy Markdown
Contributor

What

Add a stream-subsystem equivalent of the http set_cert_and_key C-API so APISIX can present a client certificate (mTLS) to a TLS upstream over the L4/stream proxy — mirroring how the http subsystem already does it.

This is the foundation for replacing the interim proxy_ssl_certificate data:$var approach in apache/apisix#13596 (stream mTLS, issue #12472) with the same mechanism http uses.

Why C-API instead of the proxy_ssl_certificate data: directive

The http subsystem deliberately uses a C-API rather than the data:-in-variable directive, for two reasons this PR brings to stream:

  1. No private key in an nginx variable. The decrypted private key is held only as an opaque EVP_PKEY and never stringified — so it can't leak into error_log / access_log (the data: approach puts the plaintext PEM into a variable, which nginx dumps to the error log on any SSL load failure).
  2. Parse once. The cert/key are parsed/cached on the APISIX side and applied as already-parsed objects, instead of nginx re-parsing the inline PEM on every upstream handshake (every L4 connection).

Changes

  • src/stream/ngx_stream_apisix_module.{c,h}: store parsed cert/pkey in the module ctx (ngx_stream_apisix_upstream_set_cert_and_key), apply at the upstream SSL handshake (ngx_stream_apisix_set_upstream_sslSSL_use_certificate / SSL_add1_chain_cert / SSL_use_PrivateKey), free via pool cleanup. Mirrors ngx_http_apisix_* exactly.
  • lib/resty/apisix/stream/upstream.lua: set_cert_and_key(cert, key) binding.
  • patch/1.29.2.4/nginx-stream_upstream_mtls.patch: inject ngx_stream_apisix_set_upstream_ssl(s, pc) right before ngx_ssl_handshake(pc) in ngx_stream_proxy_ssl_init_connection. Verified to apply cleanly against nginx 1.29.2 (patch -p0, applies at the correct location). The #include <ngx_stream_apisix_module.h> is already added by nginx-tcp_over_tls.patch.

Follow-ups (not in this PR)

  • Port the one-line proxy hunk to the other supported patch/<ver>/ dirs (the C module + Lua binding are version-agnostic; only the proxy injection is per-nginx-version).
  • apache/apisix: switch set_stream_upstream_client_cert to resty.apisix.stream.upstream.set_cert_and_key (parsed + decrypted + cached via fetch_cert/fetch_pkey) and drop the proxy_ssl_certificate $upstream_mtls_cert template plumbing — after a runtime build ships this.

Testing

Compile-validated by mirroring the http implementation; the patch was dry-run/applied against the pinned nginx 1.29.2 source. Functional mTLS verification happens downstream via apache/apisix t/stream-node/upstream-mtls.t once a runtime image includes this. (Cannot run the full openresty build locally.)

Summary by CodeRabbit

  • New Features
    • Added stream upstream mutual TLS support, letting requests provide an upstream client certificate chain and matching private key.
    • Introduced a new stream API to set upstream certificate and key for the upstream TLS handshake.
  • Bug Fixes
    • Upstream mTLS credentials are now applied during the upstream SSL handshake when stream SSL is enabled, with stricter input validation and safer credential replacement.
  • Tests
    • Added stream mTLS tests for success and failure scenarios, including repeated calls.
    • Updated TLS handshake error assertions to match OpenResty version differences.
  • Chores
    • Adjusted CI exclusions for specific stream xRPC tests.

Mirror the http subsystem's set_cert_and_key mechanism for the stream
proxy, so APISIX can present a client certificate to a TLS upstream over
L4 without exposing the private key as an nginx variable.

- ngx_stream_apisix_module: store parsed cert/pkey in the module ctx
  (set_cert_and_key), apply them at the upstream SSL handshake via
  set_upstream_ssl (SSL_use_certificate / SSL_add1_chain_cert /
  SSL_use_PrivateKey), with pool-cleanup-based freeing.
- lib/resty/apisix/stream/upstream: set_cert_and_key Lua binding.
- patch/1.29.2.4: inject ngx_stream_apisix_set_upstream_ssl(s, pc) right
  before ngx_ssl_handshake(pc) in ngx_stream_proxy_ssl_init_connection
  (the <ngx_stream_apisix_module.h> include is already added by
  nginx-tcp_over_tls.patch).

This is the foundation for replacing the stream proxy_ssl_certificate
'data:' variable approach (apache/apisix#13596) with the same parse-once,
no-key-in-variable path http already uses.
@coderabbitai

coderabbitai Bot commented Jun 23, 2026

Copy link
Copy Markdown

Review Change Stack

Note

Reviews paused

It looks like this branch is under active development. To avoid overwhelming you with review comments due to an influx of new commits, CodeRabbit has automatically paused this review. You can configure this behavior by changing the reviews.auto_review.auto_pause_after_reviewed_commits setting.

Use the following commands to manage reviews:

  • @coderabbitai resume to resume automatic reviews.
  • @coderabbitai review to trigger a single review.

Use the checkboxes below for quick actions:

  • ▶️ Resume reviews
  • 🔍 Trigger review
📝 Walkthrough

Walkthrough

Adds upstream mTLS support for stream proxy. The per-request context gains SSL cert/key fields with pool cleanup. Two new C functions store and apply upstream client certificates at SSL handshake time. Nginx patches hook the apply function into ngx_stream_proxy_ssl_init_connection across multiple versions. A Lua FFI function exposes the API to callers. Four test cases validate successful mTLS, parameter validation, cert verification, and repeated API calls. CI and test expectations are updated for OpenResty 1.29.2.4 compatibility.

Changes

Stream upstream mTLS

Layer / File(s) Summary
Context struct, SSL helpers, and pool cleanup
src/stream/ngx_stream_apisix_module.c
ngx_stream_apisix_ctx_t gains upstream_cert and upstream_pkey fields under #if (NGX_STREAM_SSL). Static helpers for X509 copying, error clearing, and cert/key freeing are added. ngx_stream_apisix_get_module_ctx() is updated to register a pool cleanup handler that frees those fields at request end. ngx_stream_apisix_upstream_enable_tls() is refactored to use the new ctx getter.
C functions: set_cert_and_key and set_upstream_ssl
src/stream/ngx_stream_apisix_module.c, src/stream/ngx_stream_apisix_module.h
ngx_stream_apisix_upstream_set_cert_and_key() validates inputs, replaces any cached cert/key pair via the cleanup handler, deep-copies the cert chain, and refcounts the key. ngx_stream_apisix_set_upstream_ssl() reads the stored chain and key from ctx and applies them to the upstream SSL connection using OpenSSL APIs. The header adds the NGX_STREAM_SSL-guarded declaration for ngx_stream_apisix_set_upstream_ssl.
Nginx stream proxy SSL handshake hook
patch/1.19.9/nginx-stream_upstream_mtls.patch, patch/1.21.4/nginx-stream_upstream_mtls.patch, patch/1.21.4.1/nginx-stream_upstream_mtls.patch, patch/1.25.3.1/nginx-stream_upstream_mtls.patch, patch/1.27.1.1/nginx-stream_upstream_mtls.patch, patch/1.29.2.4/nginx-stream_upstream_mtls.patch
Patches ngx_stream_proxy_ssl_init_connection() across multiple nginx versions to call ngx_stream_apisix_set_upstream_ssl(s, pc) under #if (NGX_STREAM_APISIX) before ngx_ssl_handshake.
Lua FFI declaration and set_cert_and_key API
lib/resty/apisix/stream/upstream.lua
Adds the FFI declaration for ngx_stream_apisix_upstream_set_cert_and_key and implements _M.set_cert_and_key(cert, key) with argument validation, FFI invocation, and error propagation.
Upstream mTLS test cases
t/stream/upstream_mtls.t
Injects upstream TLS server on port 1995 with cert/key and ssl_verify_client on. Four tests cover successful handshake with set_cert_and_key, validation error handling for missing private key, certificate verification failure with mismatched cert, and repeated calls to set_cert_and_key.
OpenResty 1.29.2.4 compatibility
.github/workflows/ci.yml, t/ssl.t
CI workflow excludes stream xrpc tests due to debug log changes in stream-lua 0.0.19. Version detection logic in t/ssl.t selects expected error messages; error log matchers in TLS handshake tests use regex patterns to accommodate reworded messages in OpenResty 1.29.2.4's tlshandshake implementation.

Sequence Diagram

sequenceDiagram
  participant LuaPlugin as Lua Plugin
  participant UpstreamLua as upstream.lua (set_cert_and_key)
  participant CModule as ngx_stream_apisix_module.c
  participant NginxSSL as ngx_stream_proxy_ssl_init_connection
  participant OpenSSL as OpenSSL

  LuaPlugin->>UpstreamLua: set_cert_and_key(cert, key)
  UpstreamLua->>CModule: ngx_stream_apisix_upstream_set_cert_and_key(r, cert, key)
  CModule->>CModule: free previous cert/key, deep-copy chain, refcount key
  CModule->>CModule: store in ctx->upstream_cert / upstream_pkey
  CModule-->>UpstreamLua: NGX_OK
  UpstreamLua-->>LuaPlugin: true

  Note over NginxSSL: upstream SSL handshake begins
  NginxSSL->>CModule: ngx_stream_apisix_set_upstream_ssl(s, pc)
  CModule->>CModule: read ctx->upstream_cert, upstream_pkey
  CModule->>OpenSSL: SSL_use_certificate, SSL_add1_chain_cert, SSL_use_PrivateKey
  OpenSSL-->>CModule: success
  CModule-->>NginxSSL: return
  NginxSSL->>OpenSSL: ngx_ssl_handshake(pc)
Loading

Estimated code review effort

🎯 4 (Complex) | ⏱️ ~45 minutes

Possibly related PRs

  • api7/apisix-nginx-module#113: Introduces the corresponding OpenResty 1.29.2.4 patch that hooks ngx_stream_apisix_set_upstream_ssl into the stream proxy SSL handshake path, which is core to this PR's mTLS implementation.
🚥 Pre-merge checks | ✅ 5 | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
E2e Test Quality Review ⚠️ Warning E2E test is comprehensive and code quality is solid, but a critical review comment on t/ssl.t version safety was not addressed, leaving it vulnerable to undef variable warnings. Apply the suggested fix to t/ssl.t lines 11-13: add // '' default to eval, and guard numeric comparisons with defined checks on $major and $minor.
✅ Passed checks (5 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The pull request title 'feat(stream): support upstream mTLS client cert via C-API' directly and accurately describes the main change: adding support for upstream mutual TLS client certificates in the stream subsystem via a new C-API function.
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.
Security Check ✅ Passed The PR implements stream mTLS support by storing opaque EVP_PKEY and X509 objects in per-request memory context with proper cleanup handlers. Keys are never logged, stringified, or exposed—they rem...

✏️ Tip: You can configure your own custom pre-merge checks in the settings.

✨ Finishing Touches
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch feat/stream-upstream-mtls

Comment @coderabbitai help to get the list of available commands.

Add t/stream/upstream_mtls.t exercising resty.apisix.stream.upstream
set_cert_and_key over a real stream proxy to a TLS upstream that requires
a client certificate (ssl_verify_client on):
- valid client cert+key -> mTLS handshake succeeds (ssl_client_verify SUCCESS)
- missing private key -> rejected by the Lua wrapper before handshake
- wrong client cert (not signed by the upstream CA) -> upstream verify error
- repeated set_cert_and_key -> still handshakes (covers the re-entrant
  cleanup path in ngx_stream_apisix_upstream_set_cert_and_key)

Mirrors the existing http t/upstream_mtls.t for the stream subsystem; the
feature previously had no test coverage.

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Actionable comments posted: 1

🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@t/stream/upstream_mtls.t`:
- Around line 65-89: The TEST 2 block currently validates that set_cert_and_key
rejects the missing private key argument, but does not verify that the upstream
mTLS handshake actually failed. Add a negative handshake assertion after the
error_log section to ensure the TLS handshake did not succeed, for example by
checking that a success confirmation pattern (such as "client verify: SUCCESS")
is absent from the logs or error output, or by asserting that an expected
handshake-failure pattern is present. This prevents the test from passing if
cert/key state from previous tests are accidentally reused.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: CHILL

Plan: Pro

Run ID: 860978b0-a26c-4ae8-91b9-00f45beb7fe9

📥 Commits

Reviewing files that changed from the base of the PR and between 47f2676 and 150af10.

📒 Files selected for processing (1)
  • t/stream/upstream_mtls.t

Comment thread t/stream/upstream_mtls.t
The set_upstream_ssl injection that actually presents the client cert
during the upstream TLS handshake lived only in patch/1.29.2.4/. Every
other runtime that has nginx-tcp_over_tls.patch (1.19.9, 1.21.4, 1.21.4.1,
1.25.3.1, 1.27.1.1) was missing it, so on those builds set_cert_and_key
stored the cert but it was never applied -- the upstream rejected the
handshake with "400 No required SSL certificate was sent". This was
invisible until t/stream/upstream_mtls.t was added; it surfaced on the
api7ee-runtime build (nginx 1.21.4).

The injection point (before ngx_ssl_handshake in
ngx_stream_proxy_ssl_init_connection) is identical across these versions,
so the patch body is shared; patch -p0 applies it by context.

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🧹 Nitpick comments (1)
.github/workflows/ci.yml (1)

18-21: 📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick win

Track a concrete re-enable path for the excluded xrpc tests.

This skip is reasonable short-term, but without a tracked follow-up it can silently become permanent and reduce coverage on apisix-runtime. Please add an issue reference/TODO with re-enable criteria in this comment block.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In @.github/workflows/ci.yml around lines 18 - 21, The comment block explaining
why the xrpc tests are excluded (t/stream/xrpc/downstream.t and
t/stream/xrpc/upstream.t in the exclude_tests configuration) lacks a tracked
follow-up mechanism for re-enabling them. Add a TODO comment or issue reference
to the existing comment block that specifies the concrete criteria or conditions
for when these tests should be re-enabled on apisix-runtime, such as a specific
version bump or functionality milestone. This ensures the exclusion is temporary
and prevents the coverage reduction from becoming permanent.
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Nitpick comments:
In @.github/workflows/ci.yml:
- Around line 18-21: The comment block explaining why the xrpc tests are
excluded (t/stream/xrpc/downstream.t and t/stream/xrpc/upstream.t in the
exclude_tests configuration) lacks a tracked follow-up mechanism for re-enabling
them. Add a TODO comment or issue reference to the existing comment block that
specifies the concrete criteria or conditions for when these tests should be
re-enabled on apisix-runtime, such as a specific version bump or functionality
milestone. This ensures the exclusion is temporary and prevents the coverage
reduction from becoming permanent.

ℹ️ Review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: CHILL

Plan: Pro

Run ID: edb05870-8e9b-4ea9-8f35-3bc7113890c3

📥 Commits

Reviewing files that changed from the base of the PR and between 68c2219 and 2d79dec.

📒 Files selected for processing (2)
  • .github/workflows/ci.yml
  • t/ssl.t

t/ssl.t TEST 3 and TEST 5 assert error messages emitted by our own
lua-resty-core-tlshandshake patch, whose wording differs between the
OpenResty 1.21.x patch and the 1.29.2.4 patch. Detect the running
openresty version via 'nginx -v' and assert the exact message for that
version instead of a loose regex, so a wrong-message regression is still
caught.

xrpc downstream/upstream tests assert stream-lua internal
buffer-allocation debug logs that changed in stream-lua 0.0.19
(OpenResty 1.29.2.4); exclude them on apisix-runtime only.
@AlinsRan AlinsRan force-pushed the feat/stream-upstream-mtls branch from 2d79dec to f953f0a Compare June 23, 2026 20:37

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Actionable comments posted: 1

🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@t/ssl.t`:
- Around line 11-13: The version parsing in the block starting with the `eval`
statement and the regex match on `$version` does not guard against cases where
the pattern fails to match. When the openresty version regex pattern does not
find a match, `$major` and `$minor` will be undefined, causing the subsequent
numeric comparisons in the `$reworded` assignment to generate warnings and
potentially select the wrong code branch. Add a guard condition to check that
both `$major` and `$minor` are defined and have valid numeric values before
performing the numeric comparisons in the `$reworded` assignment, ensuring a
sensible default value is used when the version cannot be parsed.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: CHILL

Plan: Pro

Run ID: 2ed2d4da-d9a8-421d-9bd5-123c077bfe3d

📥 Commits

Reviewing files that changed from the base of the PR and between 2d79dec and f953f0a.

📒 Files selected for processing (2)
  • .github/workflows/ci.yml
  • t/ssl.t
🚧 Files skipped from review as they are similar to previous changes (1)
  • .github/workflows/ci.yml

Comment thread t/ssl.t Outdated
- t/ssl.t: guard the openresty version parse so an unexpected `nginx -V`
  output falls back to the pre-1.29 wording instead of warning on an undef
  numeric comparison.
- t/stream/upstream_mtls.t: TEST 2 now also asserts the upstream rejects the
  request ("No required SSL certificate was sent"), proving the handshake did
  not succeed when set_cert_and_key rejected the missing key, not just that the
  argument was rejected.
@AlinsRan AlinsRan force-pushed the feat/stream-upstream-mtls branch from e9304a8 to 43d6f9b Compare June 24, 2026 00:02

@membphis membphis left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

LGTM.

@AlinsRan AlinsRan merged commit 996fd95 into main Jun 24, 2026
6 checks passed
@AlinsRan AlinsRan deleted the feat/stream-upstream-mtls branch June 24, 2026 05:11
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.

3 participants