Reticulum-Go/pkg/controlapi/ws.go

391 lines
10 KiB
Go

// SPDX-License-Identifier: Apache-2.0
// Copyright (c) 2024-2026 Quad4.io
package controlapi
import (
"bufio"
"crypto/sha1" // #nosec G505 -- RFC 6455 mandates SHA-1 for the handshake accept key
"encoding/base64"
"encoding/binary"
"encoding/hex"
"encoding/json"
"errors"
"fmt"
"io"
"math"
"net"
"net/http"
"sync"
"quad4/reticulum-go/pkg/debug"
)
// RFC 6455 framing constants. A minimal server-side implementation lives
// here rather than pulling in a WebSocket dependency: the control API only
// ever exchanges small, unfragmented JSON text frames.
const (
wsGUID = "258EAFA5-E914-47DA-95CA-C5AB0DC85B11"
wsOpcodeText = 0x1
wsOpcodeBinary = 0x2
wsOpcodeClose = 0x8
wsOpcodePing = 0x9
wsOpcodePong = 0xA
wsFinBit = 0x80
wsOpcodeMask = 0x0F
wsMaskedBit = 0x80
wsLenMask = 0x7F
wsLen16 = 126
wsLen64 = 127
wsMaxMessageBytes = 1 << 20 // 1 MiB cap on control-plane JSON payloads
)
// wsConn is a hijacked HTTP connection speaking the WebSocket frame format.
// Clients (the control API's callers) must mask their frames per RFC 6455.
// the server never masks outbound frames.
type wsConn struct {
conn net.Conn
reader *bufio.Reader
writeMu sync.Mutex
}
// wsPendingUpgrade holds a hijacked connection whose 101 response has not
// been flushed yet. Callers register the session client before Flush so the
// peer cannot observe a ready WebSocket before broadcast delivery works.
type wsPendingUpgrade struct {
conn net.Conn
rw *bufio.ReadWriter
accept string
}
// beginWebSocketUpgrade hijacks w without sending the handshake response.
// Callers must have already verified the Upgrade and Sec-WebSocket-Key
// headers so earlier failures can still use a normal HTTP error response.
func beginWebSocketUpgrade(w http.ResponseWriter, r *http.Request) (*wsPendingUpgrade, error) {
hj, ok := w.(http.Hijacker)
if !ok {
return nil, errors.New("controlapi: response writer does not support hijacking")
}
key := r.Header.Get("Sec-WebSocket-Key")
netConn, rw, err := hj.Hijack()
if err != nil {
return nil, err
}
return &wsPendingUpgrade{
conn: netConn,
rw: rw,
accept: computeWSAccept(key),
}, nil
}
// Conn returns a wsConn bound to the hijacked socket. Safe to use for
// session registration before Flush: outbound frames must wait until after
// Flush so they cannot interleave with the HTTP 101 response.
func (u *wsPendingUpgrade) Conn() *wsConn {
return &wsConn{conn: u.conn, reader: u.rw.Reader}
}
// Flush writes the WebSocket handshake response. On failure the underlying
// connection is closed.
func (u *wsPendingUpgrade) Flush() error {
response := "HTTP/1.1 101 Switching Protocols\r\n" +
"Upgrade: websocket\r\n" +
"Connection: Upgrade\r\n" +
"Sec-WebSocket-Accept: " + u.accept + "\r\n\r\n"
if _, err := u.rw.Write([]byte(response)); err != nil {
_ = u.conn.Close()
return err
}
if err := u.rw.Flush(); err != nil {
_ = u.conn.Close()
return err
}
return nil
}
func computeWSAccept(key string) string {
h := sha1.New() // #nosec G401 -- RFC 6455 mandates SHA-1 for the handshake accept key
h.Write([]byte(key))
h.Write([]byte(wsGUID))
return base64.StdEncoding.EncodeToString(h.Sum(nil))
}
// readMessage returns the payload of the next text or binary frame,
// answering pings and discarding pongs internally. Fragmented messages are
// rejected: control-plane commands always fit in a single frame.
func (c *wsConn) readMessage() ([]byte, error) {
for {
header := make([]byte, 2)
if _, err := io.ReadFull(c.reader, header); err != nil {
return nil, err
}
fin := header[0]&wsFinBit != 0
opcode := header[0] & wsOpcodeMask
masked := header[1]&wsMaskedBit != 0
length := int(header[1] & wsLenMask)
// RFC 6455 section 5.1: client-to-server frames must be masked.
if !masked {
return nil, errors.New("controlapi: client frames must be masked")
}
switch length {
case wsLen16:
ext := make([]byte, 2)
if _, err := io.ReadFull(c.reader, ext); err != nil {
return nil, err
}
length = int(binary.BigEndian.Uint16(ext))
case wsLen64:
ext := make([]byte, 8)
if _, err := io.ReadFull(c.reader, ext); err != nil {
return nil, err
}
v := binary.BigEndian.Uint64(ext)
if v > uint64(wsMaxMessageBytes) {
return nil, fmt.Errorf("controlapi: websocket message too large: %d bytes", v)
}
length = int(v)
}
if length > wsMaxMessageBytes {
return nil, fmt.Errorf("controlapi: websocket message too large: %d bytes", length)
}
var maskKey [4]byte
if _, err := io.ReadFull(c.reader, maskKey[:]); err != nil {
return nil, err
}
payload := make([]byte, length)
if _, err := io.ReadFull(c.reader, payload); err != nil {
return nil, err
}
for i := range payload {
payload[i] ^= maskKey[i%4]
}
switch opcode {
case wsOpcodeClose:
return nil, io.EOF
case wsOpcodePing:
if err := c.writeFrame(wsOpcodePong, payload); err != nil {
return nil, err
}
continue
case wsOpcodePong:
continue
case wsOpcodeText, wsOpcodeBinary:
if !fin {
return nil, errors.New("controlapi: fragmented websocket messages are not supported")
}
return payload, nil
default:
return nil, fmt.Errorf("controlapi: unsupported websocket opcode: %d", opcode)
}
}
}
// writeMessage sends payload as a single unmasked text frame.
func (c *wsConn) writeMessage(payload []byte) error {
return c.writeFrame(wsOpcodeText, payload)
}
func (c *wsConn) writeFrame(opcode byte, payload []byte) error {
c.writeMu.Lock()
defer c.writeMu.Unlock()
var header []byte
length := len(payload)
switch {
case length < wsLen16:
header = []byte{wsFinBit | opcode, byte(length)}
case length <= math.MaxUint16:
header = make([]byte, 4)
header[0] = wsFinBit | opcode
header[1] = wsLen16
binary.BigEndian.PutUint16(header[2:], uint16(length))
default:
header = make([]byte, 10)
header[0] = wsFinBit | opcode
header[1] = wsLen64
binary.BigEndian.PutUint64(header[2:], uint64(length))
}
if _, err := c.conn.Write(header); err != nil {
return err
}
if len(payload) > 0 {
if _, err := c.conn.Write(payload); err != nil {
return err
}
}
return nil
}
func (c *wsConn) close() error {
_ = c.writeFrame(wsOpcodeClose, nil)
return c.conn.Close()
}
// wsClient is one active /v1/sessions/{id}/events connection. Events are
// queued on outbox and delivered by a dedicated writer goroutine so that a
// slow or stuck client cannot block announce delivery to other clients.
type wsClient struct {
conn *wsConn
session *session
server *Server
outbox chan []byte
done chan struct{}
writable chan struct{}
writableOnce sync.Once
closeOnce sync.Once
announceFilterMu sync.Mutex
announceFilter string // empty = all, else exact 16-byte dest hash hex
}
const wsClientOutboxSize = 32
func newWSClient(server *Server, sess *session, conn *wsConn) *wsClient {
return &wsClient{
conn: conn,
session: sess,
server: server,
outbox: make(chan []byte, wsClientOutboxSize),
done: make(chan struct{}),
writable: make(chan struct{}),
}
}
// startWriter launches writeLoop before the handshake is flushed so the
// writer exists by the time the peer can observe a ready socket. writeLoop
// blocks on writable until enableWrites, so frames cannot interleave with
// the HTTP 101 response.
func (c *wsClient) startWriter() {
go c.writeLoop()
}
// enableWrites unblocks writeLoop after the handshake has been flushed.
func (c *wsClient) enableWrites() {
c.writableOnce.Do(func() { close(c.writable) })
}
// run blocks in the read loop until the connection closes. The caller must
// already have registered the client, started the writer, flushed the
// handshake, and called enableWrites.
func (c *wsClient) run() {
c.readLoop()
c.close()
}
func (c *wsClient) writeLoop() {
select {
case <-c.writable:
case <-c.done:
return
}
for {
select {
case msg := <-c.outbox:
if err := c.conn.writeMessage(msg); err != nil {
return
}
case <-c.done:
return
}
}
}
func (c *wsClient) readLoop() {
for {
msg, err := c.conn.readMessage()
if err != nil {
return
}
c.handleCommand(msg)
}
}
func (c *wsClient) handleCommand(raw []byte) {
var env wsCommandEnvelope
if err := json.Unmarshal(raw, &env); err != nil {
c.send(commandErrorEvent{Type: "command.error", Error: "invalid command json"})
return
}
switch env.Type {
case "subscribe_announces":
c.handleSubscribeAnnounces(raw)
case "link.open":
c.handleLinkOpen(raw)
case "link.send":
c.handleLinkSend(raw)
case "link.close":
c.handleLinkClose(raw)
case "link.request":
c.handleLinkRequest(raw)
case "link.send_resource":
c.handleLinkSendResource(raw)
case "link.identify":
c.handleLinkIdentify(raw)
case "request.respond":
c.handleRequestRespond(raw)
default:
c.send(commandErrorEvent{Type: "command.error", Command: env.Type, Error: "unknown command"})
}
}
func (c *wsClient) handleSubscribeAnnounces(raw []byte) {
var cmd subscribeAnnouncesCommand
if err := json.Unmarshal(raw, &cmd); err != nil {
c.send(commandErrorEvent{Type: "command.error", Command: "subscribe_announces", Error: "invalid command json"})
return
}
if cmd.Filter != "" {
b, err := hex.DecodeString(cmd.Filter)
if err != nil || len(b) != 16 {
c.send(commandErrorEvent{Type: "command.error", Command: "subscribe_announces", Error: "filter must be 16 hex-encoded bytes or empty"})
return
}
cmd.Filter = hex.EncodeToString(b)
}
c.announceFilterMu.Lock()
c.announceFilter = cmd.Filter
c.announceFilterMu.Unlock()
c.server.subscribeAnnounces(c)
}
func (c *wsClient) matchesAnnounceFilter(destHashHex string) bool {
c.announceFilterMu.Lock()
filter := c.announceFilter
c.announceFilterMu.Unlock()
if filter == "" {
return true
}
return filter == destHashHex
}
// send enqueues v for delivery, dropping it if the client is not draining
// its outbox fast enough.
func (c *wsClient) send(v any) {
data, err := json.Marshal(v)
if err != nil {
return
}
select {
case c.outbox <- data:
case <-c.done:
default:
debug.Log(debug.DebugError, "controlapi: dropping event, client outbox full")
}
}
func (c *wsClient) close() {
c.closeOnce.Do(func() {
close(c.done)
_ = c.conn.close()
c.server.unsubscribeAnnounces(c)
c.session.removeClient(c)
})
}