feat(ws): opt-in allowed origins and observer key (#170)

This commit is contained in:
MrAlders0n
2026-09-27 11:41:11 -04:00
committed by GitHub
parent 91b4b457b5
commit dec643a2ad
14 changed files with 442 additions and 73 deletions
+1
View File
@@ -309,6 +309,7 @@ func main() {
r := router.New(h, reader, []*ingest.Worker{broker1, broker2}, router.Options{
MaxConnsPerIP: resolved.MaxConnsPerIP,
MaxConnectsPerMinute: resolved.MaxConnectsPerMinute,
WSAllowedOrigins: cfg.WebSocket.AllowedOrigins,
CORS: cfg.CORS, Server: cfg.Server, Auth: cfg.Auth, RateLimit: resolved.RateLimit,
AdminRoutes: map[string]http.Handler{
"/accounts": handlers.AccountsRouter(store),
+3
View File
@@ -149,6 +149,9 @@ websocket:
# Within this rate budget, a full concurrent cap accepts then closes with 1013
# before hello, allowing browsers to back off without evicting existing clients.
max_connects_per_minute: 10
# Other sites allowed to open /ws. Exact scheme://host[:port], no wildcards.
#allowed_origins:
# - https://example.com
# Node staleness, deletion, and clock-drift thresholds.
#nodes:
+1
View File
@@ -14,6 +14,7 @@ import (
type Options struct {
MaxConnsPerIP int
MaxConnectsPerMinute int
WSAllowedOrigins []string
CORS config.CORSConfig
Server config.ServerConfig
Auth config.AuthConfig
+1 -1
View File
@@ -91,7 +91,7 @@ func New(h *hub.Hub, reader api.Reader, workers []*ingest.Worker, opts Options)
))
// ── WebSocket ────────────────────────────────────────────────────────────
r.Get("/ws", ws.Handler(h, reader, opts.MaxConnsPerIP, opts.MaxConnectsPerMinute))
r.Get("/ws", ws.Handler(h, reader, opts.MaxConnsPerIP, opts.MaxConnectsPerMinute, opts.WSAllowedOrigins))
// ── Public REST API (v1) ─────────────────────────────────────────────────
r.Route("/api/v1", func(r chi.Router) {
+23
View File
@@ -8,8 +8,10 @@ package config
import (
"fmt"
"net/netip"
"net/url"
"os"
"path/filepath"
"strings"
"time"
"gopkg.in/yaml.v3"
@@ -241,6 +243,8 @@ type WebSocketConfig struct {
// MaxConnectsPerMinute limits upgrade attempts, including failed handshakes.
// Zero/omitted defaults to 10; IPv6 addresses share a /64 attempt budget.
MaxConnectsPerMinute int `yaml:"max_connects_per_minute"`
// AllowedOrigins are extra exact origins allowed to open /ws. Defaults to same-host only.
AllowedOrigins []string `yaml:"allowed_origins"`
}
// PacketsConfig controls packet retention behaviour.
@@ -396,6 +400,11 @@ func Load(path string) (*Config, error) {
if cfg.WebSocket.MaxConnectsPerMinute < 0 {
return nil, fmt.Errorf("websocket.max_connects_per_minute must be positive or zero for the default")
}
for i, origin := range cfg.WebSocket.AllowedOrigins {
if err := validateOrigin(origin); err != nil {
return nil, fmt.Errorf("websocket.allowed_origins[%d]: %w", i, err)
}
}
configDir := filepath.Dir(path)
for iata, details := range cfg.IATAs {
if details.BorderFile != "" && !filepath.IsAbs(details.BorderFile) {
@@ -406,6 +415,20 @@ func Load(path string) (*Config, error) {
return cfg, nil
}
// validateOrigin rejects wildcards, which the WebSocket library would treat as patterns.
func validateOrigin(origin string) error {
u, err := url.Parse(origin)
if err != nil {
return fmt.Errorf("invalid origin %q: %w", origin, err)
}
if (u.Scheme != "http" && u.Scheme != "https") || u.Host == "" || u.User != nil ||
u.Path != "" || u.RawQuery != "" || u.Fragment != "" || u.Opaque != "" ||
strings.ContainsAny(origin, `*?[]\`) {
return fmt.Errorf("origin %q must be exactly scheme://host[:port] with an http or https scheme", origin)
}
return nil
}
// Resolve returns a ResolvedConfig with defaults applied for any zero values.
func Resolve(cfg *Config) ResolvedConfig {
r := ResolvedConfig{
+32
View File
@@ -35,3 +35,35 @@ func TestWebSocketConnectConfig(t *testing.T) {
})
}
}
func TestWebSocketAllowedOrigins(t *testing.T) {
for _, tc := range []struct {
name, origin string
wantError bool
}{
{"https", "https://example.com", false},
{"http with port", "http://localhost:5173", false},
{"wildcard", "*", true},
{"wildcard host", "https://*.example.com", true},
{"no scheme", "example.com", true},
{"other scheme", "ftp://example.com", true},
{"path", "https://example.com/", true},
{"query", "https://example.com?x=1", true},
{"userinfo", "https://user@example.com", true},
} {
t.Run(tc.name, func(t *testing.T) {
path := filepath.Join(t.TempDir(), "config.yaml")
yaml := "websocket: {allowed_origins: [\"" + tc.origin + "\"]}"
if err := os.WriteFile(path, []byte(yaml), 0600); err != nil {
t.Fatal(err)
}
cfg, err := Load(path)
if (err != nil) != tc.wantError {
t.Fatalf("Load error = %v, want error = %v", err, tc.wantError)
}
if err == nil && (len(cfg.WebSocket.AllowedOrigins) != 1 || cfg.WebSocket.AllowedOrigins[0] != tc.origin) {
t.Fatalf("allowed_origins = %v", cfg.WebSocket.AllowedOrigins)
}
})
}
}
+60 -26
View File
@@ -20,6 +20,7 @@ import (
"fmt"
"log/slog"
"slices"
"sync/atomic"
)
// EventType identifies the kind of server-push event. These match the
@@ -40,10 +41,13 @@ const (
// fields for clients that opted into them via configure (currently just
// resolvedPath on packetObservation events). Left nil for event types that
// don't have an opt-in variant; the hub falls back to Payload in that case.
// The WithKey variants also carry observerPublicKey.
type Event struct {
Type EventType
Payload json.RawMessage
PayloadResolved json.RawMessage
Type EventType
Payload json.RawMessage
PayloadResolved json.RawMessage
PayloadWithKey json.RawMessage
PayloadResolvedWithKey json.RawMessage
// Routing metadata used by the hub to match subscriptions.
// Populated by the ingest layer before calling Broadcast.
@@ -73,11 +77,35 @@ type Client struct {
subscriptions map[string]Scope // OR semantics: event matches if it matches any scope entry
// ResolvePath is a connection-wide opt-in (not per-subscription), set via
// SetResolvePath ("configure" WS messages). Freely toggleable at any
// Configure ("configure" WS messages). Freely toggleable at any
// point during the connection's lifetime. Only ever read/written inside
// Run(), so it needs no locking despite Client being shared with the WS
// goroutines.
ResolvePath bool
// IncludeObserverKey works like ResolvePath, for observerPublicKey.
IncludeObserverKey bool
}
// ClientOptions are the connection-wide settings a "configure" message sets.
type ClientOptions struct {
ResolvePath bool
IncludeObserverKey bool
}
// payloadFor falls back to a narrower variant if the event lacks one.
func (e Event) payloadFor(c *Client) json.RawMessage {
if c.IncludeObserverKey {
if c.ResolvePath && e.PayloadResolvedWithKey != nil {
return e.PayloadResolvedWithKey
}
if !c.ResolvePath && e.PayloadWithKey != nil {
return e.PayloadWithKey
}
}
if c.ResolvePath && e.PayloadResolved != nil {
return e.PayloadResolved
}
return e.Payload
}
// matches returns true if the event satisfies at least one of the client's
@@ -122,10 +150,13 @@ type Hub struct {
unsubscribe chan unsubscribeMsg
remove chan *Client
broadcast chan Event
// observerKeyClients counts registered clients with IncludeObserverKey set.
observerKeyClients atomic.Int64
}
// subscribeMsg carries a client registration, a scope subscription, or a
// configure (resolvePath toggle) request — all three go through this single
// configure request — all three go through this single
// channel, not separate ones, specifically so that Go's same-channel FIFO
// guarantee orders them relative to NewClient's registration message. A
// separate "configure" channel raced against registration: select() has no
@@ -139,7 +170,7 @@ type subscribeMsg struct {
subscriptionID string
isConfigure bool
resolvePath bool
options ClientOptions
}
type unsubscribeMsg struct {
@@ -147,14 +178,6 @@ type unsubscribeMsg struct {
subscriptionID string
}
// configureMsg carries a connection-wide setting change, decoupled from the
// subscribe/unsubscribe scope mechanics so it can be toggled independently
// and repeatedly over the life of a connection.
type configureMsg struct {
client *Client
resolvePath bool
}
// New creates a Hub. Call Run() in a goroutine before using it.
func New() *Hub {
return &Hub{
@@ -192,13 +215,15 @@ func (h *Hub) RemoveScope(c *Client, id string) {
h.unsubscribe <- unsubscribeMsg{client: c, subscriptionID: id}
}
// SetResolvePath toggles a client's opt-in to the resolvedPath variant of
// packetObservation events. Unlike scopes, this is a single connection-wide
// flag (not additive/OR'd) and can be flipped on or off at any point during
// the connection's lifetime — takes effect on the next broadcast after the
// hub processes it.
func (h *Hub) SetResolvePath(c *Client, enabled bool) {
h.subscribe <- subscribeMsg{client: c, isConfigure: true, resolvePath: enabled}
// Configure replaces a client's connection-wide options. Unlike scopes, it is
// not additive.
func (h *Hub) Configure(c *Client, opts ClientOptions) {
h.subscribe <- subscribeMsg{client: c, isConfigure: true, options: opts}
}
// ObserverKeyWanted lets ingest skip the key variants when nobody wants them.
func (h *Hub) ObserverKeyWanted() bool {
return h.observerKeyClients.Load() > 0
}
// Remove deregisters a client and closes its Send channel.
@@ -236,9 +261,17 @@ func (h *Hub) Run() {
// Registration with no scope yet (NewClient path).
clients[msg.client] = struct{}{}
case msg.isConfigure:
// SetResolvePath path — client must already be registered.
// Configure path — client must already be registered.
if _, ok := clients[msg.client]; ok {
msg.client.ResolvePath = msg.resolvePath
if msg.client.IncludeObserverKey != msg.options.IncludeObserverKey {
if msg.options.IncludeObserverKey {
h.observerKeyClients.Add(1)
} else {
h.observerKeyClients.Add(-1)
}
}
msg.client.ResolvePath = msg.options.ResolvePath
msg.client.IncludeObserverKey = msg.options.IncludeObserverKey
}
default:
// AddScope path — client must already be registered.
@@ -255,6 +288,9 @@ func (h *Hub) Run() {
case c := <-h.remove:
if _, ok := clients[c]; ok {
delete(clients, c)
if c.IncludeObserverKey {
h.observerKeyClients.Add(-1)
}
close(c.Send)
close(c.laggedCH)
}
@@ -265,9 +301,7 @@ func (h *Hub) Run() {
continue
}
outEvt := evt
if c.ResolvePath && evt.PayloadResolved != nil {
outEvt.Payload = evt.PayloadResolved
}
outEvt.Payload = evt.payloadFor(c)
select {
case c.Send <- outEvt:
default:
+60 -5
View File
@@ -269,7 +269,7 @@ func TestHub_ResolvePath_OptedIn_GetsResolvedPayload(t *testing.T) {
h := runHub(t)
c := h.NewClient()
h.AddScope(c, "sub1", Scope{Events: []EventType{EventPacketObservation}})
h.SetResolvePath(c, true)
h.Configure(c, ClientOptions{ResolvePath: true})
time.Sleep(10 * time.Millisecond)
@@ -294,7 +294,7 @@ func TestHub_ResolvePath_DefaultOff_GetsBasePayload(t *testing.T) {
h := runHub(t)
c := h.NewClient()
h.AddScope(c, "sub1", Scope{Events: []EventType{EventPacketObservation}})
// no SetResolvePath call — default is off
// no Configure call — default is off
time.Sleep(10 * time.Millisecond)
@@ -320,7 +320,7 @@ func TestHub_ResolvePath_OptedIn_NoResolvedVariant_FallsBackToBase(t *testing.T)
c := h.NewClient()
// e.g. nodeUpdate events never carry a PayloadResolved variant
h.AddScope(c, "sub1", Scope{Events: []EventType{EventNodeUpdate}})
h.SetResolvePath(c, true)
h.Configure(c, ClientOptions{ResolvePath: true})
time.Sleep(10 * time.Millisecond)
@@ -367,15 +367,70 @@ func TestHub_ResolvePath_ToggleableLive(t *testing.T) {
t.Errorf("expected base payload before opting in, got %s", got)
}
h.SetResolvePath(c, true)
h.Configure(c, ClientOptions{ResolvePath: true})
time.Sleep(10 * time.Millisecond)
if got := broadcastAndRead(); got != `{"resolvedPath":[{"confidence":"high"}]}` {
t.Errorf("expected resolved payload after opting in, got %s", got)
}
h.SetResolvePath(c, false)
h.Configure(c, ClientOptions{})
time.Sleep(10 * time.Millisecond)
if got := broadcastAndRead(); got != `{"resolvedPath":null}` {
t.Errorf("expected base payload after opting back out, got %s", got)
}
}
func TestEvent_PayloadFor(t *testing.T) {
full := Event{
Payload: json.RawMessage(`base`),
PayloadResolved: json.RawMessage(`resolved`),
PayloadWithKey: json.RawMessage(`key`),
PayloadResolvedWithKey: json.RawMessage(`resolved+key`),
}
baseOnly := Event{Payload: json.RawMessage(`base`), PayloadResolved: json.RawMessage(`resolved`)}
for _, tc := range []struct {
name string
evt Event
opts ClientOptions
want string
}{
{"default", full, ClientOptions{}, "base"},
{"resolve", full, ClientOptions{ResolvePath: true}, "resolved"},
{"key", full, ClientOptions{IncludeObserverKey: true}, "key"},
{"both", full, ClientOptions{ResolvePath: true, IncludeObserverKey: true}, "resolved+key"},
{"key missing", baseOnly, ClientOptions{IncludeObserverKey: true}, "base"},
{"both, key missing", baseOnly, ClientOptions{ResolvePath: true, IncludeObserverKey: true}, "resolved"},
} {
t.Run(tc.name, func(t *testing.T) {
c := &Client{ResolvePath: tc.opts.ResolvePath, IncludeObserverKey: tc.opts.IncludeObserverKey}
if got := string(tc.evt.payloadFor(c)); got != tc.want {
t.Fatalf("payloadFor = %q, want %q", got, tc.want)
}
})
}
}
func TestHub_ObserverKeyWanted_TracksOptIns(t *testing.T) {
h := runHub(t)
waitFor := func(want bool) {
t.Helper()
deadline := time.Now().Add(time.Second)
for h.ObserverKeyWanted() != want {
if time.Now().After(deadline) {
t.Fatalf("ObserverKeyWanted = %t, want %t", !want, want)
}
time.Sleep(time.Millisecond)
}
}
a, b := h.NewClient(), h.NewClient()
if h.ObserverKeyWanted() {
t.Fatal("no client opted in yet")
}
h.Configure(a, ClientOptions{IncludeObserverKey: true})
h.Configure(a, ClientOptions{IncludeObserverKey: true}) // repeat must not double count
h.Configure(b, ClientOptions{IncludeObserverKey: true})
waitFor(true)
h.Configure(a, ClientOptions{ResolvePath: true})
h.Remove(b)
waitFor(false)
}
+25 -14
View File
@@ -378,32 +378,43 @@ func (w *Worker) broadcast(eventType hub.EventType, iata string, payloadType uin
})
}
// broadcastPacketObservation marshals evt twice: once as-is (the default
// broadcastPacketObservation marshals evt once as-is (the default
// payload every packetObservation subscriber gets) and once with
// resolvedPath populated (delivered only to connections that opted in via
// the "configure" WS message; see hub.Client.ResolvePath). resolvedPath is
// passed in rather than computed here because the caller already has the
// path-hash resolution results in hand from other per-packet work (known
// route detection, capability detection) — this adds no extra DB calls.
func (w *Worker) broadcastPacketObservation(iata string, payloadType uint8, evt packetObservationEvent, resolvedPath []api.ResolvedHop) {
func (w *Worker) broadcastPacketObservation(iata string, payloadType uint8, evt packetObservationEvent, resolvedPath []api.ResolvedHop, observerKey string) {
base, err := json.Marshal(evt)
if err != nil {
w.log.Error("failed to marshal packetObservation event", "error", err)
return
}
evt.Observation.ResolvedPath = resolvedPath
resolved, err := json.Marshal(evt)
if err != nil {
w.log.Error("failed to marshal packetObservation event (resolved variant)", "error", err)
resolved = nil // fall back to base-only; not fatal
out := hub.Event{
Type: hub.EventPacketObservation,
Payload: base,
IATA: iata,
PayloadType: payloadType,
}
w.hub.Broadcast(hub.Event{
Type: hub.EventPacketObservation,
Payload: base,
PayloadResolved: resolved,
IATA: iata,
PayloadType: payloadType,
})
evt.Observation.ResolvedPath = resolvedPath
if out.PayloadResolved, err = json.Marshal(evt); err != nil {
w.log.Error("failed to marshal packetObservation event (resolved variant)", "error", err)
out.PayloadResolved = nil
}
if w.hub.ObserverKeyWanted() {
evt.Observation.ObserverPublicKey = observerKey
if out.PayloadResolvedWithKey, err = json.Marshal(evt); err != nil {
w.log.Error("failed to marshal packetObservation event (resolved key variant)", "error", err)
out.PayloadResolvedWithKey = nil
}
evt.Observation.ResolvedPath = nil
if out.PayloadWithKey, err = json.Marshal(evt); err != nil {
w.log.Error("failed to marshal packetObservation event (key variant)", "error", err)
out.PayloadWithKey = nil
}
}
w.hub.Broadcast(out)
}
// parseNumber handles RSSI and SNR fields that different observer types send as
+80
View File
@@ -0,0 +1,80 @@
// Copyright 2026 Beacon Contributors
// SPDX-License-Identifier: AGPL-3.0-or-later
package ingest
import (
"context"
"encoding/json"
"strings"
"testing"
"time"
"github.com/MeshCore-Beacon/beacon-server/internal/api"
"github.com/MeshCore-Beacon/beacon-server/internal/hub"
)
func TestBroadcastPacketObservation_ObserverKeyOptIn(t *testing.T) {
const key = "ab01020304050607080910111213141516171819202122232425262728293031"
w, _ := newTestWorker()
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
defer cancel()
go w.hub.Run()
client := w.hub.NewClient()
defer w.hub.Remove(client)
w.hub.AddScope(client, "all", hub.Scope{Events: []hub.EventType{hub.EventPacketObservation, hub.EventObserverStatus}})
waitForSummarySubscriber(t, ctx, w.hub, client)
broadcast := func() hub.Event {
t.Helper()
w.broadcastPacketObservation("YVR", 4, packetObservationEvent{}, []api.ResolvedHop{{}}, key)
for {
select {
case evt := <-client.Send:
if evt.Type == hub.EventPacketObservation {
return evt
}
case <-ctx.Done():
t.Fatal("packetObservation not delivered")
}
}
}
observation := func(raw json.RawMessage) map[string]json.RawMessage {
t.Helper()
var evt struct {
Observation map[string]json.RawMessage `json:"observation"`
}
if err := json.Unmarshal(raw, &evt); err != nil {
t.Fatal(err)
}
return evt.Observation
}
evt := broadcast()
if evt.PayloadWithKey != nil || evt.PayloadResolvedWithKey != nil {
t.Fatal("key variants built with no opted-in client")
}
if strings.Contains(string(evt.Payload)+string(evt.PayloadResolved), "observerPublicKey") {
t.Fatal("default payloads must not carry observerPublicKey")
}
// client stays plain so its Payload is still the base variant.
keyed := w.hub.NewClient()
defer w.hub.Remove(keyed)
w.hub.Configure(keyed, hub.ClientOptions{IncludeObserverKey: true})
for !w.hub.ObserverKeyWanted() {
time.Sleep(time.Millisecond)
}
evt = broadcast()
if _, ok := observation(evt.Payload)["observerPublicKey"]; ok {
t.Fatal("base payload must not carry observerPublicKey")
}
withKey := observation(evt.PayloadWithKey)
if string(withKey["observerPublicKey"]) != `"`+key+`"` || string(withKey["resolvedPath"]) != "null" {
t.Fatalf("key variant = %v", withKey)
}
both := observation(evt.PayloadResolvedWithKey)
if string(both["observerPublicKey"]) != `"`+key+`"` || string(both["resolvedPath"]) == "null" {
t.Fatalf("resolved key variant = %v", both)
}
}
+12 -10
View File
@@ -82,15 +82,17 @@ type packetObservationEvent struct {
Summary *string `json:"summary,omitempty"` // same advert name as REST list/backfill rows
} `json:"packet"`
Observation struct {
ObserverID string `json:"observerId"`
ObserverName string `json:"observerName"`
IATA string `json:"iata"`
HeardAt int64 `json:"heardAt"`
RSSI int16 `json:"rssi"`
SNR float32 `json:"snr"`
SourceBroker string `json:"sourceBroker"`
PathBytes string `json:"pathBytes"`
PathLength struct {
ObserverID string `json:"observerId"`
ObserverName string `json:"observerName"`
// Only set for clients that sent includeObserverKey.
ObserverPublicKey string `json:"observerPublicKey,omitempty"`
IATA string `json:"iata"`
HeardAt int64 `json:"heardAt"`
RSSI int16 `json:"rssi"`
SNR float32 `json:"snr"`
SourceBroker string `json:"sourceBroker"`
PathBytes string `json:"pathBytes"`
PathLength struct {
Raw string `json:"raw"`
HashSize uint8 `json:"hashSize"`
HopCount uint8 `json:"hopCount"`
@@ -912,7 +914,7 @@ func (w *Worker) handlePacket(ctx context.Context, iata, pubkeyHex string, raw [
resolvedPath := api.BuildResolvedPath(hashes, resolved)
evt.Observation.ResolvedSource = resolvedSource
evt.Observation.ResolvedDestination = resolvedDestination
w.broadcastPacketObservation(iata, packet.PayloadType(), evt, resolvedPath)
w.broadcastPacketObservation(iata, packet.PayloadType(), evt, resolvedPath, hex.EncodeToString(pubkeyBytes))
}
}
+2 -2
View File
@@ -20,7 +20,7 @@ import (
func connectTestServer(t *testing.T, maxConns, maxConnects int) (*httptest.Server, <-chan struct{}) {
t.Helper()
handler := Handler(hub.New(), nil, maxConns, maxConnects)
handler := Handler(hub.New(), nil, maxConns, maxConnects, nil)
done := make(chan struct{}, 64)
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
handler(w, r)
@@ -57,7 +57,7 @@ func TestFailedHandshakeDoesNotUseConnectionSlot(t *testing.T) {
func TestConnectAttemptBudgetAndRecovery(t *testing.T) {
synctest.Test(t, func(t *testing.T) {
handler := Handler(nil, nil, 1, 2)
handler := Handler(nil, nil, 1, 2, nil)
attempt := func() *httptest.ResponseRecorder {
request := httptest.NewRequest(http.MethodGet, "/ws", nil)
request.RemoteAddr = "198.51.100.1:1234"
+20 -15
View File
@@ -10,14 +10,15 @@
// Client → Server:
// subscribe { v, type, id, scope } → server replies subscribed { v, type, id, subscriptionId }
// unsubscribe { v, type, id, subscriptionId }
// configure { v, type, id, resolvePath } → server replies configured { v, type, id, resolvePath }
// configure { v, type, id, resolvePath, includeObserverKey }
// → server replies configured { v, type, id, resolvePath, includeObserverKey }
// ping { v, type, id } → server replies pong { v, type, id }
//
// configure's resolvePath (bool) is a connection-wide setting, not
// per-subscription: enables/disables per-hop resolvedPath data on
// packetObservation events. Freely toggleable at any point during the
// connection; each configure call sets it to exactly the value sent
// (not additive across calls, unlike subscribe scopes). Default false.
// configure's flags are connection-wide settings, not per-subscription.
// resolvePath adds per-hop resolvedPath data to packetObservation events,
// includeObserverKey adds observation.observerPublicKey. Each configure
// sets both to exactly the values sent, so an omitted flag turns off.
// Both default false.
//
// Server → Client events (unsolicited):
// packetObservation, observerStatus, nodeUpdate, channelMessage
@@ -53,7 +54,11 @@ const (
// Handler returns an http.HandlerFunc that requires the hub to be injected.
// Wire it via router.New(h) so the hub is available at startup.
func Handler(h *hub.Hub, reader api.Reader, maxConnsPerIP, maxConnectsPerMinute int) http.HandlerFunc {
func Handler(h *hub.Hub, reader api.Reader, maxConnsPerIP, maxConnectsPerMinute int, allowedOrigins []string) http.HandlerFunc {
var acceptOpts *websocket.AcceptOptions
if len(allowedOrigins) > 0 {
acceptOpts = &websocket.AcceptOptions{OriginPatterns: allowedOrigins}
}
limiter := newIPLimiter(maxConnsPerIP)
attempts := httprate.NewRateLimiter(maxConnectsPerMinute, time.Minute,
httprate.WithResponseHeaders(httprate.ResponseHeaders{RetryAfter: "Retry-After"}))
@@ -67,7 +72,7 @@ func Handler(h *hub.Hub, reader api.Reader, maxConnsPerIP, maxConnectsPerMinute
if attempts.RespondOnLimit(w, r, httprate.CanonicalizeIP(ip)) {
return
}
conn, err := websocket.Accept(w, r, nil)
conn, err := websocket.Accept(w, r, acceptOpts)
if err != nil {
slog.Warn("ws: failed to accept connection", "component", "ws", "error", err)
return
@@ -172,10 +177,9 @@ type clientMessage struct {
SubscriptionID string `json:"subscriptionId,omitempty"`
Scope *subscribeScope `json:"scope,omitempty"`
// ResolvePath is only read for "configure" messages: enables/disables
// the resolvedPath variant of packetObservation events for the whole
// connection. See package doc.
ResolvePath bool `json:"resolvePath,omitempty"`
// Only read for "configure" messages. See package doc.
ResolvePath bool `json:"resolvePath,omitempty"`
IncludeObserverKey bool `json:"includeObserverKey,omitempty"`
}
// subscribeScope mirrors the scope object in the subscribe message.
@@ -260,12 +264,13 @@ func handleClientMessage(ctx context.Context, client *hub.Client, reader api.Rea
}
case "configure":
h.SetResolvePath(client, msg.ResolvePath)
h.Configure(client, hub.ClientOptions{ResolvePath: msg.ResolvePath, IncludeObserverKey: msg.IncludeObserverKey})
reply, _ := json.Marshal(map[string]any{
"v": 1, "type": "configured", "id": msg.ID, "resolvePath": msg.ResolvePath,
"v": 1, "type": "configured", "id": msg.ID,
"resolvePath": msg.ResolvePath, "includeObserverKey": msg.IncludeObserverKey,
})
if slog.Default().Enabled(context.Background(), slog.LevelDebug) {
slog.Debug(fmt.Sprintf("ws[%s]: configured resolvePath=%t", connID, msg.ResolvePath), "component", "ws")
slog.Debug(fmt.Sprintf("ws[%s]: configured resolvePath=%t includeObserverKey=%t", connID, msg.ResolvePath, msg.IncludeObserverKey), "component", "ws")
}
if err := conn.Write(ctx, websocket.MessageText, reply); err != nil {
slog.Warn(fmt.Sprintf("ws[%s]: failed to send configured reply", connID), "component", "ws", "error", err)
+122
View File
@@ -0,0 +1,122 @@
// Copyright 2026 Beacon Contributors
// SPDX-License-Identifier: AGPL-3.0-or-later
package ws
import (
"context"
"encoding/json"
"net/http"
"net/http/httptest"
"strings"
"testing"
"time"
"github.com/MeshCore-Beacon/beacon-server/internal/hub"
"github.com/coder/websocket"
)
func TestAllowedOrigins(t *testing.T) {
for _, tc := range []struct {
name string
allowed []string
origin string
wantOK bool
}{
{"no origin header", nil, "", true},
{"foreign origin by default", nil, "https://example.com", false},
{"listed origin", []string{"https://example.com"}, "https://example.com", true},
{"listed origin, other case", []string{"https://example.com"}, "https://Example.com", true},
{"scheme mismatch", []string{"https://example.com"}, "http://example.com", false},
{"port mismatch", []string{"https://example.com"}, "https://example.com:8443", false},
{"unlisted origin", []string{"https://example.com"}, "https://other.example", false},
} {
t.Run(tc.name, func(t *testing.T) {
server := httptest.NewServer(Handler(hub.New(), nil, 5, 100, tc.allowed))
t.Cleanup(server.Close)
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
defer cancel()
header := http.Header{}
if tc.origin != "" {
header.Set("Origin", tc.origin)
}
conn, resp, err := websocket.Dial(ctx, "ws"+strings.TrimPrefix(server.URL, "http"), &websocket.DialOptions{HTTPHeader: header})
if tc.wantOK {
if err != nil {
t.Fatalf("dial: %v", err)
}
conn.CloseNow()
return
}
if err == nil {
conn.CloseNow()
t.Fatal("expected handshake to be refused")
}
if resp == nil || resp.StatusCode != http.StatusForbidden {
t.Fatalf("expected 403, got %v (%v)", resp, err)
}
})
}
}
func TestConfigureIncludeObserverKey(t *testing.T) {
h := hub.New()
go h.Run()
server := httptest.NewServer(Handler(h, nil, 5, 100, nil))
t.Cleanup(server.Close)
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
defer cancel()
read := func(conn *websocket.Conn) map[string]json.RawMessage {
t.Helper()
_, data, err := conn.Read(ctx)
if err != nil {
t.Fatal(err)
}
var msg map[string]json.RawMessage
if err := json.Unmarshal(data, &msg); err != nil {
t.Fatal(err)
}
return msg
}
send := func(conn *websocket.Conn, msg string) {
t.Helper()
if err := conn.Write(ctx, websocket.MessageText, []byte(msg)); err != nil {
t.Fatal(err)
}
}
connect := func(configure string) *websocket.Conn {
t.Helper()
conn, _, err := websocket.Dial(ctx, "ws"+strings.TrimPrefix(server.URL, "http"), nil)
if err != nil {
t.Fatal(err)
}
t.Cleanup(func() { conn.CloseNow() })
read(conn) // hello
if configure != "" {
send(conn, configure)
reply := read(conn)
if string(reply["type"]) != `"configured"` || string(reply["includeObserverKey"]) != "true" || string(reply["resolvePath"]) != "false" {
t.Fatalf("unexpected configured reply: %v", reply)
}
}
send(conn, `{"v":1,"type":"subscribe","id":"s","scope":{}}`)
read(conn) // subscribed
return conn
}
plain := connect("")
keyed := connect(`{"v":1,"type":"configure","id":"c","includeObserverKey":true}`)
time.Sleep(20 * time.Millisecond) // let the hub apply both subscriptions
h.Broadcast(hub.Event{
Type: hub.EventPacketObservation,
Payload: json.RawMessage(`{"variant":"base"}`),
PayloadWithKey: json.RawMessage(`{"variant":"key"}`),
})
if got := string(read(plain)["data"]); got != `{"variant":"base"}` {
t.Errorf("plain client got %s", got)
}
if got := string(read(keyed)["data"]); got != `{"variant":"key"}` {
t.Errorf("opted-in client got %s", got)
}
}