From 36f4ba0eaf79d0807f76ebd9ab23a194404f1dcf Mon Sep 17 00:00:00 2001 From: Egutierrez Date: Sat, 13 Jun 2026 22:11:39 +0200 Subject: [PATCH] feat(embeddednats): optional WebSocket listener for browser clients MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add WebsocketConfig to ServerConfig so the embedded nats-server can expose an additional WebSocket port (nats.ws) alongside the TCP data plane. This lets a browser SPA speak the NATS protocol directly, the way native TCP peers (Go, Kotlin/android) already do — the first enabler for uniweb becoming a browser-native client with no Go gateway (issue uniweb/0001, Phase 0). The client authenticator applies to WebSocket connections too, so this adds a transport, not a trust bypass. Plain ws:// is used only without TLS (loopback dev); a certificate yields wss://. An empty AllowedOrigins enforces same-origin. Nil WebsocketConfig keeps the server TCP-only, so existing single-node and cluster deployments are unchanged. Tests: WebSocket listener opens and completes the upgrade handshake (101); no listener opens when WebsocketConfig is nil. --- pkg/embeddednats/embeddednats.go | 59 ++++++++++++++++ pkg/embeddednats/websocket_test.go | 108 +++++++++++++++++++++++++++++ 2 files changed, 167 insertions(+) create mode 100644 pkg/embeddednats/websocket_test.go diff --git a/pkg/embeddednats/embeddednats.go b/pkg/embeddednats/embeddednats.go index 9cd02c6c..a6acc119 100644 --- a/pkg/embeddednats/embeddednats.go +++ b/pkg/embeddednats/embeddednats.go @@ -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 diff --git a/pkg/embeddednats/websocket_test.go b/pkg/embeddednats/websocket_test.go new file mode 100644 index 00000000..162a8949 --- /dev/null +++ b/pkg/embeddednats/websocket_test.go @@ -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) + } +}