Merge branch 'quick/0001-ws-cors-prep'
This commit is contained in:
@@ -2,7 +2,7 @@
|
||||
name: unibus
|
||||
lang: go
|
||||
domain: infra
|
||||
version: 0.13.0
|
||||
version: 0.14.0
|
||||
description: "Bus de mensajería unificado sobre NATS+JetStream con cifrado E2E por room (megolm/olm reducido): service de membresía/claves, librería cliente y peers demo."
|
||||
tags: [service, messaging, nats, e2e]
|
||||
uses_functions:
|
||||
@@ -169,6 +169,16 @@ agent.<nombre>.{in,out} inbox/outbox de agente LLM (agent.scout.in)
|
||||
|
||||
## Capability growth log
|
||||
|
||||
- v0.14.0 (2026-06-13) — prep para el cliente browser-nativo `uniweb` (issue
|
||||
uniweb/0001, Fase 0), todo aditivo y opt-in: (1) el nats-server embebido puede
|
||||
exponer un listener WebSocket (`WebsocketConfig`) para que un navegador hable el
|
||||
protocolo NATS via `nats.ws`, igual que los peers TCP nativos; el authenticator
|
||||
nkey aplica también al WebSocket. (2) El control-plane (`membershipd`) gana una
|
||||
allowlist CORS opt-in (`--cors-origins`) para aceptar llamadas cross-origin del
|
||||
navegador; vacía = CORS off, sin cambios para clientes nativos. (3) `cmd/busvectors`
|
||||
genera vectores de test deterministas (endpoint id, firma Ed25519, AEAD
|
||||
ChaCha20-Poly1305, sealed-box, wire del Frame) como contrato de paridad para el
|
||||
port TypeScript. Peers Go/Kotlin existentes sin cambios; build/vet/test verdes.
|
||||
- v0.13.0 (2026-06-13) — el frontend web se separa a su propia app `uniweb`
|
||||
(`projects/message_bus/apps/uniweb`, sub-repo Gitea propio). unibus deja de
|
||||
contener la SPA (`web/`) y el gateway web (`cmd/webgw`): ahora es estrictamente
|
||||
|
||||
@@ -0,0 +1,229 @@
|
||||
// Command busvectors emits deterministic cross-language test vectors for the bus
|
||||
// protocol and its end-to-end crypto. The browser-native client (uniweb) ports the
|
||||
// protocol to TypeScript; these vectors are the contract that proves the port is
|
||||
// byte-for-byte compatible with this Go reference implementation (issue
|
||||
// uniweb/0001, Phase 0).
|
||||
//
|
||||
// Every input is fixed (hardcoded key material and messages) so the output is
|
||||
// stable across runs and can be committed as a golden file. The crypto primitives
|
||||
// are the SAME registry functions the bus uses (functions/cybersecurity), so the
|
||||
// vectors exercise the real path, not a test-only reimplementation.
|
||||
//
|
||||
// Coverage:
|
||||
// - endpoint_id : EndpointID(signPub) = base64url(sha256(signPub))
|
||||
// - sign : Ed25519 signature over a fixed message (deterministic)
|
||||
// - aead : ChaCha20-Poly1305 seal with a FIXED nonce (deterministic, so
|
||||
// the TS port must reproduce the same ciphertext AND open it)
|
||||
// - keybox : sealed-box (X25519) of a room key for a recipient; the TS port
|
||||
// must OPEN it (the ephemeral sender key is random, so only the
|
||||
// open direction is a stable vector — the TS->Go seal direction
|
||||
// is covered by the live E2E test in Phase 3)
|
||||
// - frame : canonical JSON wire bytes of a Frame, and its SigningBytes
|
||||
//
|
||||
// Usage:
|
||||
//
|
||||
// go run ./cmd/busvectors > ../uniweb/web/src/bus/testdata/vectors.json
|
||||
package main
|
||||
|
||||
import (
|
||||
"crypto/ed25519"
|
||||
"encoding/base64"
|
||||
"encoding/hex"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"os"
|
||||
|
||||
cs "fn-registry/functions/cybersecurity"
|
||||
|
||||
"github.com/enmanuel/unibus/pkg/frame"
|
||||
"golang.org/x/crypto/chacha20poly1305"
|
||||
"golang.org/x/crypto/curve25519"
|
||||
)
|
||||
|
||||
// Fixed key material. The bytes are arbitrary but stable: the point is a golden
|
||||
// file, not secrecy (these are test vectors, never real identities).
|
||||
var (
|
||||
signSeed = mustHex("000102030405060708090a0b0c0d0e0f101112131415161718191a1b1c1d1e1f")
|
||||
kexPriv = mustHex("202122232425262728292a2b2c2d2e2f303132333435363738393a3b3c3d3e3f")
|
||||
recipientKexPriv = mustHex("404142434445464748494a4b4c4d4e4f505152535455565758595a5b5c5d5e5f")
|
||||
aeadKey = mustHex("606162636465666768696a6b6c6d6e6f707172737475767778797a7b7c7d7e7f")
|
||||
aeadNonce = mustHex("808182838485868788898a8b") // 12 bytes (ChaCha20-Poly1305 IETF)
|
||||
roomKey = mustHex("a0a1a2a3a4a5a6a7a8a9aaabacadaeafb0b1b2b3b4b5b6b7b8b9babbbcbdbebf")
|
||||
signMessage = []byte("unibus parity vector message")
|
||||
aeadAAD = []byte("unibus-room-42")
|
||||
aeadPlaintext = []byte("hello from the bus")
|
||||
)
|
||||
|
||||
// vectors is the JSON document consumed by the TypeScript parity tests. Every field
|
||||
// is hex except the frame wire bytes, which are base64 (the frame is JSON, so the
|
||||
// TS side compares the exact UTF-8 bytes).
|
||||
type vectors struct {
|
||||
Note string `json:"note"`
|
||||
Endpoint endpointVector `json:"endpoint_id"`
|
||||
Sign signVector `json:"sign"`
|
||||
AEAD aeadVector `json:"aead"`
|
||||
KeyBox keyboxVector `json:"keybox"`
|
||||
Frame frameVector `json:"frame"`
|
||||
}
|
||||
|
||||
type endpointVector struct {
|
||||
SignPubHex string `json:"sign_pub_hex"`
|
||||
EndpointID string `json:"endpoint_id"` // base64url(sha256(sign_pub)), unpadded
|
||||
}
|
||||
|
||||
type signVector struct {
|
||||
SignPrivHex string `json:"sign_priv_hex"`
|
||||
SignPubHex string `json:"sign_pub_hex"`
|
||||
MessageHex string `json:"message_hex"`
|
||||
SigHex string `json:"sig_hex"`
|
||||
}
|
||||
|
||||
type aeadVector struct {
|
||||
KeyHex string `json:"key_hex"`
|
||||
NonceHex string `json:"nonce_hex"`
|
||||
AADHex string `json:"aad_hex"`
|
||||
PlaintextHex string `json:"plaintext_hex"`
|
||||
CiphertextHex string `json:"ciphertext_hex"` // includes the 16-byte Poly1305 tag
|
||||
}
|
||||
|
||||
type keyboxVector struct {
|
||||
RecipientKexPubHex string `json:"recipient_kex_pub_hex"`
|
||||
RecipientKexPrivHex string `json:"recipient_kex_priv_hex"`
|
||||
SecretHex string `json:"secret_hex"`
|
||||
SealedHex string `json:"sealed_hex"`
|
||||
}
|
||||
|
||||
type frameVector struct {
|
||||
// The source fields, so the TS side can build the same Frame and compare.
|
||||
Type int `json:"type"`
|
||||
Subject string `json:"subject"`
|
||||
Sender string `json:"sender"`
|
||||
MsgID string `json:"msg_id"`
|
||||
Epoch int `json:"epoch"`
|
||||
NonceHex string `json:"nonce_hex"`
|
||||
PayloadHex string `json:"payload_hex"`
|
||||
WireB64 string `json:"wire_b64"` // base64(Marshal()) — full frame incl. sig
|
||||
SigningB64 string `json:"signing_bytes_b64"` // base64(SigningBytes()) — what gets signed
|
||||
SigHex string `json:"sig_hex"` // Ed25519 over SigningBytes
|
||||
}
|
||||
|
||||
func main() {
|
||||
if err := run(os.Stdout); err != nil {
|
||||
fmt.Fprintln(os.Stderr, "busvectors:", err)
|
||||
os.Exit(1)
|
||||
}
|
||||
}
|
||||
|
||||
func run(out *os.File) error {
|
||||
// Identity from the fixed seed: Go's ed25519 private key layout is seed||pub, the
|
||||
// same 64-byte layout cs.Identity and the TS wallet use.
|
||||
signPriv := ed25519.NewKeyFromSeed(signSeed)
|
||||
signPub := signPriv.Public().(ed25519.PublicKey)
|
||||
|
||||
// X25519 public keys from the fixed private scalars (curve25519 clamps internally,
|
||||
// matching @noble/curves x25519.getPublicKey).
|
||||
kexPub, err := curve25519.X25519(kexPriv, curve25519.Basepoint)
|
||||
if err != nil {
|
||||
return fmt.Errorf("kex pub: %w", err)
|
||||
}
|
||||
recipientKexPub, err := curve25519.X25519(recipientKexPriv, curve25519.Basepoint)
|
||||
if err != nil {
|
||||
return fmt.Errorf("recipient kex pub: %w", err)
|
||||
}
|
||||
|
||||
// AEAD with a FIXED nonce so the vector is deterministic. This is the same cipher
|
||||
// (ChaCha20-Poly1305 IETF, 12-byte nonce) that cs.SealAEAD uses; we set the nonce
|
||||
// explicitly only to make the vector reproducible. OpenAEAD verifies round-trip.
|
||||
aead, err := chacha20poly1305.New(aeadKey)
|
||||
if err != nil {
|
||||
return fmt.Errorf("aead cipher: %w", err)
|
||||
}
|
||||
ciphertext := aead.Seal(nil, aeadNonce, aeadPlaintext, aeadAAD)
|
||||
if _, err := cs.OpenAEAD(aeadKey, aeadNonce, ciphertext, aeadAAD); err != nil {
|
||||
return fmt.Errorf("aead self-check: %w", err)
|
||||
}
|
||||
|
||||
// Sealed box of the room key for the recipient. The sender's ephemeral key is
|
||||
// random (anonymous sealed box), so SealedHex changes per run; the stable, useful
|
||||
// assertion for the TS port is that OpenKeyBox recovers the secret, which we
|
||||
// self-check here. The TS test opens SealedHex and compares to SecretHex.
|
||||
sealed, err := cs.SealKeyBox(recipientKexPub, roomKey)
|
||||
if err != nil {
|
||||
return fmt.Errorf("seal keybox: %w", err)
|
||||
}
|
||||
if got, err := cs.OpenKeyBox(recipientKexPub, recipientKexPriv, sealed); err != nil || hex.EncodeToString(got) != hex.EncodeToString(roomKey) {
|
||||
return fmt.Errorf("keybox self-check failed: %v", err)
|
||||
}
|
||||
|
||||
// A representative encrypted-room frame, signed end-to-end.
|
||||
f := frame.Frame{
|
||||
Type: frame.PUB,
|
||||
Subject: "room.parity",
|
||||
Sender: frame.EndpointID(signPub),
|
||||
MsgID: "01HZY0VECTORFIXEDULID0001",
|
||||
Epoch: 1,
|
||||
Nonce: aeadNonce,
|
||||
Payload: ciphertext,
|
||||
}
|
||||
f.Sig = ed25519.Sign(signPriv, f.SigningBytes())
|
||||
wire, err := f.Marshal()
|
||||
if err != nil {
|
||||
return fmt.Errorf("marshal frame: %w", err)
|
||||
}
|
||||
|
||||
v := vectors{
|
||||
Note: "Deterministic cross-language vectors for the unibus protocol. Generated by " +
|
||||
"cmd/busvectors in the unibus repo; regenerate with `go run ./cmd/busvectors`. " +
|
||||
"sealed_hex varies per run (anonymous sealed box); assert via OpenKeyBox.",
|
||||
Endpoint: endpointVector{
|
||||
SignPubHex: hex.EncodeToString(signPub),
|
||||
EndpointID: frame.EndpointID(signPub),
|
||||
},
|
||||
Sign: signVector{
|
||||
SignPrivHex: hex.EncodeToString(signPriv),
|
||||
SignPubHex: hex.EncodeToString(signPub),
|
||||
MessageHex: hex.EncodeToString(signMessage),
|
||||
SigHex: hex.EncodeToString(ed25519.Sign(signPriv, signMessage)),
|
||||
},
|
||||
AEAD: aeadVector{
|
||||
KeyHex: hex.EncodeToString(aeadKey),
|
||||
NonceHex: hex.EncodeToString(aeadNonce),
|
||||
AADHex: hex.EncodeToString(aeadAAD),
|
||||
PlaintextHex: hex.EncodeToString(aeadPlaintext),
|
||||
CiphertextHex: hex.EncodeToString(ciphertext),
|
||||
},
|
||||
KeyBox: keyboxVector{
|
||||
RecipientKexPubHex: hex.EncodeToString(recipientKexPub),
|
||||
RecipientKexPrivHex: hex.EncodeToString(recipientKexPriv),
|
||||
SecretHex: hex.EncodeToString(roomKey),
|
||||
SealedHex: hex.EncodeToString(sealed),
|
||||
},
|
||||
Frame: frameVector{
|
||||
Type: int(f.Type),
|
||||
Subject: f.Subject,
|
||||
Sender: f.Sender,
|
||||
MsgID: f.MsgID,
|
||||
Epoch: f.Epoch,
|
||||
NonceHex: hex.EncodeToString(f.Nonce),
|
||||
PayloadHex: hex.EncodeToString(f.Payload),
|
||||
WireB64: base64.StdEncoding.EncodeToString(wire),
|
||||
SigningB64: base64.StdEncoding.EncodeToString(f.SigningBytes()),
|
||||
SigHex: hex.EncodeToString(f.Sig),
|
||||
},
|
||||
// kexPub is unused in a vector field today but derived above to validate the
|
||||
// scalar; reference it so the intent is documented.
|
||||
}
|
||||
_ = kexPub
|
||||
|
||||
enc := json.NewEncoder(out)
|
||||
enc.SetIndent("", " ")
|
||||
return enc.Encode(v)
|
||||
}
|
||||
|
||||
func mustHex(s string) []byte {
|
||||
b, err := hex.DecodeString(s)
|
||||
if err != nil {
|
||||
panic("busvectors: bad fixed hex: " + s)
|
||||
}
|
||||
return b
|
||||
}
|
||||
@@ -13,6 +13,7 @@ import (
|
||||
"net/http"
|
||||
"os"
|
||||
"os/signal"
|
||||
"strings"
|
||||
"syscall"
|
||||
"time"
|
||||
|
||||
@@ -56,6 +57,7 @@ func main() {
|
||||
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)")
|
||||
corsOrigins = flag.String("cors-origins", "", "comma-separated CORS allowlist of browser origins permitted to call the control plane (e.g. http://localhost:5173,https://chat.example.com); empty = CORS off. Enables the browser-native uniweb client (issue uniweb/0001)")
|
||||
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.
|
||||
@@ -329,6 +331,13 @@ func main() {
|
||||
Cluster: clustered,
|
||||
Store: *storeBackend,
|
||||
}
|
||||
// CORS allowlist for the browser-native client (uniweb). splitRoutes is reused
|
||||
// as a generic comma-list parser (trim + drop empties). Empty flag => empty
|
||||
// slice => CORS stays off, identical to the pre-flag behavior.
|
||||
if origins := splitRoutes(*corsOrigins); len(origins) > 0 {
|
||||
srv.AllowedOrigins = origins
|
||||
log.Printf("CORS: allowing %d browser origin(s): %s", len(origins), strings.Join(origins, ", "))
|
||||
}
|
||||
|
||||
// Replicated anti-replay (issue 0006a, audit 0008 N3): a clustered node MUST
|
||||
// share its nonce store across the cluster, or a request accepted on one node
|
||||
|
||||
@@ -79,6 +79,42 @@ type ServerConfig struct {
|
||||
// availability (issue 0003a). Nil keeps the server standalone (the legacy
|
||||
// single-node behavior).
|
||||
Cluster *ClusterConfig
|
||||
// Websocket, when non-nil, opens an ADDITIONAL WebSocket listener on the
|
||||
// embedded nats-server so browser clients (nats.ws) can reach the data plane
|
||||
// directly, the same way native TCP peers (Go, Kotlin) do (issue uniweb/0001).
|
||||
// Native TCP clients are unaffected: the WebSocket listener is a separate port
|
||||
// layered on top of the existing TCP listener, and the client authenticator
|
||||
// (Auth) applies to both. Nil keeps the server TCP-only (legacy behavior).
|
||||
Websocket *WebsocketConfig
|
||||
}
|
||||
|
||||
// WebsocketConfig configures the embedded nats-server's WebSocket listener so a
|
||||
// browser can speak the NATS protocol over ws://. A browser cannot open a raw TCP
|
||||
// socket, so this is the only way the SPA reaches the data plane without a Go
|
||||
// gateway in between.
|
||||
//
|
||||
// Security: off loopback a browser requires wss:// (TLS) — set TLS with a
|
||||
// certificate the browser trusts. NoTLS plain ws:// is acceptable only for a
|
||||
// loopback dev stack. The WebSocket upgrade also enforces an Origin allowlist
|
||||
// (browser same-origin policy); AllowedOrigins must list the SPA's origins or the
|
||||
// browser handshake is refused.
|
||||
type WebsocketConfig struct {
|
||||
// Host is the bind interface for the WebSocket listener; "" lets nats-server
|
||||
// pick its default. Use "127.0.0.1" to keep it loopback-only in dev.
|
||||
Host string
|
||||
// Port is the WebSocket listen port (e.g. 8480). Required (non-zero) for the
|
||||
// listener to open.
|
||||
Port int
|
||||
// NoTLS serves plain ws:// instead of wss://. Loopback/dev only: browsers refuse
|
||||
// ws:// to a non-loopback origin. Ignored when TLS is set (TLS implies wss://).
|
||||
NoTLS bool
|
||||
// TLS, when set, serves wss:// with this certificate. Required for any browser
|
||||
// origin that is not loopback.
|
||||
TLS *tls.Config
|
||||
// AllowedOrigins is the allowlist of browser Origin headers permitted to upgrade
|
||||
// the WebSocket. Empty = same-origin only (nats-server SameOrigin). Never use a
|
||||
// wildcard in production; list the exact SPA origins.
|
||||
AllowedOrigins []string
|
||||
}
|
||||
|
||||
// Start is a thin backward-compatible wrapper: embedded JetStream server on the
|
||||
@@ -170,6 +206,29 @@ func StartServer(cfg ServerConfig) (*server.Server, error) {
|
||||
opts.TLS = true
|
||||
}
|
||||
|
||||
if cfg.Websocket != nil {
|
||||
// Layer a WebSocket listener on top of the TCP data plane so browser
|
||||
// clients (nats.ws) can connect. The client authenticator (opts.*Auth above)
|
||||
// applies to WebSocket connections too, so a browser still has to pass the
|
||||
// nkey + allowlist check; this only adds a transport, not a trust bypass.
|
||||
ws := server.WebsocketOpts{
|
||||
Host: cfg.Websocket.Host,
|
||||
Port: cfg.Websocket.Port,
|
||||
AllowedOrigins: cfg.Websocket.AllowedOrigins,
|
||||
}
|
||||
if cfg.Websocket.TLS != nil {
|
||||
ws.TLSConfig = cfg.Websocket.TLS
|
||||
} else {
|
||||
// No certificate: plain ws:// (loopback/dev only). Browsers refuse this
|
||||
// off-loopback, which is the intended guard rail.
|
||||
ws.NoTLS = true
|
||||
}
|
||||
// Empty AllowedOrigins means "same-origin only": tell nats-server to enforce
|
||||
// it rather than defaulting to accept-any-origin.
|
||||
ws.SameOrigin = len(cfg.Websocket.AllowedOrigins) == 0
|
||||
opts.Websocket = ws
|
||||
}
|
||||
|
||||
if cfg.Cluster != nil {
|
||||
if err := applyClusterOpts(opts, cfg.Cluster); err != nil {
|
||||
return nil, err
|
||||
|
||||
@@ -0,0 +1,108 @@
|
||||
package embeddednats_test
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"net"
|
||||
"net/http"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/enmanuel/unibus/pkg/embeddednats"
|
||||
)
|
||||
|
||||
// wsFreePort returns an OS-assigned free TCP port on loopback. Kept local to this
|
||||
// file so the WebSocket tests do not depend on the cluster test helpers.
|
||||
func wsFreePort(t *testing.T) int {
|
||||
t.Helper()
|
||||
l, err := net.Listen("tcp", "127.0.0.1:0")
|
||||
if err != nil {
|
||||
t.Fatalf("reserve free port: %v", err)
|
||||
}
|
||||
defer l.Close()
|
||||
return l.Addr().(*net.TCPAddr).Port
|
||||
}
|
||||
|
||||
// TestWebsocketListenerOpens verifies that when a ServerConfig carries a
|
||||
// WebsocketConfig the embedded nats-server opens the additional WebSocket port and
|
||||
// accepts a connection there, while the regular TCP client port keeps working. A
|
||||
// browser cannot speak raw TCP, so this WebSocket listener is the only path the SPA
|
||||
// has to the data plane (issue uniweb/0001).
|
||||
func TestWebsocketListenerOpens(t *testing.T) {
|
||||
clientPort := wsFreePort(t)
|
||||
wsPort := wsFreePort(t)
|
||||
|
||||
ns, err := embeddednats.StartServer(embeddednats.ServerConfig{
|
||||
StoreDir: t.TempDir(),
|
||||
Host: "127.0.0.1",
|
||||
Port: clientPort,
|
||||
Websocket: &embeddednats.WebsocketConfig{
|
||||
Host: "127.0.0.1",
|
||||
Port: wsPort,
|
||||
NoTLS: true, // loopback dev: plain ws://
|
||||
},
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("StartServer with websocket: %v", err)
|
||||
}
|
||||
t.Cleanup(func() { ns.Shutdown(); ns.WaitForShutdown() })
|
||||
|
||||
// The WebSocket listener must accept a TCP connection on its dedicated port.
|
||||
addr := fmt.Sprintf("127.0.0.1:%d", wsPort)
|
||||
conn, err := net.DialTimeout("tcp", addr, 2*time.Second)
|
||||
if err != nil {
|
||||
t.Fatalf("websocket port %d not accepting connections: %v", wsPort, err)
|
||||
}
|
||||
conn.Close()
|
||||
|
||||
// And it must speak the WebSocket upgrade handshake: a GET with the upgrade
|
||||
// headers should get a 101 Switching Protocols (nats-server's ws endpoint),
|
||||
// proving it is a real WebSocket listener, not just an open socket.
|
||||
req, err := http.NewRequest(http.MethodGet, "http://"+addr+"/", nil)
|
||||
if err != nil {
|
||||
t.Fatalf("build upgrade request: %v", err)
|
||||
}
|
||||
req.Header.Set("Upgrade", "websocket")
|
||||
req.Header.Set("Connection", "Upgrade")
|
||||
req.Header.Set("Sec-WebSocket-Version", "13")
|
||||
req.Header.Set("Sec-WebSocket-Key", "dGhlIHNhbXBsZSBub25jZQ==")
|
||||
|
||||
client := &http.Client{Timeout: 2 * time.Second}
|
||||
resp, err := client.Do(req)
|
||||
if err != nil {
|
||||
t.Fatalf("websocket upgrade request: %v", err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
if resp.StatusCode != http.StatusSwitchingProtocols {
|
||||
t.Fatalf("websocket upgrade: got status %d, want 101 Switching Protocols", resp.StatusCode)
|
||||
}
|
||||
}
|
||||
|
||||
// TestNoWebsocketByDefault verifies the listener stays TCP-only when WebsocketConfig
|
||||
// is nil: opening the browser transport must be an explicit opt-in so existing
|
||||
// single-node and cluster deployments are unchanged.
|
||||
func TestNoWebsocketByDefault(t *testing.T) {
|
||||
clientPort := wsFreePort(t)
|
||||
// Reserve a port, then free it, so we can assert nothing is listening there.
|
||||
maybeWSPort := wsFreePort(t)
|
||||
|
||||
ns, err := embeddednats.StartServer(embeddednats.ServerConfig{
|
||||
StoreDir: t.TempDir(),
|
||||
Host: "127.0.0.1",
|
||||
Port: clientPort,
|
||||
// Websocket intentionally nil.
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("StartServer: %v", err)
|
||||
}
|
||||
t.Cleanup(func() { ns.Shutdown(); ns.WaitForShutdown() })
|
||||
|
||||
conn, err := net.DialTimeout("tcp", fmt.Sprintf("127.0.0.1:%d", maybeWSPort), 300*time.Millisecond)
|
||||
if err == nil {
|
||||
conn.Close()
|
||||
t.Fatalf("a listener is unexpectedly open on %d with no WebsocketConfig", maybeWSPort)
|
||||
}
|
||||
if !strings.Contains(err.Error(), "refused") && !strings.Contains(err.Error(), "timeout") {
|
||||
t.Logf("dial error (acceptable, port closed): %v", err)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,150 @@
|
||||
package membership_test
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
|
||||
"github.com/enmanuel/unibus/pkg/blobstore"
|
||||
"github.com/enmanuel/unibus/pkg/membership"
|
||||
)
|
||||
|
||||
// newCORSServer builds a control-plane server with the given CORS allowlist over a
|
||||
// throwaway store, and returns a live httptest server. /healthz is auth-exempt, so
|
||||
// the CORS tests can exercise the cross-origin pipeline without signing requests.
|
||||
func newCORSServer(t *testing.T, origins ...string) *httptest.Server {
|
||||
t.Helper()
|
||||
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, _ := blobstore.New(filepath.Join(dir, "blobs"))
|
||||
|
||||
srv := membership.NewServer(store, blobs, membership.AuthOff)
|
||||
srv.AllowedOrigins = origins
|
||||
ts := httptest.NewServer(srv)
|
||||
t.Cleanup(ts.Close)
|
||||
return ts
|
||||
}
|
||||
|
||||
// TestCORSPreflightAllowedOrigin: a preflight (OPTIONS) from an allow-listed origin
|
||||
// is answered 204 with the Access-Control headers, and never reaches auth. This is
|
||||
// what lets the browser-native uniweb client call the control plane (issue
|
||||
// uniweb/0001).
|
||||
func TestCORSPreflightAllowedOrigin(t *testing.T) {
|
||||
const origin = "http://localhost:5173"
|
||||
ts := newCORSServer(t, origin)
|
||||
|
||||
req, _ := http.NewRequest(http.MethodOptions, ts.URL+"/rooms", nil)
|
||||
req.Header.Set("Origin", origin)
|
||||
req.Header.Set("Access-Control-Request-Method", "POST")
|
||||
|
||||
resp, err := http.DefaultClient.Do(req)
|
||||
if err != nil {
|
||||
t.Fatalf("preflight: %v", err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
if resp.StatusCode != http.StatusNoContent {
|
||||
t.Fatalf("preflight status = %d, want 204", resp.StatusCode)
|
||||
}
|
||||
if got := resp.Header.Get("Access-Control-Allow-Origin"); got != origin {
|
||||
t.Fatalf("Allow-Origin = %q, want %q", got, origin)
|
||||
}
|
||||
if got := resp.Header.Get("Access-Control-Allow-Methods"); got == "" {
|
||||
t.Fatalf("Allow-Methods missing on preflight")
|
||||
}
|
||||
}
|
||||
|
||||
// TestCORSPreflightDisallowedOrigin: a preflight from an origin NOT in the allowlist
|
||||
// gets 403 and no Access-Control headers, so the browser blocks the real request.
|
||||
func TestCORSPreflightDisallowedOrigin(t *testing.T) {
|
||||
ts := newCORSServer(t, "http://localhost:5173")
|
||||
|
||||
req, _ := http.NewRequest(http.MethodOptions, ts.URL+"/rooms", nil)
|
||||
req.Header.Set("Origin", "https://evil.example.com")
|
||||
req.Header.Set("Access-Control-Request-Method", "POST")
|
||||
|
||||
resp, err := http.DefaultClient.Do(req)
|
||||
if err != nil {
|
||||
t.Fatalf("preflight: %v", err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
if resp.StatusCode != http.StatusForbidden {
|
||||
t.Fatalf("disallowed preflight status = %d, want 403", resp.StatusCode)
|
||||
}
|
||||
if got := resp.Header.Get("Access-Control-Allow-Origin"); got != "" {
|
||||
t.Fatalf("Allow-Origin leaked for disallowed origin: %q", got)
|
||||
}
|
||||
}
|
||||
|
||||
// TestCORSActualRequestCarriesHeader: a real GET from an allow-listed origin is
|
||||
// served normally AND carries the Allow-Origin header so the browser accepts the
|
||||
// response.
|
||||
func TestCORSActualRequestCarriesHeader(t *testing.T) {
|
||||
const origin = "http://localhost:5173"
|
||||
ts := newCORSServer(t, origin)
|
||||
|
||||
req, _ := http.NewRequest(http.MethodGet, ts.URL+"/healthz", nil)
|
||||
req.Header.Set("Origin", origin)
|
||||
|
||||
resp, err := http.DefaultClient.Do(req)
|
||||
if err != nil {
|
||||
t.Fatalf("get: %v", err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
t.Fatalf("healthz status = %d, want 200", resp.StatusCode)
|
||||
}
|
||||
if got := resp.Header.Get("Access-Control-Allow-Origin"); got != origin {
|
||||
t.Fatalf("Allow-Origin = %q, want %q", got, origin)
|
||||
}
|
||||
}
|
||||
|
||||
// TestCORSDisabledByDefault: with an empty allowlist no Access-Control header is
|
||||
// ever emitted (CORS off) and requests behave exactly as before. This guards the
|
||||
// opt-in invariant: untouched deployments are unaffected.
|
||||
func TestCORSDisabledByDefault(t *testing.T) {
|
||||
ts := newCORSServer(t) // no origins
|
||||
|
||||
req, _ := http.NewRequest(http.MethodGet, ts.URL+"/healthz", nil)
|
||||
req.Header.Set("Origin", "http://localhost:5173")
|
||||
|
||||
resp, err := http.DefaultClient.Do(req)
|
||||
if err != nil {
|
||||
t.Fatalf("get: %v", err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
t.Fatalf("healthz status = %d, want 200", resp.StatusCode)
|
||||
}
|
||||
if got := resp.Header.Get("Access-Control-Allow-Origin"); got != "" {
|
||||
t.Fatalf("Allow-Origin emitted with CORS off: %q", got)
|
||||
}
|
||||
}
|
||||
|
||||
// TestCORSNativeClientUnaffected: a request with no Origin header (a native Go/Kotlin
|
||||
// client) is processed normally and gets no CORS headers, even when an allowlist is
|
||||
// configured.
|
||||
func TestCORSNativeClientUnaffected(t *testing.T) {
|
||||
ts := newCORSServer(t, "http://localhost:5173")
|
||||
|
||||
resp, err := http.Get(ts.URL + "/healthz") // no Origin header
|
||||
if err != nil {
|
||||
t.Fatalf("get: %v", err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
t.Fatalf("healthz status = %d, want 200", resp.StatusCode)
|
||||
}
|
||||
if got := resp.Header.Get("Access-Control-Allow-Origin"); got != "" {
|
||||
t.Fatalf("Allow-Origin set for a no-Origin native client: %q", got)
|
||||
}
|
||||
}
|
||||
@@ -87,6 +87,16 @@ type Server struct {
|
||||
// posture a secure cluster requires (audit 0008 N1). It is set by the command;
|
||||
// the zero value (all false) reflects an unsecured dev node.
|
||||
Posture Posture
|
||||
|
||||
// AllowedOrigins is the CORS allowlist of browser Origin headers permitted to
|
||||
// call the control plane cross-origin. It exists so a browser-native client
|
||||
// (uniweb) can talk to membershipd directly, the way the Go/Kotlin clients
|
||||
// already do over a non-browser transport (issue uniweb/0001). Native clients
|
||||
// send no Origin header and are unaffected. The zero value (empty) keeps CORS
|
||||
// OFF — no Access-Control headers are emitted and the server behaves exactly as
|
||||
// before — so this is opt-in per deployment. Entries are matched exactly (scheme
|
||||
// + host + port); never use "*" with credentials. Set by the command from a flag.
|
||||
AllowedOrigins []string
|
||||
}
|
||||
|
||||
// Posture describes the security posture a membershipd node runs with. It is
|
||||
@@ -143,6 +153,15 @@ func (s *Server) UseReplicatedNonces(js jetstream.JetStream, replicas int) error
|
||||
func (s *Server) ServeHTTP(w http.ResponseWriter, r *http.Request) {
|
||||
now := time.Now()
|
||||
|
||||
// CORS runs before everything else so a browser preflight never pays the rate
|
||||
// limit or auth cost. When the request carries an allowed Origin we echo the
|
||||
// Access-Control headers; a preflight (OPTIONS) is answered here and short-
|
||||
// circuits the pipeline. With an empty allowlist this is a no-op, so non-browser
|
||||
// clients and untouched deployments behave exactly as before (issue uniweb/0001).
|
||||
if s.applyCORS(w, r) {
|
||||
return // preflight handled
|
||||
}
|
||||
|
||||
// Per-IP rate limit runs first, ahead of auth and body reads, so a flood is
|
||||
// shed at the cheapest possible point. The health probe is exempt so liveness
|
||||
// checks are never throttled.
|
||||
@@ -221,6 +240,54 @@ func (s *Server) ServeHTTP(w http.ResponseWriter, r *http.Request) {
|
||||
s.mux.ServeHTTP(w, r.WithContext(withSigner(r.Context(), res.endpoint, res.pubHex)))
|
||||
}
|
||||
|
||||
// applyCORS handles cross-origin requests for the control plane. When the request
|
||||
// carries an Origin in the allowlist it sets the Access-Control-Allow-* response
|
||||
// headers so the browser accepts the eventual response; when the request is a CORS
|
||||
// preflight (OPTIONS) it writes the preflight reply and returns true so ServeHTTP
|
||||
// short-circuits before the rate limiter and auth ever run. It returns false for
|
||||
// every non-preflight request — including same-origin and native clients that send
|
||||
// no Origin header — leaving the normal pipeline to run unchanged. With an empty
|
||||
// AllowedOrigins it never sets a header (CORS is off): the opt-in default.
|
||||
func (s *Server) applyCORS(w http.ResponseWriter, r *http.Request) (preflight bool) {
|
||||
origin := r.Header.Get("Origin")
|
||||
allowed := origin != "" && s.originAllowed(origin)
|
||||
if allowed {
|
||||
h := w.Header()
|
||||
h.Set("Access-Control-Allow-Origin", origin)
|
||||
// Vary: Origin so a cache never serves an allow-listed response to another
|
||||
// origin. Add (not Set) to preserve any Vary the handler may add later.
|
||||
h.Add("Vary", "Origin")
|
||||
h.Set("Access-Control-Allow-Methods", "GET, POST, OPTIONS")
|
||||
h.Set("Access-Control-Allow-Headers", "Content-Type, Authorization")
|
||||
h.Set("Access-Control-Max-Age", "600")
|
||||
}
|
||||
if r.Method == http.MethodOptions {
|
||||
// Answer the preflight here so it never reaches the rate limiter or auth. An
|
||||
// allowed origin gets 204 with the headers above; a disallowed or missing
|
||||
// origin gets 403 with no Access-Control headers, so the browser blocks the
|
||||
// real cross-origin request.
|
||||
if allowed {
|
||||
w.WriteHeader(http.StatusNoContent)
|
||||
} else {
|
||||
w.WriteHeader(http.StatusForbidden)
|
||||
}
|
||||
return true
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// originAllowed reports whether origin is in the CORS allowlist. Matching is exact
|
||||
// (scheme + host + port): a browser Origin is an opaque string, so an exact compare
|
||||
// is both correct and the safest policy (no wildcard, no suffix tricks).
|
||||
func (s *Server) originAllowed(origin string) bool {
|
||||
for _, o := range s.AllowedOrigins {
|
||||
if o == origin {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// isBodyTooLarge reports whether err is the sentinel returned by MaxBytesReader
|
||||
// when the body exceeds its limit, so the middleware can map it to 413.
|
||||
func isBodyTooLarge(err error) bool {
|
||||
|
||||
Reference in New Issue
Block a user