Skip to content

feat: client_dial helper — open a tcp socket and run mtls handshake - #442

Merged
kacy merged 1 commit into
mainfrom
feat/mtls-upstream-dial
Jun 22, 2026
Merged

feat: client_dial helper — open a tcp socket and run mtls handshake#442
kacy merged 1 commit into
mainfrom
feat/mtls-upstream-dial

Conversation

@kacy

@kacy kacy commented Jun 18, 2026

Copy link
Copy Markdown
Owner

summary

third PR-5 chunk (after #440, #441). adds the small glue that turns
"connect to an upstream over TCP" into "connect and optionally run an
mTLS handshake, returning a session". the L7 proxy wiring (chunk 4)
calls this from forwardSingleAttempt.

self-contained; no proxy behavior change.

what's in here

new src/tls/client_dial.zig exposing:

pub const Outcome = union(enum) {
    bare: posix.fd_t,
    session: client_session.ClientSession,
};

pub fn dial(io, alloc, opts: Options) DialError!Outcome;

Options carries upstream addr/port, optional CA pem (null ⇒ stay
plaintext), optional client cert/key, optional SNI / expected SAN, and
the current unix time. the result is a tagged union so the caller
matches once and uses one of two clean apis — no fd-vs-session
conditionals threaded through every read/write call.

also adds a small parseIpv4 helper (no std.net.Address dependency
to keep the dial path lean) with positive + negative tests.

tests

real TCP listener on 127.0.0.1:<ephemeral> for both cases:

  1. plaintext dial — no CA ⇒ returns .bare, listener accepts.
  2. mTLS dial — full opts ⇒ a worker thread runs the production
    acceptServerHandshake on the listener side; dial returns
    .session; both sides complete cleanly.

plus two parseIpv4 truth-table tests.

not in this PR

  • plumbing peer_mode through service_registry_runtime so
    resolveUpstreamWithPolicy knows which upstreams are mTLS
  • switching reverse_proxy.forwardSingleAttempt's read/write loop to
    match on the dial outcome (the bigger change — touches the
    upstream-pool path too)
  • handshake metrics and yoq cert service <name> CLI

still no auto-merge — security-sensitive wiring.

small glue between the existing connectToUpstream-style raw TCP dial
and the client_session.doHandshake driver from #438. callers pass an
address + optional CA + optional client cert/key and get back a tagged
union: .bare (plain fd, when no CA was supplied) or .session (an mTLS
ClientSession wrapping the dialed fd).

the union shape keeps the consumer (forwardSingleAttempt and friends)
agnostic to the per-target mTLS decision — they just match on the
outcome and use one of two well-defined apis.

two tests on a real local TCP listener:
1. dial with no CA → .bare fd, accept on the listener, close.
2. dial with full mTLS opts → handshake completes against
   session_runtime.acceptServerHandshake on the listener side.

production wiring (plumbing peer_mode through the L7 service registry
and switching reverse_proxy reads/writes onto the union) is the next
chunk — this PR just lands the helper so that chunk has a clean
target.
@kacy
kacy merged commit 31e59a6 into main Jun 22, 2026
10 checks passed
@kacy
kacy deleted the feat/mtls-upstream-dial branch June 22, 2026 21:10
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.
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.

1 participant