Files
unibus/cmd/membershipd/config_test.go
T
agent c90f145a05 feat(0003a): NATS cluster routes with shared-secret auth + mutual route TLS
Add high-availability cluster support to the embedded NATS server
(issue 0003a, first phase of decentralization).

pkg/embeddednats:
- ServerConfig gains ServerName (unique per node, required by JetStream
  RAFT) and an optional *ClusterConfig (cluster name, route host/port,
  peer route URLs, shared-secret Username/Password, and a mutual-TLS
  *tls.Config). applyClusterOpts maps it onto server.Options.Cluster +
  Routes. Nil Cluster keeps the legacy standalone server.

pkg/busauth:
- RouteTLSConfig builds the route layer's mutual-TLS config: the node
  presents its CA-signed certificate AND verifies the peer's certificate
  against the bus CA (RequireAndVerifyClientCert), reusing the issue-0001
  CA. Routes authenticate NODES, never the client nkey authenticator.

cmd/membershipd:
- Cluster flags (--cluster-name/--server-name/--cluster-port/--routes/
  --cluster-user/--cluster-pass/--route-tls-cert/-key/-ca) wire a node
  into the cluster. validateClusterConfig refuses a public cluster
  without a route secret and complete mutual route TLS, and rejects
  partial route-TLS flags (all-or-nothing). splitRoutes parses the CSV.

Tests (DoD: golden + 2 edge + error path):
- TestClusterForwardsAcrossNodes: 2-node cluster forwards a client
  subject from one node to a subscriber on the other.
- TestClusterThreeNodesForward: 3-node (HA shape) cross-node forwarding.
- TestClusterMutualTLSForwards: forwarding over mutual-TLS routes.
- TestClusterRejectsBadRouteAuth: wrong cluster password -> no route.
- TestClusterRejectsUnsignedNode: cert not signed by the bus CA -> no route.
- TestClusterConfigPolicy / TestSplitRoutes: boot-guard + CSV parsing.

Master stays green: standalone (no --cluster-name) is unchanged.
2026-06-07 14:54:53 +02:00

133 lines
5.3 KiB
Go

package main
import (
"strings"
"testing"
"github.com/enmanuel/unibus/pkg/membership"
)
// TestAudit_FailOpenTLSWithoutAuth ports the auditor's H2 vector. Before the
// guard, booting with TLS on but the authenticator off ("--bind 0.0.0.0
// --tls-cert … " without enforce) produced an encrypted data plane that an
// unregistered, nkey-less client could still connect to — a fail-open config
// wearing the appearance of security. validateBootConfig now refuses it, so the
// insecure server never starts (the client therefore has nothing to connect to).
func TestAudit_FailOpenTLSWithoutAuth(t *testing.T) {
// The exact auditor configuration: public bind, TLS provided, auth off.
err := validateBootConfig("0.0.0.0", membership.AuthOff, "server.crt", "server.key")
if err == nil {
t.Fatalf("TLS without enforce on a public bind must be refused at startup")
}
if !strings.Contains(err.Error(), "enforce") {
t.Fatalf("error should point the operator at --bus-auth enforce, got: %v", err)
}
// And TLS without enforce is rejected even on loopback: TLS implies a
// security posture, so authenticating no one is always a misconfiguration.
if err := validateBootConfig("127.0.0.1", membership.AuthOff, "server.crt", "server.key"); err == nil {
t.Fatalf("TLS flags without enforce must be refused regardless of bind")
}
}
// TestBootConfigPolicy is the full table: the golden secure-public config is
// allowed, dev loopback is allowed, and every fail-open shape is refused.
func TestBootConfigPolicy(t *testing.T) {
cases := []struct {
name string
bind string
mode membership.AuthMode
cert string
key string
wantErr bool
}{
// Golden: the intended public production config.
{"public+enforce+tls", "0.0.0.0", membership.AuthEnforce, "s.crt", "s.key", false},
{"public+enforce+notls", "0.0.0.0", membership.AuthEnforce, "", "", false},
// Edge: local dev on loopback may stay open (no auth, no TLS).
{"loopback+off", "127.0.0.1", membership.AuthOff, "", "", false},
{"loopback-ipv6+off", "::1", membership.AuthOff, "", "", false},
{"localhost+off", "localhost", membership.AuthOff, "", "", false},
{"loopback+soft", "127.0.0.1", membership.AuthSoft, "", "", false},
// Error: public bind without enforce.
{"public+off", "0.0.0.0", membership.AuthOff, "", "", true},
{"public+soft", "0.0.0.0", membership.AuthSoft, "", "", true},
{"lan-ip+off", "192.168.1.10", membership.AuthOff, "", "", true},
{"empty-bind+off", "", membership.AuthOff, "", "", true},
// Error: TLS flags without enforce (cert or key alone is enough to trip it).
{"loopback+tlscert+off", "127.0.0.1", membership.AuthOff, "s.crt", "", true},
{"loopback+tlskey+soft", "127.0.0.1", membership.AuthSoft, "", "s.key", true},
}
for _, c := range cases {
t.Run(c.name, func(t *testing.T) {
err := validateBootConfig(c.bind, c.mode, c.cert, c.key)
if c.wantErr && err == nil {
t.Fatalf("config %+v should be refused", c)
}
if !c.wantErr && err != nil {
t.Fatalf("config %+v should be allowed, got: %v", c, err)
}
})
}
}
// TestClusterConfigPolicy is the cluster route guard (issue 0003a): a standalone
// server is always fine; a loopback cluster is dev-only and unguarded; a public
// cluster demands both a route secret and complete mutual route TLS; and the
// route-TLS flags are all-or-nothing regardless of bind.
func TestClusterConfigPolicy(t *testing.T) {
const c, k, ca = "node.crt", "node.key", "ca.crt"
cases := []struct {
name string
clusterName, bind string
user, pass string
rtCert, rtKey, rtCA string
wantErr bool
}{
// Standalone (no cluster name) is always allowed, even on a public bind.
{"standalone-public", "", "0.0.0.0", "", "", "", "", "", false},
// Loopback dev cluster: unguarded (unreachable from outside).
{"loopback-cluster-bare", "unibus", "127.0.0.1", "", "", "", "", "", false},
// Golden: full public HA config.
{"public-full", "unibus", "0.0.0.0", "u", "p", c, k, ca, false},
// Error: public cluster without a route secret.
{"public-no-secret", "unibus", "0.0.0.0", "", "", c, k, ca, true},
{"public-half-secret", "unibus", "0.0.0.0", "u", "", c, k, ca, true},
// Error: public cluster without mutual route TLS.
{"public-no-tls", "unibus", "10.0.0.1", "u", "p", "", "", "", true},
// Error: partial route-TLS flags trip regardless of bind.
{"loopback-partial-tls", "unibus", "127.0.0.1", "", "", c, "", "", true},
{"standalone-partial-tls", "", "127.0.0.1", "", "", c, k, "", true},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
err := validateClusterConfig(tc.clusterName, tc.bind, tc.user, tc.pass, tc.rtCert, tc.rtKey, tc.rtCA)
if tc.wantErr && err == nil {
t.Fatalf("cluster config %+v should be refused", tc)
}
if !tc.wantErr && err != nil {
t.Fatalf("cluster config %+v should be allowed, got: %v", tc, err)
}
})
}
}
func TestSplitRoutes(t *testing.T) {
cases := []struct {
in string
want int
}{
{"", 0},
{"nats://a:1", 1},
{"nats://a:1,nats://b:2", 2},
{" nats://a:1 , nats://b:2 ", 2}, // spaces trimmed
{"nats://a:1,,", 1}, // empty entries dropped
{",", 0},
}
for _, c := range cases {
if got := splitRoutes(c.in); len(got) != c.want {
t.Fatalf("splitRoutes(%q) = %v (len %d), want len %d", c.in, got, len(got), c.want)
}
}
}