🌐 US-Proxy
class="logged-out env-production page-responsive" style="word-wrap: break-word;" >
Skip to content

feat: NATS mTLS pubsub implementation - #26902

Merged
cstyan merged 16 commits into
mainfrom
callum/nats-mtls-functions
Jul 13, 2026
Merged

feat: NATS mTLS pubsub implementation#26902
cstyan merged 16 commits into
mainfrom
callum/nats-mtls-functions

Conversation

@cstyan

@cstyan cstyan commented Jun 30, 2026

Copy link
Copy Markdown
Contributor

This PR supersedes the existing chain of 3 PRs for mTLS, implementing the CA cert fetching/leaf key generation via an extension of the existing signing cache + functions on TLS structs themselves which allow for signing/verification on each request without storage of the root CA cert locally (other than the cache).

Summary of Changes

  1. updated the cryptokeys signing cache to allow for fetch/refresh/rotation via the existing logic, with a modification to allow for returning a different result when fetching/reading a CA cert instead of the existing byte slice we use for JWTs.

  2. We use the tls Config callbacks GetCertificate/GetClientCertificate/VerifyConnection to mint/return the replicas leaf key, and to verify peers against the CA fetched from the cache. This means that each leaf includes the relevant CA certs sequence # in its leaf certificate, meaning it should be easy/consistent to fetch the correct cert. during rotation overlap periods on the receiving end.

Notes

  • The pubsub itself starts with a noop CA cache implementation in the signing cache, which is replaced if the HA feature is enabled in the license
  • The nats_ca cert generation/rotation is disabled unless the experiment is enabled
  • InsecureSkipVerify is used intentionally, by default Go has a static RootCA field but we need to have CA cert rotation, so we do our own verification via VerifyConnection
    • RequireAnyClientCert still requires a peer cert, so all connections are still mutually verified
  • Source IP binding is accept-side only; Go only exposes the full underlying connection when accepting a connection/route, we can only verify the IP SAN of the peer at this time
  • This mTLS implementation is in addition to the existing token auth

Open Questions

  • Do we need any verification whether the peer IP is a valid member of the replicas table in addition to just the IP SAN check? This would allow for dial side verification in addition to the existing accepting side verification
  • currently we just shoehorn a sequence number for the CA certs into the x509 subject serial number, is this reasonable or should we use a custom extension? this would be relatively simple, mostly just requiring a bit of glue code plus some parsing function
  • right now we don't have warn/error logging of issues such as handshake failures/invalid leaf keys/etc. as it could be too noisy without a log deduper, should we use a metric instead?

@github-actions

Copy link
Copy Markdown

Docs preview

📖 View docs preview for docs/reference/api/schemas.md

@cstyan
cstyan force-pushed the callum/nats-mtls-functions branch from cc97307 to 6d28412 Compare June 30, 2026 23:33
@cstyan

cstyan commented Jun 30, 2026

Copy link
Copy Markdown
Contributor Author

/coder-agents-review

@coder-agents-review

coder-agents-review Bot commented Jun 30, 2026

Copy link
Copy Markdown
Contributor

Chat: Review posted | View chat
Requested: 2026-07-01 01:40 UTC by @cstyan
Spend: $53.82 / $100.00

Review history
  • R1 (2026-07-01), 1 P1, COMMENT. Review
  • R2 (2026-07-01): 18 reviewers, 4 Nit, 1 P1, 1 P2, 4 P3, COMMENT. Review

deep-review v0.9.0 | Round 2 | a73e677..5e5174e

Last posted: Round 2, 10 findings (1 P1, 1 P2, 4 P3, 4 Nit), COMMENT. Review

Finding inventory

Findings

# Sev Status Location Summary Round Reviewer Posted
CRF-1 P1 Author fixed (5e5174e) rotate.go:328 keysByFeature rejects DB keys for unmanaged features, breaking all rotation when NATS experiment toggled off R1 Netero Yes
CRF-2 P2 Open tls.go:149 Leaf-minting errors in currentLeaf invisible to operators; no logging parallel to verifyLogged R2 Chopper Yes
CRF-3 P3 Open rotate.go:24 NATSCALeafValidity name misleads (leaf validity vs CA retention budget); invariant linking it to leafCertValidity unenforced R2 Hisoka P3, Mafuuu P3, Gon P3, Leorio P3, Knov P3, Ryosuke P3, Kite P3 Yes
CRF-4 P3 Open enterprise/coderd/coderd.go:893 configureNATSClusterTLS silent return on nil/empty relay URL; missing Warn log R2 Mafuuu Yes
CRF-5 P3 Open dbgen/dbgen.go:2237 generateCACryptoKeySecret near-duplicates generateCASecret; export eliminates the copy R2 Robin Yes
CRF-6 P3 Open tls.go:164 Rejected peer certs logged at Debug, invisible at production log levels R2 Chopper Yes
CRF-7 P4 Dropped by orchestrator (renewal tested transitively through mesh tests) tls_internal_test.go:97 No direct unit test for currentLeaf renewal on time expiry R2 Bisky No
CRF-8 P4 Dropped by orchestrator (Go API limitation, documented by author) tls.go:302 Dial-side verify does not bind peer cert to dialed address R2 Kurapika No
CRF-9 P4 Dropped by orchestrator (style, comments are accurate) coderd.go:615 Comment verbosity: 6 lines for 2-line concept R2 Gon No
CRF-10 P4 Dropped by orchestrator (style, comments are accurate) coderd.go:308 Comment restates SigningKeycache interface contract R2 Gon No
CRF-11 P4 Dropped by orchestrator (style, comments are accurate) cli/server.go:828 Comment: 5 lines for "noop; real cache swapped in later" R2 Gon No
CRF-12 Nit Open tls.go:262 Subject.SerialNumber carries signing CA sequence, not leaf's own identifier (open question flagged by author) R2 Mafuuu Yes
CRF-13 Nit Open tls.go:341 leafHasIP is a manual contains; slices.ContainsFunc is the project pattern R2 Ging-Go Yes
CRF-14 Nit Dropped by orchestrator (low impact) tls.go:40 clockSkewToleranceTLS duplicates clockSkewTolerance from ca.go R2 Gon, Robin No
CRF-15 Nit Open coderd.go:637 "properly" is noise in Fatal log R2 Leorio Yes
CRF-16 Nit Open tls.go:325 x509.VerifyOptions uses time.Now() while rest of clusterTLS uses quartz.Clock R2 Knov Yes
CRF-17 Nit Dropped by orchestrator (comment is close enough) tls.go:325 EKU comment overstates constraint (Go accepts at-least-one, not exactly) R2 Kurapika No
CRF-18 Nit Dropped by orchestrator (low impact) cache.go:253 idSecret name is opaque; decodeKeyMaterial would be self-describing R2 Gon No
CRF-19 Nit Dropped by orchestrator (low impact) ca.go:59 Bare 128 magic number for CA serial; tls.go names it leafSerialBits R2 Gon No

Law analysis

Effective LOC: 1448 (722 prod, 726 test, 13 generated). Head SHA: 6d28412. Verdict: Don't split. Enforcement: Advisory. The PR is one reviewable idea (NATS cluster mTLS), all concerns serve it, and no independent risk domains or unrelated work is bundled.

Round log

Round 1

Netero-only. 1 P1. Reviewed against c40b3b9..6d28412.

Round 2

Panel (18 reviewers). CRF-1 addressed. 1 P2, 4 P3, 5 Nit new. 7 dropped (P4/Nit below threshold). Reviewed against a73e677..5e5174e.

About deep-review

CRF = Coder Review Finding (P0-P4, Nit, Note)

Reviewer Focus
Bisky tests
Chopper ops/errors
Churn-guard change verification
Ging language modernization
Gon naming
Hisoka edge cases
Killua perf
Kite change integrity
Knov contracts
Knuckle SQL
Komugi flake/determinism
Kurapika security
Law decomposition
Leorio docs
Luffy product
Mafu-san process
Mafuuu contracts
Melody dispatch/pairing
Meruem structural
Nami frontend
Netero mechanical checks
Pariston premise testing
Pen-botter product gaps
Razor verification
Robin duplication
Ryosuke Go arch
Takumi concurrency
Zoro shape

🤖 Managed by Coder Agents.

@coder-agents-review coder-agents-review Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

First-pass review (Netero). 1 P1 finding. The full review panel has not yet reviewed this PR; it will review after this finding is addressed.

The mTLS design is well-structured overall: CA generation, leaf minting, and verification callbacks are cleanly separated. Test coverage is thorough with a ~1:1 test-to-production ratio.

However, there is a P1 that can break all key rotation when the NATS experiment is toggled off after having been on.

"Every 10-minute rotation tick fails with 'keys by feature: unknown feature: nats_ca'. The initial rotateKeys call in StartRotator also fails." (Netero)


coderd/cryptokeys/rotate.go:328

P1 [CRF-1] keysByFeature rejects DB keys for features outside the rotator's managed set, breaking ALL key rotation when the NATS experiment is toggled off after having been on. (Netero)

GetCryptoKeys returns every feature's rows. keysByFeature errors if any key's feature is absent from the features map. Before this PR, the rotator always ran with the full feature set, so no mismatch was possible. Now WithFeatures allows a subset (excluding nats_ca when the experiment is off), but once nats_ca rows exist in the DB from a prior experiment-on run, every rotation tick aborts:

rotateKeys
  -> tx.GetCryptoKeys(ctx)        // returns ALL features
  -> keysByFeature(keys, k.features)
       for _, key := range keys {
           if _, ok := m[key.Feature]; !ok {
               return nil, xerrors.Errorf("unknown feature: %s", key.Feature)  // breaks here
           }
       }

This means workspace apps, OIDC convert, and tailnet resume keys all stop rotating.

Fix: keysByFeature should skip (not error on) keys for features outside the managed set. They belong to other features the rotator is not responsible for.

🤖

🤖 This review was automatically generated with Coder Agents.

@cstyan
cstyan force-pushed the callum/nats-mtls-functions branch 2 times, most recently from 179b894 to 5e5174e Compare July 1, 2026 01:12
@cstyan

cstyan commented Jul 1, 2026

Copy link
Copy Markdown
Contributor Author

/coder-agents-review

@coder-agents-review coder-agents-review Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Panel review (R2, 18 reviewers). CRF-1 (P1) addressed. 1 P2, 4 P3, 5 Nit new.

The mTLS design is structurally sound. Lock ordering is strict (clusterTLS.mu -> cache.mu, never reversed). The noop/real cache swap pattern cleanly separates the enterprise enable path from the boot path. The CRF-1 fix generalizes correctly, protecting any future gated feature, not just nats_ca. Test coverage is thorough across unit, behavioral, and integration layers. Error messages name the failing operation and wrap the cause consistently. The two-commit split is clean.

Seven reviewers independently flagged the NATSCALeafValidity naming/invariant issue, the strongest signal from the panel. The leaf-minting error invisibility is an operational gap worth closing before this ships.

"An operator whose CA cache is broken would see zero routes forming with zero diagnostic output. The only recovery path is noticing the rotator's Critical log, which is about the rotator, not about why cluster routes aren't forming." (Chopper)

🤖 This review was automatically generated with Coder Agents.

Comment thread coderd/x/nats/tls.go Outdated
func (t *clusterTLS) tlsConfig() *tls.Config {
return &tls.Config{
MinVersion: tls.VersionTLS13,
GetCertificate: func(*tls.ClientHelloInfo) (*tls.Certificate, error) { return t.currentLeaf() },

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

P2 [CRF-2] currentLeaf() errors are invisible to operators. (Chopper)

GetCertificate and GetClientCertificate call currentLeaf() on every route handshake. When it fails (CA cache error, wrong key type, mint failure), the error returns to Go's TLS stack, which sends a generic alert and drops the connection. The embedded NATS server runs with NoLog, so it never surfaces the cause.

The verify path has verifyLogged (line 164) which wraps rejections with a Debug log. The currentLeaf path has no equivalent. An operator whose CA cache is broken sees zero routes forming with zero diagnostic output. The "minted nats cluster leaf" log at line 230 fires on success, not failure.

A currentLeafLogged wrapper parallel to verifyLogged would close this gap.

🤖

Comment thread coderd/cryptokeys/rotate.go Outdated
Comment on lines +24 to +28
// NATSCALeafValidity is the maximum lifetime of a leaf certificate
// minted under the NATS cluster CA. Old CA rows must remain valid trust
// roots for this long after rotation so that replicas holding leaves
// signed by the old CA can still be verified.
NATSCALeafValidity = time.Hour * 24 * 30

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

P3 [CRF-3] NATSCALeafValidity name and doc say "maximum lifetime of a leaf certificate," but the actual leaf lifetime is leafCertValidity = 24h in coderd/x/nats/tls.go:29. This constant (30 days) is a CA retention budget, not a leaf lifetime. (Hisoka P3, Mafuuu P3, Gon P3, Leorio P3, Knov P3, Ryosuke P3, Kite P3)

Seven reviewers independently flagged this. The invariant leafCertValidity <= NATSCALeafValidity must hold, or old CAs get deleted while in-flight leaves still chain to them, breaking verification during rotation overlap. Today the 30x headroom absorbs any mistake, but the constants live in separate packages with no compile-time, init-time, or runtime check linking them.

A developer reading "maximum lifetime of a leaf" would either shrink NATSCALeafValidity to 24h to match the actual leaf, or raise leafCertValidity to 30d to match this constant. Either breaks the system.

Fix: rename to something like NATSCAKeyRetention, update the godoc to describe what it actually controls (CA retention window, not leaf lifetime), and cross-reference leafCertValidity. A compile-time assertion would make the invariant mechanical rather than documentary.

🤖

Comment thread enterprise/coderd/coderd.go Outdated
// the IP SAN peers verify, so without an IP-based relay URL the cache is left
// as the boot-time noop and routes stay plaintext (token auth only). The CA is
// read lazily by the TLS callbacks on each handshake, so nothing reads it here.
func (api *API) configureNATSClusterTLS(natsPubsub *nats.Pubsub) {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

P3 [CRF-4] Silent return when relay URL is nil or empty. When the hostname is not an IP, line 898 logs a Warn. When the relay URL is absent, this branch returns without any log. (Mafuuu)

An operator who enables HA without setting CODER_DERP_SERVER_RELAY_URL gets no indication that cluster routes are running on token auth only, no mTLS. Debugging cluster security requires knowing that mTLS depends on the DERP relay URL, a non-obvious coupling.

Fix: log at Warn level before the early return, matching the pattern on line 898.

🤖

Comment on lines +2237 to +2244
func generateCACryptoKeySecret() (string, error) {
key, err := ecdsa.GenerateKey(elliptic.P256(), rand.Reader)
if err != nil {
return "", xerrors.Errorf("generate key: %w", err)
}
template := &x509.Certificate{
SerialNumber: big.NewInt(1),
Subject: pkix.Name{CommonName: "dbgen-ca"},

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

P3 [CRF-5] generateCACryptoKeySecret near-duplicates generateCASecret at coderd/cryptokeys/ca.go:52. (Robin)

~28 lines of identical flow: generate P256 key, build self-signed CA template, CreateCertificate, MarshalECPrivateKey, PEM-encode. If the bundle format changes (key type, PEM block labels, or the post-parse validation in parseCASecret that rejects non-CA certs), the dbgen copy won't track.

cryptokeys does not import dbgen, so exporting generateCASecret (or a GenerateTestCASecret wrapper with sensible defaults) and calling it from dbgen eliminates the copy with no import cycle.

🤖

Comment thread coderd/x/nats/tls.go Outdated
func (t *clusterTLS) verifyLogged(cs tls.ConnectionState, sourceIP net.IP) error {
err := t.verify(cs, sourceIP)
if err != nil {
t.logger.Debug(t.ctx, "rejected nats cluster peer certificate", slog.Error(err))

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

P3 [CRF-6] Rejected peer certificates are logged at Debug, invisible at production log levels. The comment at line 161 states its purpose: "logging here gives deployments a way to see why a cluster peer was rejected." At the default production log level (Info or Warn), this purpose is not achieved. (Chopper)

The PR description flags this as an open question: "should we use a metric instead?" The noise concern during rotation overlap is valid. A rate-limited Warn or a counter metric would preserve the operator signal without saturating logs during expected transient rejections.

🤖

Comment thread coderd/x/nats/tls.go
CommonName: "coder-nats-cluster-leaf",
// SerialNumber carries the sequence of the CA that signed this
// leaf, letting a verifier fetch exactly that CA from its cache.
SerialNumber: strconv.FormatInt(int64(ca.Sequence), 10),

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Nit [CRF-12] The leaf's Subject.SerialNumber carries the signing CA's crypto_keys sequence, which is a different entity's identifier. RFC 5280 section 4.1.2.6 defines this field for the entity itself. (Mafuuu)

The PR description flags this as an open question. A custom X.509 extension (OID under coder's arc) would be semantically correct and avoids confusion when PKI tooling or log indexers interpret this field as an entity identifier. The current approach is functional and safe (trust comes from chain verification, not the stamped sequence), so this is a convention/semantics decision for stabilization.

🤖

Comment thread coderd/x/nats/tls.go Outdated
}

// leafHasIP reports whether the leaf carries ip as an IP SAN.
func leafHasIP(leaf *x509.Certificate, ip net.IP) bool {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Nit [CRF-13] leafHasIP is a manual contains check; slices.ContainsFunc is the project pattern (dozens of call sites). (Ging-Go)

func leafHasIP(leaf *x509.Certificate, ip net.IP) bool {
	return slices.ContainsFunc(leaf.IPAddresses, ip.Equal)
}

🤖

Comment thread coderd/coderd.go Outdated
if experiments.Enabled(codersdk.ExperimentNATSPubsub) {
options.NATSCACache, err = cryptokeys.NewSigningCache(ctx, options.Logger.Named("nats_ca_cache"), &cryptokeys.DBFetcher{DB: options.Database}, codersdk.CryptoKeyFeatureNATSCA)
if err != nil {
options.Logger.Fatal(ctx, "failed to properly instantiate NATS CA cache", slog.Error(err))

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Nit [CRF-15] "properly" is noise. (Leorio)

options.Logger.Fatal(ctx, "failed to instantiate NATS CA cache", slog.Error(err))

🤖

Comment thread coderd/x/nats/tls.go
// Leaves carry both ServerAuth and ClientAuth, since each replica is both a
// route server and client. Requiring those specific usages rejects a leaf
// with some unexpected EKU rather than accepting any usage.
if _, err := leaf.Verify(x509.VerifyOptions{

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Nit [CRF-16] x509.VerifyOptions does not set CurrentTime, so Go defaults to time.Now(). Every other time-sensitive operation in clusterTLS uses t.clock.Now(). (Knov)

In production both are real time. In tests with a mock clock, a test that advances the clock past a CA's NotAfter would see pool pruning fire correctly but x509.Verify would still accept the leaf. Setting CurrentTime: t.clock.Now() closes the gap for future test authors.

🤖

cstyan and others added 2 commits July 6, 2026 18:59
Add a nats_ca crypto-key feature holding the NATS cluster mTLS CA: a PEM
cert+key bundle minted by the key rotator (generateCASecret/parseCASecret),
sized so an old CA stays a valid trust root for the maximum leaf lifetime
after rotation. The CA is served through the generic cryptokeys signing
cache rather than a bespoke cache: idSecret decodes the PEM bundle into a
*NATSCA for nats_ca (hex bytes otherwise), so SigningKey returns the active
CA and VerifyingKey returns a specific CA by sequence, reusing the cache's
fetch/refresh/rotation logic.

The feature is experiment-gated. The rotator only mints nats_ca when opted
in via WithFeatures (default rotation excludes it), and coderd.New opts it
in and builds a real signing cache only when ExperimentNATSPubsub is enabled;
otherwise the cache is a NoopSigningKeycache so callers still get a valid
response (treated as mTLS-off). nats_ca is kept off the workspace-proxy
crypto key allowlist so the CA private key is never served over the API.

Co-authored-by: Mux <mux@coder.com>
Add cluster-route mTLS driven by tls.Config callbacks that read the nats_ca
CA cache on each handshake, so a CA rotation is tracked with no restart.
Each replica mints an ephemeral leaf from the active CA, stamping the
signing CA's crypto_keys sequence into the leaf so a verifier loads exactly
that CA (rotation overlap works in both directions). InsecureSkipVerify is
set so verification runs in VerifyConnection against the live CA instead of
a static, rotation-blind RootCAs pool; the connection is still mutually
verified (RequireAnyClientCert).

The leaf carries the replica's relay IP as an IP SAN. On the accepting
side, where the dialing peer's source address is available, verification
also requires the leaf SAN to match the connection source IP; the dialing
side has no equivalent hook in Go and verifies the chain only.

mTLS is optional: the pubsub boots with a noop CA cache (no leaf can be
minted, so no route forms) and zero CA dependency. Enterprise HA swaps the
real nats_ca cache plus the relay IP in via SetClusterCA when the
high-availability feature is enabled, and reverts to noop when disabled.
mTLS complements the existing shared route token (defense in depth).

Co-authored-by: Mux <mux@coder.com>
@cstyan
cstyan force-pushed the callum/nats-mtls-functions branch from 5e5174e to f24b480 Compare July 6, 2026 19:00
Comment thread coderd/cryptokeys/ca.go Outdated
CommonName: "coder-nats-ca",
},
NotBefore: anchorTime.Add(-clockSkewTolerance),
NotAfter: anchorTime.Add(keyDuration + NATSCALeafValidity + clockSkewTolerance),

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

this should still change

Comment on lines +28 to +30
// The certificate's NotAfter must track the supplied keyDuration, not a
// hardcoded default, so a CA stays valid for as long as it can be the
// active signer plus the longest leaf it signs.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

the leaf duration needs to change when we get proper rotation squared away

Comment on lines +78 to +86
// WithFeatures sets the crypto key features the rotator manages, replacing the
// default set. Use this to opt experiment- or deployment-gated features (such
// as the NATS cluster CA) into rotation only when their owner is active.
func WithFeatures(features []database.CryptoKeyFeature) RotatorOption {
return func(r *rotator) {
r.features = slices.Clone(features)
}
}

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

should we change this to add features onto the default set instead of replacing the default set?

Comment on lines +260 to +264
// generateNewSecret generates the secret for a new key of the given feature.
// keyDuration is the rotator's key duration; it is only used by features whose
// secret encodes its own validity window (currently only the NATS CA, whose
// certificate must outlive the key row's active-signer period).
func generateNewSecret(feature database.CryptoKeyFeature, startsAt time.Time, keyDuration time.Duration) (string, error) {

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

should the time be optional/a pointer, or should we have a new generateSecretWithStartsAt?

Comment on lines +141 to +149
// The old CA must remain a valid trust root for the maximum leaf
// lifetime after rotation.
expectedDeletesAt := oldKey.ExpiresAt(keyDuration).Add(NATSCALeafValidity + time.Hour)
oldKey, err = db.GetCryptoKeyByFeatureAndSequence(ctx, database.GetCryptoKeyByFeatureAndSequenceParams{
Feature: oldKey.Feature,
Sequence: oldKey.Sequence,
})
require.NoError(t, err)
require.Equal(t, expectedDeletesAt, oldKey.DeletesAt.Time.UTC())

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

this will have to change with rotation validity times as well

Comment thread coderd/x/nats/cluster.go Outdated
Comment on lines +52 to +65
// SetClusterCA swaps the cluster mTLS CA cache and this replica's leaf IP SAN,
// then triggers a peer refresh so any route blocked by the previous (for
// example noop) cache is retried. It is a no-op unless the pubsub was started
// with cluster TLS enabled (Options.ClusterCA set, which installs the TLS
// callbacks). Passing a noop cache reverts to no mTLS: new route handshakes
// can no longer mint a leaf and will not form.
func (p *Pubsub) SetClusterCA(ca ClusterCAKeycache, ip net.IP) {
if p.clusterTLS == nil {
return
}
p.clusterTLS.setClusterCA(ca, ip)
p.RefreshPeers()
}

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

should we rename this to EnableClusterMTLS, and potentially have a Disable that resets the cache to the noop cache?

Comment thread coderd/x/nats/tls.go Outdated
Comment on lines +25 to +29
// leafCertValidity is the lifetime of an ephemeral cluster leaf
// certificate. Leaves are re-minted before expiry and whenever the active
// CA rotates, so this can be well under the CA's own validity window
// (cryptokeys.NATSCALeafValidity).
leafCertValidity = 24 * time.Hour

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

this should be min(24*time.hour, CA cert validity -1h) or something similar

Comment thread coderd/x/nats/tls.go Outdated
Comment on lines +51 to +55
type ClusterCAKeycache interface {
SigningKey(ctx context.Context) (id string, key interface{}, err error)
VerifyingKey(ctx context.Context, id string) (key interface{}, err error)
}

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

do we need to use a separate interface here?

@cstyan
cstyan force-pushed the callum/nats-mtls-functions branch from 50306a4 to 071f40f Compare July 7, 2026 17:02
@cstyan
cstyan requested a review from sreya July 7, 2026 17:35

@sreya sreya left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

  • Do we need any verification whether the peer IP is a valid member of the replicas table in addition to just the IP SAN check? This would allow for dial side verification in addition to the existing accepting side verification
    Yeah I think we should

  • Currently we just shoehorn a sequence number for the CA certs into the x509 subject serial number, is this reasonable or should we use a custom extension? this would be relatively simple, mostly just requiring a bit of glue code plus some parsing function
    I think its fine as-is

  • right now we don't have warn/error logging of issues such as handshake failures/invalid leaf keys/etc. as it could be too noisy without a log deduper, should we use a metric instead?
    I think logging is fine, we consider these real errors right? I don't think we're presuming these are going to occur frequently and transiently over the lifespan of a replica.

Comment thread coderd/x/nats/tls.go Outdated
Comment thread cli/server.go
Comment thread enterprise/coderd/coderd.go Outdated
Comment thread coderd/database/modelmethods.go Outdated
Comment thread coderd/x/nats/cluster.go Outdated
Comment thread coderd/x/nats/tls.go Outdated
Comment thread coderd/x/nats/tls.go Outdated
Comment thread coderd/x/nats/tls.go Outdated
Comment thread coderd/x/nats/tls.go Outdated
Comment thread coderd/x/nats/tls_integration_internal_test.go Outdated
cstyan added 2 commits July 8, 2026 21:15
A cluster route leaf only authenticates a handshake, so it needs no independent lifetime: its NotAfter now tracks the signing CA's, and re-minting is driven purely by CA rotation.
Accept-side verification now requires the connection source IP to be one of this replica's configured cluster-route peers (from the replicas table via the NATS peer fetcher) in addition to matching the peer leaf's IP SAN.
@cstyan

cstyan commented Jul 8, 2026

Copy link
Copy Markdown
Contributor Author
  • I think logging is fine, we consider these real errors right? I don't think we're presuming these are going to occur frequently and transiently over the lifespan of a replica.

In the case of a connection error, it would be frequently as IIUC we attempt a reconnection on a 1s timer (with some amount of jitter). It's still not too much volume, given the replica count we have, so if you still think these are okay to move to warn/error I'll do so. I think a metric makes sense to have as well or instead of moving the logging from debug level.

cstyan added 7 commits July 8, 2026 22:15
Drop the redundant ClusterCAKeycache interface in favor of cryptokeys.SigningKeycache, remove the unused CryptoKey.DecodeString method, merge the NATS TLS integration test into the internal test file, and note the CA-cache boot refactor as a TODO.
Derive the leaf IP SAN and accept-side binding from the replica's ClusterHost (the CODER_CLUSTER_HOST argument) fixed at construction, replacing the DERP-relay-URL source, and rename Pubsub.SetClusterCA to SetCACache now that it only swaps the cache.
Reuse the cached leaf while it is still within its validity window before consulting the signing cache, dropping the now-redundant leafSeq field. Keep verify and currentLeaf as pure functions returning wrapped errors, and log at the tls.Config callback sites where the embedded NATS server would otherwise swallow them.
The startup peer refresh runs once with the boot-time noop fetcher and can race a manual setPeerAddresses call, wiping the route and known-peer set; with fail-closed membership that made the route-forming tests flaky, so drive their peers through fetchers as production does.
It is the only clock field on Options, so the shorter name is unambiguous.
…etcher

Drop configureNATSClusterTLS so SetCACache sits beside SetPeerFetcher in the HA enable block, moving only the mTLS-status logging into a small helper.
…tests

Production keeps the NATS default (2s); the longer 10s timeout that avoids flaky handshakes under load and in CI is now set through a test-only Options field.
@cstyan
cstyan requested a review from sreya July 9, 2026 17:03
Comment thread cli/server.go Outdated
// ClusterHost is this replica's routable cluster address. It
// is the NATS route listener host and, when it is an IP, the
// leaf certificate's IP SAN for cluster mTLS.
ClusterHost: options.DeploymentValues.Cluster.Host.String(),

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

We technically fallback to the DERP host if Cluster Host is unset. We should just move that code into cli/server.go instead of doing it enterprise/cli to prevent this divergence. It's technically an enterprise concept but we gate that functionality in the entitlements loop so we aren't leaking enterprise features into open source.

Comment thread enterprise/coderd/coderd.go Outdated
Comment thread coderd/x/nats/tls.go
Comment thread coderd/x/nats/cluster.go Outdated
cstyan added 4 commits July 9, 2026 18:23
Cluster route admission now relies on CA-chain verification and the leaf IP-SAN to source-IP binding; the extra check that the source is a current replicas-table member (and its peerIP plumbing) is removed.
… set

Emit the mTLS enabled/disabled/inactive log from setCACache, the point where the state transition actually happens, instead of from the enterprise HA wiring.
The accept side now fails closed when it cannot determine the peer's source IP, instead of leaving it nil and silently skipping the leaf SAN to source-IP binding in verify.
Move the Cluster.Host to DERP-relay-host fallback into cli/server.go behind a new coderd.Options.ClusterHost so the NATS pubsub, leaf SAN, and replicas table all use the same resolved value instead of the enterprise-only resolution diverging from AGPL.
@cstyan
cstyan requested a review from sreya July 9, 2026 23:32
@cstyan
cstyan merged commit ad29777 into main Jul 13, 2026
32 of 33 checks passed
@cstyan
cstyan deleted the callum/nats-mtls-functions branch July 13, 2026 18:00
@github-actions github-actions Bot locked and limited conversation to collaborators Jul 13, 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.

2 participants