nats: a WebSocket listener for a page in a browser, from the overlay only (hq research 036)
mesh/merge-gate head e6d5ead50dfd merged into trunk 44e7002d3c84 — fail: builds nats, sent nowhere; merging this needs a person's bus upgrade; 1 wait(s) fo…
mesh/repo-check head e6d5ead50dfd merged into trunk 44e7002d3c84 — fail: head e6d5ead50dfd does not merge cleanly into trunk 44e7002d3c84: in conflict modu…
mesh/delivery stopped: the pull request closed unmerged
mesh/merge-gate head e6d5ead50dfd merged into trunk 44e7002d3c84 — fail: builds nats, sent nowhere; merging this needs a person's bus upgrade; 1 wait(s) fo…
mesh/repo-check head e6d5ead50dfd merged into trunk 44e7002d3c84 — fail: head e6d5ead50dfd does not merge cleanly into trunk 44e7002d3c84: in conflict modu…
mesh/delivery stopped: the pull request closed unmerged
The bus has no way for a browser to reach it (research 036, gap G1). The server now also listens over WebSocket on 4223: published from the mesh like 4222, so the machine's filter admits it from the overlay and nothing else; no TLS, acceptable only because the overlay is WireGuard and a browser cannot be made to trust the mesh's own certificate; compression on. The user a page connects as is the read-only view the controller composes (mesh-controller, `bus view-credential`). A test parses the declared configuration with the server itself, holds the opening, the published port and the listener to one port, and connects a client over WebSocket. The bus module's policy is `record`: this reaches the bus's machine only through the planned step a person starts (`bus upgrade`), as every bus change does.
This commit is contained in:
@@ -0,0 +1,190 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"crypto/ecdsa"
|
||||
"crypto/elliptic"
|
||||
"crypto/rand"
|
||||
"crypto/x509"
|
||||
"crypto/x509/pkix"
|
||||
"encoding/json"
|
||||
"encoding/pem"
|
||||
"math/big"
|
||||
"net"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strconv"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/nats-io/nats-server/v2/server"
|
||||
"github.com/nats-io/nats.go"
|
||||
)
|
||||
|
||||
// The WebSocket listener the manifest declares (novox/hq research 036, gap G1), read by the server the
|
||||
// image runs: the module's own configuration, as declared, parsed by nats-server itself, with its TLS
|
||||
// paths and store pointed at this test's directories and a user list of its own beside it — and a
|
||||
// client connected over WebSocket to it, through the port the manifest opens from the mesh and the
|
||||
// container publishes.
|
||||
func TestTheDeclaredConfigurationListensForWebSocketFromTheMesh(t *testing.T) {
|
||||
m := manifest(t)
|
||||
|
||||
// The three places the listener is declared agree on one port: the server's own configuration, the
|
||||
// opening from the mesh (the machine's filter admits it from the overlay only), and the container's
|
||||
// published port.
|
||||
var opening map[string]any
|
||||
for _, l := range m["listens"].([]any) {
|
||||
if entry := l.(map[string]any); entry["name"] == "bus-websocket" {
|
||||
opening = entry
|
||||
}
|
||||
}
|
||||
if opening == nil {
|
||||
t.Fatal("the manifest opens no bus-websocket port")
|
||||
}
|
||||
if opening["from"] != "mesh" {
|
||||
t.Fatalf("the WebSocket listener is open from %q; it is open from the mesh only, because it has no TLS", opening["from"])
|
||||
}
|
||||
port := int(opening["port"].(float64))
|
||||
published := false
|
||||
for _, r := range m["resources"].([]any) {
|
||||
if res := r.(map[string]any); res["id"] == "server" {
|
||||
for _, p := range res["ports"].([]any) {
|
||||
published = published || p.(string) == itoa(port)+":"+itoa(port)
|
||||
}
|
||||
}
|
||||
}
|
||||
if !published {
|
||||
t.Fatalf("the container does not publish %d:%d", port, port)
|
||||
}
|
||||
|
||||
dir := t.TempDir()
|
||||
conf := serverConfOf(t, m)
|
||||
conf = strings.ReplaceAll(conf, "/tls/", selfSignedInto(t, dir)+"/")
|
||||
conf = strings.ReplaceAll(conf, `store_dir: "/data"`, `store_dir: "`+filepath.Join(dir, "data")+`"`)
|
||||
if err := os.WriteFile(filepath.Join(dir, "nats.conf"), []byte(conf), 0o600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
// A user list of this test's own, in the place the mesh writes it.
|
||||
users := "accounts { MESH { jetstream: enabled\n users = [ { user: \"view\", password: \"view\", permissions: {\n" +
|
||||
" publish: { allow: [\"probe\"] }\n subscribe: { allow: [\"probe\", \"_INBOX.view.>\"] } } } ]\n} }\n"
|
||||
if err := os.WriteFile(filepath.Join(dir, "accounts.conf"), []byte(users), 0o600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
opts, err := server.ProcessConfigFile(filepath.Join(dir, "nats.conf"))
|
||||
if err != nil {
|
||||
t.Fatalf("the server refuses the module's own configuration: %v", err)
|
||||
}
|
||||
if opts.Websocket.Port != port {
|
||||
t.Fatalf("the server's WebSocket listener is on %d and the opening from the mesh is %d", opts.Websocket.Port, port)
|
||||
}
|
||||
if !opts.Websocket.NoTLS {
|
||||
t.Fatal("the WebSocket listener wants TLS: a browser cannot be made to trust the mesh's own certificate, and the overlay is the tunnel")
|
||||
}
|
||||
if !opts.Websocket.Compression {
|
||||
t.Fatal("the WebSocket listener does not compress")
|
||||
}
|
||||
if opts.Websocket.Host != "0.0.0.0" {
|
||||
t.Fatalf("inside the container the listener binds %q; it binds every interface, and the machine publishes it from the mesh", opts.Websocket.Host)
|
||||
}
|
||||
|
||||
// Run it, on ports the system chooses, so this runs beside a live bus.
|
||||
opts.Host, opts.Port = "127.0.0.1", -1
|
||||
opts.HTTPHost, opts.HTTPPort = "127.0.0.1", 0
|
||||
opts.Websocket.Host, opts.Websocket.Port = "127.0.0.1", -1
|
||||
opts.NoLog, opts.NoSigs = true, true
|
||||
s, err := server.NewServer(opts)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
go s.Start()
|
||||
if !s.ReadyForConnections(10 * time.Second) {
|
||||
t.Fatal("the server did not start")
|
||||
}
|
||||
defer s.Shutdown()
|
||||
|
||||
nc, err := nats.Connect(s.WebsocketURL(), nats.UserInfo("view", "view"), nats.CustomInboxPrefix("_INBOX.view"), nats.Compression(true))
|
||||
if err != nil {
|
||||
t.Fatalf("a WebSocket client could not connect to %s: %v", s.WebsocketURL(), err)
|
||||
}
|
||||
defer nc.Close()
|
||||
if !strings.HasPrefix(nc.ConnectedUrl(), "ws://") {
|
||||
t.Fatalf("connected to %s, not over WebSocket", nc.ConnectedUrl())
|
||||
}
|
||||
sub, err := nc.SubscribeSync("probe")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := nc.Publish("probe", []byte("round trip")); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if msg, err := sub.NextMsg(5 * time.Second); err != nil || string(msg.Data) != "round trip" {
|
||||
t.Fatalf("no round trip over WebSocket: %v", err)
|
||||
}
|
||||
// And the client port beside it still requires TLS: a plain connection is refused.
|
||||
if _, err := nats.Connect(s.ClientURL(), nats.UserInfo("view", "view"), nats.Timeout(2*time.Second)); err == nil {
|
||||
t.Fatal("the client port accepted a connection without TLS")
|
||||
}
|
||||
}
|
||||
|
||||
func itoa(n int) string { return strconv.Itoa(n) }
|
||||
|
||||
// manifest is the nats module's own definition, beside this program.
|
||||
func manifest(t *testing.T) map[string]any {
|
||||
t.Helper()
|
||||
raw, err := os.ReadFile("../../module.json")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
var m map[string]any
|
||||
if err := json.Unmarshal(raw, &m); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return m
|
||||
}
|
||||
|
||||
func serverConfOf(t *testing.T, m map[string]any) string {
|
||||
t.Helper()
|
||||
for _, r := range m["resources"].([]any) {
|
||||
if res := r.(map[string]any); res["id"] == "server-conf" {
|
||||
return res["content"].(string)
|
||||
}
|
||||
}
|
||||
t.Fatal("the nats module declares no server configuration")
|
||||
return ""
|
||||
}
|
||||
|
||||
// selfSignedInto writes a throwaway certificate and key as tls.crt and tls.key into a directory, as the
|
||||
// mesh mounts the broker's at /tls, and returns that directory.
|
||||
func selfSignedInto(t *testing.T, dir string) string {
|
||||
t.Helper()
|
||||
key, err := ecdsa.GenerateKey(elliptic.P256(), rand.Reader)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
template := &x509.Certificate{
|
||||
SerialNumber: big.NewInt(1), Subject: pkix.Name{CommonName: "test bus"},
|
||||
NotBefore: time.Now().Add(-time.Hour), NotAfter: time.Now().Add(time.Hour),
|
||||
KeyUsage: x509.KeyUsageDigitalSignature | x509.KeyUsageKeyEncipherment,
|
||||
ExtKeyUsage: []x509.ExtKeyUsage{x509.ExtKeyUsageServerAuth},
|
||||
IPAddresses: []net.IP{net.ParseIP("127.0.0.1")},
|
||||
}
|
||||
der, err := x509.CreateCertificate(rand.Reader, template, template, &key.PublicKey, key)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
raw, err := x509.MarshalECPrivateKey(key)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
tlsDir := filepath.Join(dir, "tls")
|
||||
if err := os.MkdirAll(tlsDir, 0o700); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := os.WriteFile(filepath.Join(tlsDir, "tls.crt"), pem.EncodeToMemory(&pem.Block{Type: "CERTIFICATE", Bytes: der}), 0o600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := os.WriteFile(filepath.Join(tlsDir, "tls.key"), pem.EncodeToMemory(&pem.Block{Type: "EC PRIVATE KEY", Bytes: raw}), 0o600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return tlsDir
|
||||
}
|
||||
Reference in New Issue
Block a user