8b6a01d280
membershipd never called Server.UseReplicatedNonces, so every node kept a per-process anti-replay cache and a signed request accepted on node A could be replayed to node B (200+200). This wires the shared JetStream KV nonce bucket on any clustered node, closing the cross-node replay hole. Bootstrap: under enforce the service needs JetStream on its own embedded server, but the data plane only accepts allowlisted clients. Resolved with an ephemeral internal service identity the authenticator recognizes and grants full permissions (NewNkeyAuthenticatorACLInternal), connected over the in-process transport (no TLS/CA needed for the self-connection). Hard rule: --cluster-name != "" means the replicated nonce bucket is mandatory; if it cannot be created the node refuses to start (wireReplicatedNonces returns a fatal error) rather than run insecurely. Standalone nodes keep the in-memory cache unchanged (branch-by-abstraction: no JetStream dependency added). Changes: - busauth: NewNkeyAuthenticatorACLInternal + fullPermissions for the internal id. - cmd/membershipd: connectInternalJS (in-process, privileged) / connectExternalJS; wireReplicatedNonces helper; main wires it when clustered; --kv-replicas flag. Tests (regression of audit 0008 N3): - TestAttack0008_N3: 2 clustered nodes share the bucket, cross-node replay -> 401. - TestAttack0008_N3_StandaloneKeepsLocalCache: standalone needs no JetStream, same-node replay still 401. - TestAttack0008_N3_ClusteredRequiresJetStream: clustered + no JetStream -> fatal. - TestInternalConnPrivilegedUnderEnforce / ...OutsiderRejected: the privileged self-connection works under enforce and no other identity can claim it. CGO_ENABLED=0 go build/vet/test green; govulncheck 0 reachable. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
298 lines
13 KiB
Go
298 lines
13 KiB
Go
// Command membershipd is the unibus control-plane service: room metadata,
|
|
// member directory, sealed key distribution, and the media blob store. The data
|
|
// plane is NATS — if --nats-url is empty it starts an embedded nats-server with
|
|
// JetStream so the whole stack runs with `go run` and nothing to install.
|
|
package main
|
|
|
|
import (
|
|
"context"
|
|
"crypto/tls"
|
|
"encoding/hex"
|
|
"flag"
|
|
"log"
|
|
"net/http"
|
|
"os"
|
|
"os/signal"
|
|
"syscall"
|
|
"time"
|
|
|
|
cs "fn-registry/functions/cybersecurity"
|
|
|
|
"github.com/nats-io/nats.go"
|
|
"github.com/nats-io/nats.go/jetstream"
|
|
server "github.com/nats-io/nats-server/v2/server"
|
|
|
|
"github.com/enmanuel/unibus/pkg/blobstore"
|
|
"github.com/enmanuel/unibus/pkg/busauth"
|
|
"github.com/enmanuel/unibus/pkg/embeddednats"
|
|
"github.com/enmanuel/unibus/pkg/membership"
|
|
)
|
|
|
|
func main() {
|
|
// Subcommand dispatch: `membershipd user ...` is the local administration CLI
|
|
// (seed/list/revoke bus users) and must be handled before the server flag set
|
|
// parses os.Args. Running the CLI on the bus host is trusted by design (whoever
|
|
// has a shell there already controls the service), which is how the first admin
|
|
// is seeded without a chicken-egg auth problem.
|
|
if len(os.Args) > 1 && os.Args[1] == "user" {
|
|
runUserCLI(os.Args[2:])
|
|
return
|
|
}
|
|
// `membershipd migrate-to-kv` is the one-time, idempotent SQLite->JetStream KV
|
|
// data move for decentralization (issue 0003c). Like the user CLI it runs on
|
|
// the host and is dispatched before the server flag set parses os.Args.
|
|
if len(os.Args) > 1 && os.Args[1] == "migrate-to-kv" {
|
|
runMigrateCLI(os.Args[2:])
|
|
return
|
|
}
|
|
|
|
var (
|
|
bind = flag.String("bind", "127.0.0.1", "network interface to bind the HTTP API and the embedded NATS to; use 0.0.0.0 to accept LAN/remote peers")
|
|
natsURL = flag.String("nats-url", "", "external NATS url; empty starts an embedded server")
|
|
httpPort = flag.String("http-port", "8470", "HTTP port for the control-plane API")
|
|
dbPath = flag.String("db", "./local_files/unibus.db", "SQLite database path")
|
|
storeDir = flag.String("store-dir", "./local_files/blobs", "blob store directory")
|
|
natsPort = flag.Int("nats-port", 4250, "embedded NATS listen port (when --nats-url empty)")
|
|
natsStore = flag.String("nats-store", "./local_files/jetstream", "embedded JetStream store dir")
|
|
busAuth = flag.String("bus-auth", "off", "control-plane auth rollout: off|soft|enforce (feature flag bus-auth)")
|
|
tlsCert = flag.String("tls-cert", "", "PATH to the NATS server certificate (deploy/tls/server.crt); enables TLS on the embedded data plane")
|
|
tlsKey = flag.String("tls-key", "", "path to the NATS server private key (deploy/tls/server.key); required with --tls-cert")
|
|
// Cluster (issue 0003a): empty --cluster-name keeps the server standalone.
|
|
clusterName = flag.String("cluster-name", "", "NATS cluster name (identical on every node); empty = standalone, no HA")
|
|
serverName = flag.String("server-name", "", "unique node name within the cluster (required by JetStream RAFT when clustered)")
|
|
clusterPort = flag.Int("cluster-port", 6250, "route listener port for server-to-server cluster traffic")
|
|
routesCSV = flag.String("routes", "", "comma-separated nats-route URLs of the OTHER nodes, e.g. nats://user:pass@10.0.0.2:6250")
|
|
clusterUser = flag.String("cluster-user", "", "shared route secret username (gates the route listener)")
|
|
clusterPass = flag.String("cluster-pass", "", "shared route secret password")
|
|
routeTLSCert = flag.String("route-tls-cert", "", "this node's route certificate (CA-signed); enables mutual route TLS with --route-tls-key/--route-tls-ca")
|
|
routeTLSKey = flag.String("route-tls-key", "", "this node's route private key")
|
|
routeTLSCA = flag.String("route-tls-ca", "", "bus CA that signs every node's route certificate (deploy/tls/ca.crt)")
|
|
// Replicated control plane (issue 0006a/c): the JetStream replication factor
|
|
// for the shared nonce bucket (and, with --store kv, the control-plane KV).
|
|
// 1 for a 1-2 node rollout, 3 for real HA quorum (raise in place with
|
|
// `nats stream update --replicas 3` when the third node joins).
|
|
kvReplicas = flag.Int("kv-replicas", 1, "JetStream replication factor for the shared nonce/KV buckets (1..3)")
|
|
caFile = flag.String("ca", "", "bus CA cert; only used to pin TLS on the internal JetStream connection to an EXTERNAL --nats-url (the embedded server uses an in-process connection that needs no CA)")
|
|
)
|
|
flag.Parse()
|
|
|
|
authMode, err := membership.ParseAuthMode(*busAuth)
|
|
if err != nil {
|
|
log.Fatalf("%v", err)
|
|
}
|
|
|
|
// Fail-open guard (audit H2): a non-loopback bind, or any TLS flag, demands
|
|
// --bus-auth enforce. This makes an insecure public startup impossible rather
|
|
// than silently exposing the bus with the appearance of security.
|
|
if err := validateBootConfig(*bind, authMode, *tlsCert, *tlsKey); err != nil {
|
|
log.Fatalf("%v", err)
|
|
}
|
|
// Cluster route guard (issue 0003a): a public cluster needs a route secret
|
|
// and mutual route TLS, and the route-TLS flags are all-or-nothing.
|
|
if err := validateClusterConfig(*clusterName, *bind, *clusterUser, *clusterPass, *routeTLSCert, *routeTLSKey, *routeTLSCA); err != nil {
|
|
log.Fatalf("%v", err)
|
|
}
|
|
|
|
log.SetFlags(log.LstdFlags | log.Lmsgprefix)
|
|
log.SetPrefix("[membershipd] ")
|
|
|
|
// A clustered node shares its control plane with peers, so it needs a JetStream
|
|
// client to manage the replicated nonce bucket (issue 0006a). needJS will also
|
|
// be true under --store kv (issue 0006c), where the control-plane state lives in
|
|
// JetStream KV. A standalone single-node deployment needs none of this and keeps
|
|
// the in-process, in-memory behavior unchanged.
|
|
clustered := *clusterName != ""
|
|
needJS := clustered
|
|
enforce := authMode == membership.AuthEnforce
|
|
|
|
// Internal service identity (issue 0006a): when the embedded data plane enforces
|
|
// auth, membershipd must still connect to its OWN server to manage JetStream.
|
|
// It does so with this ephemeral identity, which the authenticator is built to
|
|
// recognize and grant full permissions (it never enters the user allowlist). It
|
|
// is only generated when actually needed (JetStream required AND enforce on AND
|
|
// the server is embedded), so a standalone or non-enforce node is unchanged.
|
|
var internalID cs.Identity
|
|
var internalPubHex string
|
|
if needJS && enforce && *natsURL == "" {
|
|
internalID, err = cs.GenerateIdentity()
|
|
if err != nil {
|
|
log.Fatalf("generate internal identity: %v", err)
|
|
}
|
|
internalPubHex = hex.EncodeToString(internalID.SignPub)
|
|
}
|
|
|
|
// Control plane store first: the NATS authenticator consults IsAuthorized, so
|
|
// the store must exist before the embedded server starts.
|
|
store, err := membership.Open(*dbPath)
|
|
if err != nil {
|
|
log.Fatalf("open membership store: %v", err)
|
|
}
|
|
defer store.Close()
|
|
log.Printf("membership store: %s", *dbPath)
|
|
|
|
blobs, err := blobstore.New(*storeDir)
|
|
if err != nil {
|
|
log.Fatalf("open blob store: %v", err)
|
|
}
|
|
log.Printf("blob store: %s", *storeDir)
|
|
|
|
// Data plane: embedded or external NATS. For the embedded server, enforce
|
|
// turns on the nkey authenticator (only allowlisted identities may connect)
|
|
// and --tls-cert/--tls-key turn on TLS. An external NATS manages its own
|
|
// auth/TLS, so those flags do not apply to it.
|
|
var ns *server.Server
|
|
natsClientURL := *natsURL
|
|
if natsClientURL == "" {
|
|
cfg := embeddednats.ServerConfig{
|
|
// Bind the embedded NATS to the same interface as the HTTP API so a
|
|
// single --bind flag governs reachability: 127.0.0.1 keeps the whole
|
|
// stack loopback-only; 0.0.0.0 exposes both planes to the LAN.
|
|
StoreDir: *natsStore,
|
|
Host: *bind,
|
|
Port: *natsPort,
|
|
ServerName: *serverName,
|
|
}
|
|
// Cluster (issue 0003a): with a cluster name, join the route layer for HA.
|
|
if *clusterName != "" {
|
|
cc := &embeddednats.ClusterConfig{
|
|
Name: *clusterName,
|
|
Host: *bind,
|
|
Port: *clusterPort,
|
|
Routes: splitRoutes(*routesCSV),
|
|
Username: *clusterUser,
|
|
Password: *clusterPass,
|
|
}
|
|
if *routeTLSCert != "" {
|
|
rtls, err := busauth.RouteTLSConfig(*routeTLSCert, *routeTLSKey, *routeTLSCA)
|
|
if err != nil {
|
|
log.Fatalf("load route TLS: %v", err)
|
|
}
|
|
cc.TLS = rtls
|
|
log.Printf("cluster route TLS: ON (mutual, CA %s)", *routeTLSCA)
|
|
}
|
|
cfg.Cluster = cc
|
|
log.Printf("cluster: %q node %q, route port %d, %d peer route(s)", *clusterName, *serverName, *clusterPort, len(cc.Routes))
|
|
}
|
|
if authMode == membership.AuthEnforce {
|
|
// Per-subject data-plane ACL (audit H4 / N4 residual): the authenticator
|
|
// authorizes by the bus allowlist AND confines each connection to the
|
|
// subjects of the rooms it belongs to (plus client-infra subjects). This
|
|
// closes the wildcard metadata leak where a registered non-member could
|
|
// Subscribe(">") and harvest every room's subject and JetStream activity.
|
|
// NATS freezes permissions at connect time, so a peer that joins a room
|
|
// after connecting must client.RefreshSession to gain that room's subject.
|
|
cfg.Auth = busauth.NewNkeyAuthenticatorACLInternal(
|
|
store.IsAuthorized,
|
|
busauth.PermissionsFromSubjects(membership.SubjectACLFor(store)),
|
|
internalPubHex,
|
|
)
|
|
log.Printf("NATS nkey authentication: ON (enforce, per-subject ACL)")
|
|
}
|
|
if *tlsCert != "" || *tlsKey != "" {
|
|
if *tlsCert == "" || *tlsKey == "" {
|
|
log.Fatalf("--tls-cert and --tls-key must be set together")
|
|
}
|
|
tlsCfg, err := busauth.ServerTLSConfig(*tlsCert, *tlsKey)
|
|
if err != nil {
|
|
log.Fatalf("load NATS TLS: %v", err)
|
|
}
|
|
cfg.TLS = tlsCfg
|
|
log.Printf("NATS TLS: ON (%s)", *tlsCert)
|
|
}
|
|
ns, err = embeddednats.StartServer(cfg)
|
|
if err != nil {
|
|
log.Fatalf("start embedded nats: %v", err)
|
|
}
|
|
natsClientURL = embeddednats.ClientURL(ns)
|
|
log.Printf("embedded NATS (JetStream) ready: %s", natsClientURL)
|
|
} else {
|
|
log.Printf("using external NATS: %s", natsClientURL)
|
|
}
|
|
|
|
srv := membership.NewServer(store, blobs, authMode)
|
|
// On a public (non-loopback) bind, disable cleartext rooms: the embedded NATS
|
|
// has no per-subject ACL, so cleartext content would be readable by any
|
|
// registered peer. Forcing E2E keeps message content confidential regardless
|
|
// (audit H4 minimum defense; see dev/0004d-dataplane-acl.md).
|
|
if !isLoopbackBind(*bind) {
|
|
srv.RequireEncryptedRooms = true
|
|
log.Printf("cleartext rooms: DISABLED (public bind requires end-to-end encryption)")
|
|
}
|
|
|
|
// Replicated anti-replay (issue 0006a, audit 0008 N3): a clustered node MUST
|
|
// share its nonce store across the cluster via JetStream KV, or a request
|
|
// accepted on one node can be replayed to another. Open a privileged JetStream
|
|
// client (in-process for the embedded server, a plain client for an external
|
|
// NATS) and wire the shared nonce bucket. This is a HARD requirement: if the
|
|
// bucket cannot be created the node refuses to start rather than run with a
|
|
// per-process cache that leaves the replay hole open.
|
|
if needJS {
|
|
var (
|
|
internalNC *nats.Conn
|
|
js jetstream.JetStream
|
|
)
|
|
if *natsURL == "" {
|
|
internalNC, js, err = connectInternalJS(ns, internalID, enforce)
|
|
} else {
|
|
internalNC, js, err = connectExternalJS(natsClientURL, *caFile)
|
|
}
|
|
if err != nil {
|
|
log.Fatalf("internal JetStream connection (required by --cluster-name): %v", err)
|
|
}
|
|
defer internalNC.Close()
|
|
if err := wireReplicatedNonces(srv, js, clustered, *kvReplicas); err != nil {
|
|
log.Fatalf("%v", err)
|
|
}
|
|
log.Printf("anti-replay: replicated nonce bucket %q (replicas=%d) — cluster-safe", "KV_UNIBUS_nonces", *kvReplicas)
|
|
}
|
|
|
|
log.Printf("control-plane auth: %s", authMode)
|
|
addr := *bind + ":" + *httpPort
|
|
httpSrv := &http.Server{
|
|
Addr: addr,
|
|
Handler: srv,
|
|
// Bound request header size so a peer cannot exhaust memory with huge
|
|
// headers before any body limit applies (the body ceilings live in the
|
|
// membership middleware).
|
|
MaxHeaderBytes: membership.MaxHeaderBytes,
|
|
ReadHeaderTimeout: 10 * time.Second,
|
|
}
|
|
|
|
go func() {
|
|
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)
|
|
}
|
|
}()
|
|
|
|
// Graceful shutdown on SIGINT/SIGTERM.
|
|
stop := make(chan os.Signal, 1)
|
|
signal.Notify(stop, syscall.SIGINT, syscall.SIGTERM)
|
|
<-stop
|
|
log.Printf("shutting down...")
|
|
|
|
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
|
|
defer cancel()
|
|
_ = httpSrv.Shutdown(ctx)
|
|
if ns != nil {
|
|
ns.Shutdown()
|
|
ns.WaitForShutdown()
|
|
}
|
|
log.Printf("bye")
|
|
}
|