Merge issue/0004e-control-tls: TLS on the HTTP control plane (H5)

membershipd serves https with the bus CA cert; the client pins the CA and
refuses a plaintext control plane when a CA is provided.
This commit is contained in:
2026-06-07 14:30:15 +02:00
3 changed files with 132 additions and 9 deletions
+19 -4
View File
@@ -6,6 +6,7 @@ package main
import (
"context"
"crypto/tls"
"flag"
"log"
"net/http"
@@ -139,10 +140,24 @@ func main() {
}
go func() {
log.Printf("HTTP control-plane API: http://%s", addr)
log.Printf(" health: http://%s/healthz", addr)
if err := httpSrv.ListenAndServe(); err != nil && err != http.ErrServerClosed {
log.Fatalf("http server: %v", err)
var serveErr error
if *tlsCert != "" {
// Serve the control plane over TLS with the same CA-signed cert as the
// data plane (audit H5): metadata (subjects, pubkeys, sealed keys, the
// social graph) is no longer readable by a network MITM. The fail-open
// guard already requires --bus-auth enforce alongside these flags.
httpSrv.TLSConfig = &tls.Config{MinVersion: tls.VersionTLS12}
log.Printf("HTTPS control-plane API: https://%s", addr)
log.Printf(" health: https://%s/healthz", addr)
log.Printf("control-plane TLS: ON (%s)", *tlsCert)
serveErr = httpSrv.ListenAndServeTLS(*tlsCert, *tlsKey)
} else {
log.Printf("HTTP control-plane API: http://%s", addr)
log.Printf(" health: http://%s/healthz", addr)
serveErr = httpSrv.ListenAndServe()
}
if serveErr != nil && serveErr != http.ErrServerClosed {
log.Fatalf("http server: %v", serveErr)
}
}()
+26 -5
View File
@@ -24,6 +24,7 @@ import (
"io"
"net/http"
"strconv"
"strings"
"sync"
"time"
@@ -67,10 +68,15 @@ 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, when non-nil, secures the NATS (data plane) 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 data plane plaintext.
TLS *tls.Config
// CtrlTLS, when non-nil, secures the HTTP control-plane connection and pins it
// to this config's RootCAs. It is separate from TLS so the two planes can be
// secured independently (a test may TLS one and not the other); production
// sets both to the same CA via Connect. Nil keeps the control plane plaintext.
CtrlTLS *tls.Config
}
// New connects to NATS and records the control-plane URL with default Options
@@ -90,11 +96,19 @@ func Connect(natsURL, ctrlURL string, id cs.Identity, caPath string) (*Client, e
if caPath == "" {
return New(natsURL, ctrlURL, id)
}
// A CA implies the bus is TLS on BOTH planes. Refuse a plaintext control-plane
// URL: signing gives integrity, not confidentiality, so sending metadata over
// http:// when the operator provisioned a CA would silently leak it to a MITM
// (audit H5). Force https rather than silently downgrade.
if !strings.HasPrefix(ctrlURL, "https://") {
return nil, fmt.Errorf("client: control-plane URL %q must be https:// when a CA is provided", ctrlURL)
}
tlsCfg, err := busauth.LoadCATLSConfig(caPath)
if err != nil {
return nil, fmt.Errorf("client: load CA %q: %w", caPath, err)
}
return NewWithOptions(natsURL, ctrlURL, id, Options{UseNkey: true, TLS: tlsCfg})
// Pin the same CA on both planes: nkey+TLS on NATS, TLS on the HTTP control plane.
return NewWithOptions(natsURL, ctrlURL, id, Options{UseNkey: true, TLS: tlsCfg, CtrlTLS: tlsCfg})
}
// NewWithOptions is New with explicit connection options (nkey auth, and, from
@@ -125,13 +139,20 @@ func NewWithOptions(natsURL, ctrlURL string, id cs.Identity, opts Options) (*Cli
nc.Close()
return nil, fmt.Errorf("client: init jetstream: %w", err)
}
// The control-plane HTTP client pins the bus CA when CtrlTLS is set, so an
// https:// control plane is verified against the bus's own CA rather than the
// system roots (audit H5). Without it the client stays plaintext for dev.
httpClient := &http.Client{Timeout: 10 * time.Second}
if opts.CtrlTLS != nil {
httpClient.Transport = &http.Transport{TLSClientConfig: opts.CtrlTLS.Clone()}
}
return &Client{
id: id,
endpoint: frame.EndpointID(id.SignPub),
nc: nc,
js: js,
ctrlURL: ctrlURL,
http: &http.Client{Timeout: 10 * time.Second},
http: httpClient,
keyCache: map[string]map[int][]byte{},
signCache: map[string][]byte{},
}, nil
+87
View File
@@ -0,0 +1,87 @@
package client_test
import (
"crypto/tls"
"crypto/x509"
"net/http/httptest"
"path/filepath"
"strings"
"testing"
"github.com/enmanuel/unibus/pkg/blobstore"
"github.com/enmanuel/unibus/pkg/client"
"github.com/enmanuel/unibus/pkg/embeddednats"
"github.com/enmanuel/unibus/pkg/membership"
"github.com/enmanuel/unibus/pkg/room"
)
// TestConnectRequiresHTTPSWithCA covers audit H5's client contract: when a CA is
// provided the control-plane URL must be https://. A signed request gives
// integrity but not confidentiality, so silently talking http:// to a bus the
// operator secured with a CA would leak all metadata to a MITM. Connect refuses
// the plaintext URL outright (error path; the scheme is checked before any
// network use, so a bogus CA path is irrelevant).
func TestConnectRequiresHTTPSWithCA(t *testing.T) {
_, err := client.Connect("nats://127.0.0.1:4222", "http://127.0.0.1:8470", mustIdentity(t), "/nonexistent/ca.crt")
if err == nil {
t.Fatalf("Connect with a CA and an http:// control plane must be refused")
}
if !strings.Contains(err.Error(), "https") {
t.Fatalf("error should point the caller at https, got: %v", err)
}
}
// TestControlPlaneOverTLS proves the control plane works over TLS pinned to the
// bus CA (golden) and that a client lacking the CA cannot complete the handshake
// (error path) — so a network observer can neither read nor inject control-plane
// traffic. The data plane is left plaintext here to isolate the HTTP-TLS wiring.
func TestControlPlaneOverTLS(t *testing.T) {
dir := t.TempDir()
store, err := membership.Open(filepath.Join(dir, "unibus.db"))
if err != nil {
t.Fatalf("store: %v", err)
}
t.Cleanup(func() { store.Close() })
blobs, err := blobstore.New(filepath.Join(dir, "blobs"))
if err != nil {
t.Fatalf("blobs: %v", err)
}
ns, err := embeddednats.StartServer(embeddednats.ServerConfig{
StoreDir: filepath.Join(dir, "js"), Host: "127.0.0.1", Port: freePort(t),
})
if err != nil {
t.Fatalf("nats: %v", err)
}
t.Cleanup(func() { ns.Shutdown(); ns.WaitForShutdown() })
natsURL := embeddednats.ClientURL(ns)
// An https control plane wrapping the real membership server.
ts := httptest.NewTLSServer(membership.NewServer(store, blobs, membership.AuthOff))
t.Cleanup(ts.Close)
pool := x509.NewCertPool()
pool.AddCert(ts.Certificate())
// Golden: trusting the control-plane CA, an https control-plane request works.
good, err := client.NewWithOptions(natsURL, ts.URL, mustIdentity(t),
client.Options{CtrlTLS: &tls.Config{RootCAs: pool, MinVersion: tls.VersionTLS12}})
if err != nil {
t.Fatalf("connect with the pinned CA: %v", err)
}
defer good.Close()
if _, err := good.CreateRoom("room.tls.ctrl", room.ModeNATS); err != nil {
t.Fatalf("control plane over TLS should succeed with the pinned CA: %v", err)
}
// Error path: without the CA the https handshake fails, so the request errors.
bad, err := client.NewWithOptions(natsURL, ts.URL, mustIdentity(t),
client.Options{CtrlTLS: &tls.Config{RootCAs: x509.NewCertPool(), MinVersion: tls.VersionTLS12}})
if err != nil {
t.Fatalf("nats connect (bad CA case): %v", err)
}
defer bad.Close()
if _, err := bad.CreateRoom("room.tls.fail", room.ModeNATS); err == nil {
t.Fatalf("a control-plane request without the CA must fail the TLS handshake")
}
}