feat(embeddednats): optional WebSocket listener for browser clients
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.
This commit is contained in:
@@ -79,6 +79,42 @@ type ServerConfig struct {
|
|||||||
// availability (issue 0003a). Nil keeps the server standalone (the legacy
|
// availability (issue 0003a). Nil keeps the server standalone (the legacy
|
||||||
// single-node behavior).
|
// single-node behavior).
|
||||||
Cluster *ClusterConfig
|
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
|
// 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
|
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 cfg.Cluster != nil {
|
||||||
if err := applyClusterOpts(opts, cfg.Cluster); err != nil {
|
if err := applyClusterOpts(opts, cfg.Cluster); err != nil {
|
||||||
return nil, err
|
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)
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user