diff --git a/deploy/tls/.gitignore b/deploy/tls/.gitignore new file mode 100644 index 00000000..9609bc6f --- /dev/null +++ b/deploy/tls/.gitignore @@ -0,0 +1,6 @@ +# Private keys and the deploy-specific server certificate never go to git. +# Only the public CA certificate (ca.crt) is versioned, because clients embed it. +*.key +*.csr +*.srl +server.crt diff --git a/deploy/tls/README.md b/deploy/tls/README.md new file mode 100644 index 00000000..b4d42984 --- /dev/null +++ b/deploy/tls/README.md @@ -0,0 +1,56 @@ +# Bus TLS — self-signed CA and server certificate + +The unibus data plane (NATS) is encrypted with TLS using the project's own +self-signed CA. The bus is exposed publicly, protected by auth + TLS, so the CA +is private (not Let's Encrypt) and every client we control embeds the public +`ca.crt`; the server presents `server.crt`/`server.key`. + +## Files + +| File | Secret? | Goes where | +|---|---|---| +| `ca.crt` | no (public) | versioned in git; embedded/distributed to every client | +| `ca.key` | **yes** | stays on the machine that mints certs; gitignored | +| `server.crt` | no | deployed to the bus host; gitignored (deploy-specific SANs) | +| `server.key` | **yes** | deployed to the bus host over a secure channel; gitignored | + +Only `ca.crt` is committed. `ca.key`, `server.key`, `server.crt`, and any +`*.csr`/`*.srl` are gitignored — see `.gitignore`. + +## Generate + +```bash +cd deploy/tls +./generate-certs.sh # CA (if missing) + server cert with default SANs +./generate-certs.sh --force # also regenerate the CA (invalidates pinned clients) +``` + +The server certificate's SANs cover the public IP, the WireGuard IP, the om +hostname, plus `localhost`/`127.0.0.1` for on-host smoke tests. Override the +defaults via environment variables: + +```bash +UNIBUS_PUBLIC_IP=135.125.201.30 UNIBUS_WG_IP=10.42.0.1 UNIBUS_HOSTNAME=om ./generate-certs.sh +``` + +Verify the SANs: + +```bash +openssl x509 -in server.crt -noout -text | grep -A1 'Subject Alternative Name' +``` + +## Use + +- **Server** (`membershipd`, phase 0001e): point it at `server.crt`/`server.key` + so the embedded NATS presents the certificate and requires TLS. Built with + `busauth.ServerTLSConfig(certPath, keyPath)`. +- **Clients** (Go peers, mobile binding, gateway): pin `ca.crt` with + `busauth.LoadCATLSConfig(caPath)` and pass the result as `client.Options.TLS`. + +## Rotation + +The CA is long-lived (10 years). Rotate the server certificate (825 days) by +re-running `generate-certs.sh` (without `--force`) and redeploying +`server.crt`/`server.key`; clients are unaffected because they pin the CA, not +the server cert. Rotating the CA (`--force`) requires redistributing `ca.crt` to +every client. diff --git a/deploy/tls/ca.crt b/deploy/tls/ca.crt new file mode 100644 index 00000000..305e35a5 --- /dev/null +++ b/deploy/tls/ca.crt @@ -0,0 +1,11 @@ +-----BEGIN CERTIFICATE----- +MIIBfTCCASOgAwIBAgIUW2HZJDDlixxw/DgNP/IDIrJ7MeMwCgYIKoZIzj0EAwIw +FDESMBAGA1UEAwwJdW5pYnVzLWNhMB4XDTI2MDYwNzEwNDIyNloXDTM2MDYwNDEw +NDIyNlowFDESMBAGA1UEAwwJdW5pYnVzLWNhMFkwEwYHKoZIzj0CAQYIKoZIzj0D +AQcDQgAEe2by5l9dcEbqKB11yJtPIH9S/01XNhuFnBB/IpDevO2fWLLV+muqoB8C +ADH1wKleq8jF5D0sSlK2DCuYrjAjPqNTMFEwHQYDVR0OBBYEFABX+UI7bXICRF4l +WmmDR/rUtxnrMB8GA1UdIwQYMBaAFABX+UI7bXICRF4lWmmDR/rUtxnrMA8GA1Ud +EwEB/wQFMAMBAf8wCgYIKoZIzj0EAwIDSAAwRQIgCAeOYTKvA6SBB8xMdMdqNrp1 +20OPyi2BwFovW6vTCLMCIQC1qRi8SGRHTui8BVqIvp/DFJaZ/U8ocAg/qedLdy+R +/w== +-----END CERTIFICATE----- diff --git a/deploy/tls/generate-certs.sh b/deploy/tls/generate-certs.sh new file mode 100755 index 00000000..a8a6a426 --- /dev/null +++ b/deploy/tls/generate-certs.sh @@ -0,0 +1,64 @@ +#!/usr/bin/env bash +# +# generate-certs.sh — mint the unibus bus's self-signed CA and the NATS server +# certificate. Run once on a trusted machine; distribute ca.crt to clients and +# server.crt/server.key to the bus host (server.key by a secure channel, never +# git). Re-running regenerates the server cert; pass --force to also regenerate +# the CA (which invalidates every client that pinned the old ca.crt). +# +# SANs cover the public IP, the WireGuard IP, the om hostname, plus localhost so +# the operator can smoke-test the TLS handshake on the box. Override via env: +# UNIBUS_PUBLIC_IP (default 135.125.201.30) +# UNIBUS_WG_IP (default 10.42.0.1) +# UNIBUS_HOSTNAME (default om) +# +# Key material: EC P-256 (widely supported by Go's crypto/tls and nats-server). +set -euo pipefail + +DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +cd "$DIR" + +PUBLIC_IP="${UNIBUS_PUBLIC_IP:-135.125.201.30}" +WG_IP="${UNIBUS_WG_IP:-10.42.0.1}" +HOSTNAME_OM="${UNIBUS_HOSTNAME:-om}" +DAYS_CA=3650 +DAYS_SRV=825 + +force=0 +[[ "${1:-}" == "--force" ]] && force=1 + +# --- CA (long-lived; only the cert is public) --- +if [[ ! -f ca.crt || ! -f ca.key || $force -eq 1 ]]; then + echo "==> generating CA" + openssl ecparam -name prime256v1 -genkey -noout -out ca.key + chmod 600 ca.key + openssl req -x509 -new -key ca.key -sha256 -days "$DAYS_CA" \ + -subj "/CN=unibus-ca" -out ca.crt +else + echo "==> reusing existing CA (pass --force to regenerate)" +fi + +# --- server certificate, signed by the CA, with the bus SANs --- +echo "==> generating server certificate (SAN: $PUBLIC_IP, $WG_IP, $HOSTNAME_OM, localhost, 127.0.0.1)" +openssl ecparam -name prime256v1 -genkey -noout -out server.key +chmod 600 server.key +openssl req -new -key server.key -subj "/CN=unibus-bus" -out server.csr + +cat > server.ext < done:" +echo " ca.crt -> embed/distribute to every client (public)" +echo " server.crt -> deploy to the bus host" +echo " server.key -> deploy to the bus host over a secure channel (NEVER git)" +echo +echo "verify SANs with:" +echo " openssl x509 -in server.crt -noout -text | grep -A1 'Subject Alternative Name'" diff --git a/pkg/busauth/tls.go b/pkg/busauth/tls.go new file mode 100644 index 00000000..47e4fcfd --- /dev/null +++ b/pkg/busauth/tls.go @@ -0,0 +1,37 @@ +package busauth + +import ( + "crypto/tls" + "crypto/x509" + "fmt" + "os" +) + +// LoadCATLSConfig builds a *tls.Config that trusts ONLY the given CA certificate +// (PEM file), for a bus client pinning the project's self-signed CA. Because the +// bus uses a private CA rather than a public one, clients must pin it explicitly; +// trusting the system roots would reject the server cert. This is the single +// helper every client (Go peers, the mobile binding, the gateway) uses to turn a +// ca.crt path into a connection config. +func LoadCATLSConfig(caPEMPath string) (*tls.Config, error) { + pem, err := os.ReadFile(caPEMPath) + if err != nil { + return nil, fmt.Errorf("busauth: read CA %q: %w", caPEMPath, err) + } + pool := x509.NewCertPool() + if !pool.AppendCertsFromPEM(pem) { + return nil, fmt.Errorf("busauth: CA %q contains no valid PEM certificate", caPEMPath) + } + return &tls.Config{RootCAs: pool, MinVersion: tls.VersionTLS12}, nil +} + +// ServerTLSConfig loads the bus NATS server's certificate and private key (PEM +// files) into a *tls.Config to present to clients. The private key never leaves +// the host; only the CA cert travels to clients. +func ServerTLSConfig(certPEMPath, keyPEMPath string) (*tls.Config, error) { + cert, err := tls.LoadX509KeyPair(certPEMPath, keyPEMPath) + if err != nil { + return nil, fmt.Errorf("busauth: load server keypair: %w", err) + } + return &tls.Config{Certificates: []tls.Certificate{cert}, MinVersion: tls.VersionTLS12}, nil +} diff --git a/pkg/busauth/tls_test.go b/pkg/busauth/tls_test.go new file mode 100644 index 00000000..0b94fccf --- /dev/null +++ b/pkg/busauth/tls_test.go @@ -0,0 +1,95 @@ +package busauth + +import ( + "crypto/ecdsa" + "crypto/elliptic" + "crypto/rand" + "crypto/x509" + "crypto/x509/pkix" + "encoding/pem" + "math/big" + "os" + "path/filepath" + "testing" + "time" +) + +// writeSelfSigned writes a self-signed cert + key PEM pair to dir and returns +// their paths. It is enough to exercise both LoadCATLSConfig (reads the cert as +// a CA) and ServerTLSConfig (reads the cert+key as a server keypair). +func writeSelfSigned(t *testing.T, dir string) (certPath, keyPath string) { + t.Helper() + key, err := ecdsa.GenerateKey(elliptic.P256(), rand.Reader) + if err != nil { + t.Fatalf("key: %v", err) + } + tmpl := &x509.Certificate{ + SerialNumber: big.NewInt(1), + Subject: pkix.Name{CommonName: "unibus-tls-test"}, + NotBefore: time.Now().Add(-time.Hour), + NotAfter: time.Now().Add(time.Hour), + IsCA: true, + KeyUsage: x509.KeyUsageCertSign | x509.KeyUsageDigitalSignature, + BasicConstraintsValid: true, + } + der, err := x509.CreateCertificate(rand.Reader, tmpl, tmpl, &key.PublicKey, key) + if err != nil { + t.Fatalf("cert: %v", err) + } + certPath = filepath.Join(dir, "cert.pem") + keyPath = filepath.Join(dir, "key.pem") + certPEM := pem.EncodeToMemory(&pem.Block{Type: "CERTIFICATE", Bytes: der}) + if err := os.WriteFile(certPath, certPEM, 0o644); err != nil { + t.Fatalf("write cert: %v", err) + } + keyDER, err := x509.MarshalECPrivateKey(key) + if err != nil { + t.Fatalf("marshal key: %v", err) + } + keyPEM := pem.EncodeToMemory(&pem.Block{Type: "EC PRIVATE KEY", Bytes: keyDER}) + if err := os.WriteFile(keyPath, keyPEM, 0o600); err != nil { + t.Fatalf("write key: %v", err) + } + return certPath, keyPath +} + +// Golden: a valid CA PEM loads into a config with a non-empty RootCAs pool, and +// a valid keypair loads into a config presenting one certificate. +func TestLoadTLSConfigsGolden(t *testing.T) { + dir := t.TempDir() + certPath, keyPath := writeSelfSigned(t, dir) + + caCfg, err := LoadCATLSConfig(certPath) + if err != nil { + t.Fatalf("LoadCATLSConfig: %v", err) + } + if caCfg.RootCAs == nil { + t.Fatalf("expected a populated RootCAs pool") + } + + srvCfg, err := ServerTLSConfig(certPath, keyPath) + if err != nil { + t.Fatalf("ServerTLSConfig: %v", err) + } + if len(srvCfg.Certificates) != 1 { + t.Fatalf("expected exactly one server certificate, got %d", len(srvCfg.Certificates)) + } +} + +// Error path: missing file, and a file that is not valid PEM. +func TestLoadTLSConfigsErrors(t *testing.T) { + if _, err := LoadCATLSConfig("/no/such/ca.crt"); err == nil { + t.Fatalf("expected error for missing CA file") + } + dir := t.TempDir() + junk := filepath.Join(dir, "junk.crt") + if err := os.WriteFile(junk, []byte("not a pem"), 0o644); err != nil { + t.Fatalf("write junk: %v", err) + } + if _, err := LoadCATLSConfig(junk); err == nil { + t.Fatalf("expected error for non-PEM CA file") + } + if _, err := ServerTLSConfig("/no/such/server.crt", "/no/such/server.key"); err == nil { + t.Fatalf("expected error for missing server keypair") + } +} diff --git a/pkg/client/client.go b/pkg/client/client.go index 3d84db20..8e3a878c 100644 --- a/pkg/client/client.go +++ b/pkg/client/client.go @@ -16,6 +16,7 @@ import ( "bytes" "context" "crypto/rand" + "crypto/tls" "encoding/base64" "encoding/hex" "encoding/json" @@ -66,6 +67,10 @@ type Options struct { // with an nkey to a server that does not advertise nkey auth ("nkeys not // supported by the server"), so this is opt-in rather than always-on. UseNkey bool + // TLS, when non-nil, secures the NATS connection and pins the server to this + // config's RootCAs (the bus's self-signed CA). Build it with + // busauth.LoadCATLSConfig(caPath). Nil keeps the connection plaintext. + TLS *tls.Config } // New connects to NATS and records the control-plane URL with default Options @@ -87,6 +92,9 @@ func NewWithOptions(natsURL, ctrlURL string, id cs.Identity, opts Options) (*Cli } natsOpts = append(natsOpts, nats.Nkey(nkeyPub, nkeySign)) } + if opts.TLS != nil { + natsOpts = append(natsOpts, nats.Secure(opts.TLS)) + } nc, err := nats.Connect(natsURL, natsOpts...) if err != nil { return nil, fmt.Errorf("client: connect nats %q: %w", natsURL, err) diff --git a/pkg/client/client_test.go b/pkg/client/client_test.go index 00d199cb..8a4745c8 100644 --- a/pkg/client/client_test.go +++ b/pkg/client/client_test.go @@ -1,6 +1,7 @@ package client_test import ( + "crypto/tls" "encoding/hex" "net" "net/http" @@ -59,6 +60,12 @@ func newHarnessMode(t *testing.T, mode membership.AuthMode) *testHarness { // flags compose. The store is created before NATS so the authenticator can // consult IsAuthorized for live revocation. func newHarnessFull(t *testing.T, ctrlMode membership.AuthMode, natsAuth bool) *testHarness { + return bootHarness(t, ctrlMode, natsAuth, nil) +} + +// bootHarness is the shared body: a store, an embedded NATS (optionally with the +// nkey authenticator and/or TLS), and the membershipd HTTP server in ctrlMode. +func bootHarness(t *testing.T, ctrlMode membership.AuthMode, natsAuth bool, natsTLS *tls.Config) *testHarness { t.Helper() dir := t.TempDir() @@ -67,13 +74,16 @@ func newHarnessFull(t *testing.T, ctrlMode membership.AuthMode, natsAuth bool) * t.Fatalf("membership store: %v", err) } - var ns *server.Server - if natsAuth { - ns, err = embeddednats.StartHostAuth(filepath.Join(dir, "js"), "127.0.0.1", freePort(t), - busauth.NewNkeyAuthenticator(store.IsAuthorized)) - } else { - ns, err = embeddednats.Start(filepath.Join(dir, "js"), freePort(t)) + cfg := embeddednats.ServerConfig{ + StoreDir: filepath.Join(dir, "js"), + Host: "127.0.0.1", + Port: freePort(t), + TLS: natsTLS, } + if natsAuth { + cfg.Auth = busauth.NewNkeyAuthenticator(store.IsAuthorized) + } + ns, err := embeddednats.StartServer(cfg) if err != nil { store.Close() t.Fatalf("embedded nats: %v", err) diff --git a/pkg/client/tls_test.go b/pkg/client/tls_test.go new file mode 100644 index 00000000..236168a7 --- /dev/null +++ b/pkg/client/tls_test.go @@ -0,0 +1,113 @@ +package client_test + +import ( + "crypto/ecdsa" + "crypto/elliptic" + "crypto/rand" + "crypto/tls" + "crypto/x509" + "crypto/x509/pkix" + "encoding/pem" + "math/big" + "net" + "testing" + "time" + + "github.com/enmanuel/unibus/pkg/client" + "github.com/enmanuel/unibus/pkg/membership" + "github.com/enmanuel/unibus/pkg/room" +) + +// genTestCA mints a throwaway self-signed CA plus a server certificate (SAN +// 127.0.0.1 / localhost) signed by it, mirroring deploy/tls/generate-certs.sh +// without shelling out to openssl. It returns the server's *tls.Config (cert it +// presents) and the CA pool a client must trust to complete the handshake. +func genTestCA(t *testing.T) (server *tls.Config, caPool *x509.CertPool) { + t.Helper() + + // --- CA --- + caKey, err := ecdsa.GenerateKey(elliptic.P256(), rand.Reader) + if err != nil { + t.Fatalf("ca key: %v", err) + } + caTmpl := &x509.Certificate{ + SerialNumber: big.NewInt(1), + Subject: pkix.Name{CommonName: "unibus-test-ca"}, + NotBefore: time.Now().Add(-time.Hour), + NotAfter: time.Now().Add(24 * time.Hour), + IsCA: true, + KeyUsage: x509.KeyUsageCertSign | x509.KeyUsageDigitalSignature, + BasicConstraintsValid: true, + } + caDER, err := x509.CreateCertificate(rand.Reader, caTmpl, caTmpl, &caKey.PublicKey, caKey) + if err != nil { + t.Fatalf("ca cert: %v", err) + } + caCert, err := x509.ParseCertificate(caDER) + if err != nil { + t.Fatalf("parse ca: %v", err) + } + + // --- server cert signed by the CA --- + srvKey, err := ecdsa.GenerateKey(elliptic.P256(), rand.Reader) + if err != nil { + t.Fatalf("server key: %v", err) + } + srvTmpl := &x509.Certificate{ + SerialNumber: big.NewInt(2), + Subject: pkix.Name{CommonName: "unibus-test-server"}, + NotBefore: time.Now().Add(-time.Hour), + NotAfter: time.Now().Add(24 * time.Hour), + KeyUsage: x509.KeyUsageDigitalSignature, + ExtKeyUsage: []x509.ExtKeyUsage{x509.ExtKeyUsageServerAuth}, + DNSNames: []string{"localhost"}, + IPAddresses: []net.IP{net.IPv4(127, 0, 0, 1)}, + } + srvDER, err := x509.CreateCertificate(rand.Reader, srvTmpl, caCert, &srvKey.PublicKey, caKey) + if err != nil { + t.Fatalf("server cert: %v", err) + } + + srvCertPEM := pem.EncodeToMemory(&pem.Block{Type: "CERTIFICATE", Bytes: srvDER}) + srvKeyDER, err := x509.MarshalECPrivateKey(srvKey) + if err != nil { + t.Fatalf("marshal server key: %v", err) + } + srvKeyPEM := pem.EncodeToMemory(&pem.Block{Type: "EC PRIVATE KEY", Bytes: srvKeyDER}) + srvPair, err := tls.X509KeyPair(srvCertPEM, srvKeyPEM) + if err != nil { + t.Fatalf("server keypair: %v", err) + } + + pool := x509.NewCertPool() + pool.AddCert(caCert) + return &tls.Config{Certificates: []tls.Certificate{srvPair}, MinVersion: tls.VersionTLS12}, pool +} + +// TestNatsTLS validates the TLS data plane: a client trusting the bus CA +// completes the handshake and uses the bus (golden); a client that does NOT +// trust the CA fails the handshake (error path). +func TestNatsTLS(t *testing.T) { + serverTLS, caPool := genTestCA(t) + h := bootHarness(t, membership.AuthOff, false, serverTLS) + waitHealth(t, h.ctrlURL) + + // Golden: client pinning the CA connects over TLS and operates. + clientTLS := &tls.Config{RootCAs: caPool, MinVersion: tls.VersionTLS12} + a, err := client.NewWithOptions(h.natsURL, h.ctrlURL, mustIdentity(t), client.Options{TLS: clientTLS}) + if err != nil { + t.Fatalf("client trusting the CA should complete the TLS handshake: %v", err) + } + defer a.Close() + if _, err := a.CreateRoom("room.tls", room.ModeNATS); err != nil { + t.Fatalf("TLS client should operate on the bus: %v", err) + } + + // Error path: a client that does not trust the CA fails the handshake. Use an + // empty pool (system roots would also reject this private CA, but an empty + // pool makes the intent explicit and avoids depending on the host's roots). + badTLS := &tls.Config{RootCAs: x509.NewCertPool(), MinVersion: tls.VersionTLS12} + if _, err := client.NewWithOptions(h.natsURL, h.ctrlURL, mustIdentity(t), client.Options{TLS: badTLS}); err == nil { + t.Fatalf("client without the CA must fail the TLS handshake") + } +} diff --git a/pkg/embeddednats/embeddednats.go b/pkg/embeddednats/embeddednats.go index 7291fdad..114352ad 100644 --- a/pkg/embeddednats/embeddednats.go +++ b/pkg/embeddednats/embeddednats.go @@ -6,22 +6,33 @@ package embeddednats import ( + "crypto/tls" "fmt" "time" server "github.com/nats-io/nats-server/v2/server" ) -// Start launches an embedded nats-server with JetStream enabled, listening on -// the given port and persisting JetStream state under storeDir. The listen host -// is left at the nats-server default ("0.0.0.0", all interfaces). It blocks -// until the server is ready to accept connections (up to 5s) and returns the -// running server. The caller is responsible for calling Shutdown on it. -// -// Start is a thin backward-compatible wrapper over StartHost; callers that need -// to control the bind interface (loopback vs LAN) should use StartHost directly. +// ServerConfig is the full set of knobs for the embedded NATS server. The zero +// value (empty StoreDir aside) yields a dev-friendly server: JetStream on, bound +// to all interfaces, no client auth, no TLS. Secured deployments set Auth and +// TLS; tests set Host to loopback and a free Port. +type ServerConfig struct { + StoreDir string // JetStream store directory + Host string // bind interface; "" = nats-server default ("0.0.0.0") + Port int // listen port + // Auth, when non-nil, is installed as CustomClientAuthentication so the data + // plane only accepts approved clients (nkey signature + bus allowlist). + Auth server.Authentication + // TLS, when non-nil, makes the server present a certificate and require TLS + // on the data plane. Clients must trust the issuing CA (see busauth). + TLS *tls.Config +} + +// Start is a thin backward-compatible wrapper: embedded JetStream server on the +// default interface, no auth, no TLS. func Start(storeDir string, port int) (*server.Server, error) { - return StartHost(storeDir, "", port) + return StartServer(ServerConfig{StoreDir: storeDir, Port: port}) } // StartHost is Start with explicit control over the bind interface. host selects @@ -30,34 +41,42 @@ func Start(storeDir string, port int) (*server.Server, error) { // to expose it to the LAN so remote peers (phones, other PCs) can connect. An // empty host falls back to the nats-server default ("0.0.0.0", all interfaces). func StartHost(storeDir, host string, port int) (*server.Server, error) { - return StartHostAuth(storeDir, host, port, nil) + return StartServer(ServerConfig{StoreDir: storeDir, Host: host, Port: port}) } // StartHostAuth is StartHost with an optional custom client authenticator. When -// auth is non-nil it is installed as Options.CustomClientAuthentication, so the -// data plane only accepts clients the authenticator approves (nkey signature + -// bus allowlist). When auth is nil the server accepts any client (the legacy, -// network-trusted behavior) — used by dev stacks and tests that have not enabled -// bus auth. +// auth is non-nil only clients the authenticator approves may connect; when nil +// the server accepts any client (legacy, network-trusted behavior). func StartHostAuth(storeDir, host string, port int, auth server.Authentication) (*server.Server, error) { + return StartServer(ServerConfig{StoreDir: storeDir, Host: host, Port: port, Auth: auth}) +} + +// StartServer launches an embedded nats-server with JetStream from cfg. It +// blocks until the server is ready to accept connections (up to 5s) and returns +// the running server; the caller must Shutdown it. +func StartServer(cfg ServerConfig) (*server.Server, error) { opts := &server.Options{ JetStream: true, - StoreDir: storeDir, - Host: host, - Port: port, + StoreDir: cfg.StoreDir, + Host: cfg.Host, + Port: cfg.Port, DontListen: false, // Keep the embedded server quiet by default; the host app logs the URLs. NoLog: true, NoSigs: true, } - if auth != nil { - opts.CustomClientAuthentication = auth + if cfg.Auth != nil { + opts.CustomClientAuthentication = cfg.Auth // A CustomClientAuthentication alone does not make the server advertise a // nonce in its INFO line, and nats.go refuses to connect with an nkey to a // server that does not ("nkeys not supported by the server"). Forcing the // nonce makes nkey clients sign the challenge our authenticator verifies. opts.AlwaysEnableNonce = true } + if cfg.TLS != nil { + opts.TLSConfig = cfg.TLS + opts.TLS = true + } ns, err := server.NewServer(opts) if err != nil {