feat: server-side mtls — certificaterequest + client-cert verify - #439
Merged
Conversation
extracts the handshake portion of handleTlsSession into a new acceptServerHandshake() that returns a ServerSession. handleTlsSession keeps doing exactly what it did before — calls accept(.., null, ..) then enters the forwarding loop with the returned session. new MtlsOpts threads client-cert behavior through: - when non-null, the server emits CertificateRequest between EncryptedExtensions and Certificate - after the server's Finished, expects Certificate (possibly empty), optional CertificateVerify, then Finished - verifies the cert chain against trust_ca_pem, then verifies the CertVerify signature against the cert's pubkey using the .client context string - require_client_cert=true rejects an empty Certificate with MissingClientCert; require_client_cert=false accepts (warn mode) - returns the SAN URI (or subject CN) as session.peer_identity parseCertificateMessage now accepts an empty cert list per RFC 8446 §4.4.2 so the server can detect the empty-cert case. three e2e tests on a socketpair drive the new path through the real client_session.doHandshake: 1. valid client cert → handshake completes, identity surfaces 2. require + empty cert → server returns MissingClientCert 3. warn mode + empty cert → handshake completes, no identity
kacy
added a commit
that referenced
this pull request
Jun 18, 2026
threads the manifest's tls.peer setting through the backend registry to the L7 listener: - Backend gains a peer_mode field (default .off) - BackendRegistry.register takes a peer_mode parameter - startup_runtime passes svc.tls.peer when registering each domain - handleTlsSession takes an optional MtlsOpts; the listener builds one from cluster CA + backend.peer_mode and passes it through - listener loads the cluster CA on demand; missing CA on a tls.peer service is a misconfiguration — logs and downgrades to plain TLS two new tests on BackendRegistry confirm peer_mode round-trips through register/lookup and is overwritten correctly. the end-to-end mTLS protocol is already covered by the socketpair tests in session_runtime (PR #439); this PR's contract is just "the listener picks up tls.peer and reaches acceptServerHandshake with the right MtlsOpts", which the type-level wiring proves.
This was referenced Jun 22, 2026
kacy
added a commit
that referenced
this pull request
Jun 23, 2026
* feat: carry peer_mode through upstream + service registry types adds tls.peer to the spec-side types so the L7 proxy can read the service's mtls posture at dial time: - Upstream gains peer_mode (defaults .off) - ServiceDefinition and ServiceState gain peer_mode (defaults .off) - ServiceSnapshot exposes peer_mode read-only - assignCompatProxyFields copies peer_mode from def to state - snapshotService propagates peer_mode - resolveUpstreamWithPolicy copies peer_mode onto each Upstream candidate and onto the selected one returned to the caller the cluster-state-DB column to persist peer_mode end-to-end (so the manifest's tls.peer reaches resolveUpstream in production) is a small follow-up schema migration; this PR's purpose is the in-memory carry so forwardSingleAttempt can dispatch on it. * feat: forwardSingleAttempt dispatches mtls upstreams to client_dial upstream.peer_mode != .off now branches into a new forwardSingleAttemptMtls: - loads the cluster CA via store.getClusterCa - calls client_dial.dial with the CA pem + sni - writes the request and drains the response through the session, no pooling (mtls sessions hold encryption state and can't share a bare-fd pool key) policy: - .require + missing cluster CA → ClusterCaMissing - .warn + missing cluster CA → log + downgrade to the plaintext dial+pool path (kept as forwardPlainAttempt, exact copy of the legacy leg) so service-to-service traffic keeps flowing while the ca_bootstrap thread catches up readResponseFromSession is the session-aware equivalent of the existing bare-fd readResponse: drains chunks until PeerClosed (the session's orderly EOF) or max_bytes, returns the bytes. three tests on a duck-typed FakeSession cover the read loop's happy path, max-bytes rejection, and immediate-close fallthrough. the live tls handshake itself is already covered end-to-end by the socketpair tests in #438/#439/#442. * feat: persist tls.peer in the services table closes the manifest → registry → upstream loop. previously peer_mode was added to all the in-memory types but production code paths could not set it because the cluster state DB had no column. with this commit: - services.peer_mode TEXT column (default 'off'); ALTER TABLE migration for existing databases (no NOT NULL so the migration is forward-safe). - ServiceRecord.peer_mode (?[]const u8 — null means 'off', keeps every existing literal compiling). - services_core.createInDb inserts peer_mode; syncConfig takes a peer_mode arg and updates the column alongside lb_policy. - syncServiceDefinitions in the manifest apply path now passes svc.tls.peer.label() through. - serviceDefinitionFromRecord parses the textual peer_mode back into the enum so registry → snapshot → Upstream carries it cleanly. with this in place: a manifest with tls.peer = 'require' on a service flows end-to-end — orchestrator writes 'require' to the services row, the registry snapshot reads it back as .require, resolveUpstream copies it onto Upstream, and forwardSingleAttempt dispatches mtls upstreams to client_dial (the changes from the previous commits). one new round-trip test (syncConfig persists peer_mode) confirms the column behaves and updates apply.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
summary
fifth PR-4 chunk (after #435, #436, #437, #438). adds the server side
of mTLS — CertificateRequest emit, client Certificate + CertificateVerify
parse, X.509 chain verify against the cluster CA, and ECDSA-verify of
the client's CertVerify signature. with this, an in-process mTLS
handshake completes end-to-end and the verified peer identity (SAN URI)
is surfaced to the caller.
what's in here
handleTlsSessioninto a newacceptServerHandshake() -> ServerSession. zero behavior change forthe existing non-mTLS path —
handleTlsSessionnow calls accept withMtlsOpts = nullthen runs the forwarding loop with the returnedsession.
MtlsOpts:require_client_cert,trust_ca_pem,expected_identity?,now_unix. when non-null:its own Certificate
(optional) CertificateVerify + Finished, possibly spread across
several records
verifyLeafAgainstCa.clientcontext stringagainst the cert's pubkey
error.MissingClientCert; require=false (warn) accepts an emptycert and surfaces no identity
ServerSession.peer_identity: dup'd SAN URI (or subject CNfallback) for successful mTLS, null otherwise
parseCertificateMessagefix: empty cert lists are valid perRFC 8446 §4.4.2 (TLS 1.3's "I have nothing" signal). previously
short-bodied as Truncated.
tests
three e2e tests on a unix socketpair that drive the real
client_session.doHandshakeagainst the newacceptServerHandshake:peer_identityis theclient cert's SAN URI
MissingClientCertnot in this PR
tls.peer = require|warn|offyoq cert service <name>CLI + handshake metricshandshake helper out of
client_session.zig(since the productionacceptServerHandshakeis now reachable from tests)still no auto-merge — security-critical handshake code.