Merge issue/0001d-tls: TLS on the NATS data plane (self-signed CA)
Phase 0001d of issue 0001. embeddednats grows a ServerConfig with an optional TLS config; the client can pin the bus's self-signed CA via Options.TLS built from busauth.LoadCATLSConfig. deploy/tls/generate-certs.sh mints the CA and a server cert (SAN: public IP, WG IP, om, localhost) — only the public ca.crt is versioned, private keys are gitignored. A client trusting the CA completes the handshake; one without it fails. TLS stays off until phase 0001e wires it in. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -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
|
||||
@@ -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.
|
||||
@@ -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-----
|
||||
Executable
+64
@@ -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 <<EOF
|
||||
subjectAltName=IP:${PUBLIC_IP},IP:${WG_IP},DNS:${HOSTNAME_OM},DNS:localhost,IP:127.0.0.1
|
||||
extendedKeyUsage=serverAuth
|
||||
keyUsage=digitalSignature,keyEncipherment
|
||||
EOF
|
||||
|
||||
openssl x509 -req -in server.csr -CA ca.crt -CAkey ca.key -CAcreateserial \
|
||||
-sha256 -days "$DAYS_SRV" -extfile server.ext -out server.crt
|
||||
|
||||
rm -f server.csr server.ext ca.srl
|
||||
|
||||
echo "==> 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'"
|
||||
@@ -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
|
||||
}
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
@@ -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 {
|
||||
|
||||
Reference in New Issue
Block a user