Thanks to visit codestin.com
Credit goes to github.com

Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
16 commits
Select commit Hold shift + click to select a range
ec148d8
feat(coderd/cryptokeys): add and serve the nats_ca cluster CA
cstyan Jun 30, 2026
f24b480
feat(coderd/x/nats): mutual TLS for the embedded NATS cluster
cstyan Jun 30, 2026
071f40f
feat(coderd): setup for constrained rotation overlap timings
cstyan Jul 7, 2026
227f3e3
feat(coderd/x/nats): derive cluster leaf validity from its signing CA
cstyan Jul 8, 2026
e92ab37
feat(coderd/x/nats): reject cluster routes from unknown replica IPs
cstyan Jul 8, 2026
ec55337
refactor(coderd): tidy up NATS cluster mTLS after review
cstyan Jul 8, 2026
8229557
feat(coderd): source NATS cluster mTLS identity from cluster host
cstyan Jul 8, 2026
8f49739
refactor(coderd/x/nats): check cached cluster leaf before minting
cstyan Jul 9, 2026
980f3cd
test(coderd/x/nats): drive cluster TLS route tests through peer fetchers
cstyan Jul 9, 2026
7e00f75
refactor(coderd/x/nats): rename Options.clusterTLSClock to clock
cstyan Jul 9, 2026
60f988b
refactor(enterprise/coderd): set nats CA cache inline like the peer f…
cstyan Jul 9, 2026
382e518
refactor(coderd/x/nats): only relax cluster TLS handshake timeout in …
cstyan Jul 9, 2026
30617cc
refactor(coderd/x/nats): drop accept-side replica-membership check
cstyan Jul 9, 2026
636b238
refactor(coderd/x/nats): log cluster mTLS state where the CA cache is…
cstyan Jul 9, 2026
41215e2
fix(coderd/x/nats): reject cluster route handshakes with no source IP
cstyan Jul 9, 2026
af38308
refactor(cli): resolve cluster host with DERP fallback in AGPL server
cstyan Jul 9, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
29 changes: 29 additions & 0 deletions cli/server.go
Original file line number Diff line number Diff line change
Expand Up @@ -66,6 +66,7 @@ import (
"github.com/coder/coder/v2/coderd/aibridged"
"github.com/coder/coder/v2/coderd/authlink"
"github.com/coder/coder/v2/coderd/autobuild"
"github.com/coder/coder/v2/coderd/cryptokeys"
"github.com/coder/coder/v2/coderd/database"
"github.com/coder/coder/v2/coderd/database/awsiamrds"
"github.com/coder/coder/v2/coderd/database/dbauthz"
Expand Down Expand Up @@ -729,13 +730,23 @@ func (r *RootCmd) Server(newAPI func(context.Context, *coderd.Options) (*coderd.
return xerrors.Errorf("parse real ip config: %w", err)
}

// Resolve this replica's cluster host: the explicit Cluster.Host,
// else the DERP relay host for older HA deployments that predate the
// setting. Used as the NATS cluster route host and, when an IP, the
// cluster mTLS leaf IP SAN.
clusterHost := vals.Cluster.Host.String()
if clusterHost == "" {
clusterHost = vals.DERP.Server.RelayURL.Value().Hostname()
}

options := &coderd.Options{
AccessURL: vals.AccessURL.Value(),
AppHostname: appHostname,
AppHostnameRegex: appHostnameRegex,
Logger: logger.Named("coderd"),
Database: nil,
BaseDERPMap: derpMap,
ClusterHost: clusterHost,
Pubsub: nil,
CacheDir: cacheDir,
GoogleTokenValidator: googleTokenValidator,
Expand Down Expand Up @@ -846,6 +857,24 @@ func (r *RootCmd) Server(newAPI func(context.Context, *coderd.Options) (*coderd.
token := fmt.Sprintf("%x", sha256.Sum256([]byte(dbURL)))
natsps, err := nats.New(ctx, logger.Named("nats_pubsub"), nats.Options{
ClusterAuthToken: token,
// ClusterHost is this replica's routable cluster address
// (Cluster.Host, or the DERP relay host fallback resolved
// above). It is the NATS route listener host and, when it is
// an IP, the leaf certificate's IP SAN for cluster mTLS.
ClusterHost: options.ClusterHost,
// Install the cluster TLS callbacks with a noop CA cache so a
// single node (or pre-license deployment) boots without a CA
// dependency and forms no routes. Enterprise HA swaps in the
// real nats_ca cache via Pubsub.SetCACache once clustering is
// licensed.
//
// TODO: the real CA cache cannot be built here because
// options.Database is not yet fully instantiated (it is
// wrapped with metrics/dbauthz downstream). This split boot
// (noop here, real cache swapped in by enterprise) wants a
// refactor so the CA cache can be constructed once alongside
// the database.
ClusterCA: cryptokeys.NoopSigningKeycache{},
Comment thread
cstyan marked this conversation as resolved.
})
if err != nil {
return xerrors.Errorf("create nats pubsub: %w", err)
Expand Down
6 changes: 4 additions & 2 deletions coderd/apidoc/docs.go

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

6 changes: 4 additions & 2 deletions coderd/apidoc/swagger.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

43 changes: 41 additions & 2 deletions coderd/coderd.go
Original file line number Diff line number Diff line change
Expand Up @@ -205,6 +205,13 @@ type Options struct {
TLSCertificates []tls.Certificate
TailnetCoordinator tailnet.Coordinator
DERPServer *derp.Server
// ClusterHost is this replica's routable cluster address (IP or hostname),
// resolved from DeploymentValues.Cluster.Host, falling back to the DERP
// relay host for older HA deployments that predate the setting. It is used
// as the NATS cluster route host and, when it is an IP, the cluster mTLS
// leaf IP SAN. It is consumed by the NATS pubsub (AGPL) and, under
// enterprise HA, by replicasync.
ClusterHost string
// BaseDERPMap is used as the base DERP map for all clients and agents.
// Proxies are added to this list.
BaseDERPMap *tailcfg.DERPMap
Expand Down Expand Up @@ -305,7 +312,12 @@ type Options struct {
AppSigningKeyCache cryptokeys.SigningKeycache
AppEncryptionKeyCache cryptokeys.EncryptionKeycache
OIDCConvertKeyCache cryptokeys.SigningKeycache
Clock quartz.Clock
// NATSCACache serves the NATS cluster mTLS CA via the generic signing key
// cache for the nats_ca feature. SigningKey returns the active CA
// (a *NATSCA); VerifyingKey returns a specific CA by sequence. The key
// rotator is the sole creator of nats_ca rows, so this cache is read-only.
NATSCACache cryptokeys.SigningKeycache
Clock quartz.Clock

// WebPushDispatcher is a way to send notifications over Web Push.
WebPushDispatcher webpush.Dispatcher
Expand Down Expand Up @@ -607,10 +619,34 @@ func New(options *Options) *API {

updatesProvider := NewUpdatesProvider(options.Logger.Named("workspace_updates"), options.Pubsub, options.Database, options.Authorizer)

// The NATS cluster CA is only minted and served when NATS pubsub is in use.
// It is experiment-gated, so it is opted into rotation and backed by a real
// signing cache only when the experiment is enabled; otherwise the rotator
// leaves it alone and the cache is a noop, which still answers requests (the
// pubsub treats a missing CA as "mTLS off"). This avoids minting CA private
// keys on deployments that never run NATS clustering.
rotatedFeatures := cryptokeys.DefaultRotatedFeatures()
if experiments.Enabled(codersdk.ExperimentNATSPubsub) {
rotatedFeatures = append(rotatedFeatures, database.CryptoKeyFeatureNATSCA)
}

// Start a background process that rotates keys. We intentionally start this after the caches
// are created to force initial requests for a key to populate the caches. This helps catch
// bugs that may only occur when a key isn't precached in tests and the latency cost is minimal.
cryptokeys.StartRotator(ctx, options.Logger, options.Database)
cryptokeys.StartRotator(ctx, options.Logger, options.Database, cryptokeys.WithFeatures(rotatedFeatures))

// The NATS CA cache is read-only and depends on the rotator having minted
// the nats_ca CA, so it must be constructed after StartRotator.
if options.NATSCACache == nil {
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 instantiate NATS CA cache", slog.Error(err))
}
} else {
options.NATSCACache = cryptokeys.NoopSigningKeycache{}
}
}

// Ensure all system role permissions are current.
//nolint:gocritic // Startup reconciliation reads/writes system roles. There is
Expand Down Expand Up @@ -2398,6 +2434,9 @@ func (api *API) Close() error {
_ = api.OIDCConvertKeyCache.Close()
_ = api.AppSigningKeyCache.Close()
_ = api.AppEncryptionKeyCache.Close()
if api.NATSCACache != nil {
_ = api.NATSCACache.Close()
}
_ = api.UpdatesProvider.Close()
api.workspaceAgentConnWatcher.Close()

Expand Down
149 changes: 149 additions & 0 deletions coderd/cryptokeys/ca.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,149 @@
package cryptokeys

import (
"crypto"
"crypto/ecdsa"
"crypto/elliptic"
"crypto/rand"
"crypto/x509"
"crypto/x509/pkix"
"encoding/pem"
"math/big"
"time"

"golang.org/x/xerrors"
)

const (
caCertPEMBlockType = "CERTIFICATE"
caKeyPEMBlockType = "EC PRIVATE KEY"

// clockSkewTolerance backdates the CA certificate's NotBefore and extends
// its NotAfter so that replicas with mildly skewed clocks still accept it.
clockSkewTolerance = time.Hour
)

// NATSCA is the decoded form of a single nats_ca crypto key row, produced by
// the generic crypto key cache (see idSecret). The CA signs the ephemeral leaf
// certificates that replicas use for NATS cluster mTLS.
//
// The active CA is served by a SigningKeycache.SigningKey call for the nats_ca
// feature; a specific historical CA (for verifying a peer leaf minted under an
// earlier CA during a rotation overlap) is served by VerifyingKey with that
// row's sequence.
type NATSCA struct {
// Sequence is the crypto_keys sequence of the row this CA came from.
Sequence int32
// Cert is the CA certificate used to sign or verify leaf certificates.
Cert *x509.Certificate
// Key is the CA private key, used to sign leaves.
Key crypto.Signer
}

// generateCASecret generates a new self-signed CA certificate and private key
// for signing NATS cluster leaf certificates, PEM-encoded into a single
// bundle for storage in the crypto_keys secret column.
//
// anchorTime is the key row's starts_at (which may be in the future for a
// rotated-in key). keyDuration is the rotator's key duration: the row stays the
// active signer for that long. The certificate stays valid for NATSCAOverlap
// past that window so that, once the next CA becomes the active signer, this CA
// is still valid while replicas' key caches refresh onto the new one. Leaves
// are separately clamped to expire before this NotAfter (see coderd/x/nats
// mintLeaf), so the overlap only needs to cover the cache-refresh transition.
func generateCASecret(anchorTime time.Time, keyDuration time.Duration) (string, error) {
key, err := ecdsa.GenerateKey(elliptic.P256(), rand.Reader)
if err != nil {
return "", xerrors.Errorf("generate key: %w", err)
}

// 128-bit random serial per CA/Browser Forum conventions.
serial, err := rand.Int(rand.Reader, new(big.Int).Lsh(big.NewInt(1), 128))
if err != nil {
return "", xerrors.Errorf("generate serial: %w", err)
}

template := &x509.Certificate{
SerialNumber: serial,
Subject: pkix.Name{
CommonName: "coder-nats-ca",
},
NotBefore: anchorTime.Add(-clockSkewTolerance),
NotAfter: anchorTime.Add(keyDuration + NATSCAOverlap),
KeyUsage: x509.KeyUsageCertSign,
BasicConstraintsValid: true,
IsCA: true,
MaxPathLenZero: true,
}

der, err := x509.CreateCertificate(rand.Reader, template, template, key.Public(), key)
if err != nil {
return "", xerrors.Errorf("create certificate: %w", err)
}

keyDER, err := x509.MarshalECPrivateKey(key)
if err != nil {
return "", xerrors.Errorf("marshal private key: %w", err)
}

var secret []byte
secret = append(secret, pem.EncodeToMemory(&pem.Block{Type: caCertPEMBlockType, Bytes: der})...)
secret = append(secret, pem.EncodeToMemory(&pem.Block{Type: caKeyPEMBlockType, Bytes: keyDER})...)
return string(secret), nil
}

// parseCASecret parses a PEM bundle produced by generateCASecret back into
// the CA certificate and private key.
func parseCASecret(secret string) (*x509.Certificate, crypto.Signer, error) {
var (
cert *x509.Certificate
key *ecdsa.PrivateKey
)
rest := []byte(secret)
for {
var block *pem.Block
block, rest = pem.Decode(rest)
if block == nil {
break
}
switch block.Type {
case caCertPEMBlockType:
if cert != nil {
return nil, nil, xerrors.New("multiple certificates in CA secret")
}
var err error
cert, err = x509.ParseCertificate(block.Bytes)
if err != nil {
return nil, nil, xerrors.Errorf("parse certificate: %w", err)
}
case caKeyPEMBlockType:
if key != nil {
return nil, nil, xerrors.New("multiple private keys in CA secret")
}
var err error
key, err = x509.ParseECPrivateKey(block.Bytes)
if err != nil {
return nil, nil, xerrors.Errorf("parse private key: %w", err)
}
default:
return nil, nil, xerrors.Errorf("unexpected PEM block type: %q", block.Type)
}
}
if cert == nil {
return nil, nil, xerrors.New("no certificate in CA secret")
}
if key == nil {
return nil, nil, xerrors.New("no private key in CA secret")
}
if !key.PublicKey.Equal(cert.PublicKey) {
return nil, nil, xerrors.New("private key does not match certificate")
}
// Reject a structurally valid bundle whose certificate cannot act as a
// signing CA. Without this, a corrupted secret could yield a non-CA cert
// that silently becomes the active signer; leaves signed under it would
// then fail x509 verification on every replica.
if !cert.IsCA || !cert.BasicConstraintsValid || cert.KeyUsage&x509.KeyUsageCertSign == 0 {
return nil, nil, xerrors.New("certificate is not a valid signing CA")
}
return cert, key, nil
}
Loading
Loading