You can not select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.

426 lines
15 KiB

package directstream
import (
"context"
"encoding/json"
"errors"
"io"
"net"
"net/http"
"net/http/httptest"
"reflect"
"strings"
"sync"
"testing"
"time"
"github.com/gorilla/websocket"
)
type websocketTransportFactory struct {
peers chan net.Conn
}
func (f *websocketTransportFactory) Open(context.Context, string) (io.ReadWriteCloser, error) {
transport, peer := net.Pipe()
f.peers <- peer
return transport, nil
}
type websocketAuthority struct {
store *TicketStore
mu sync.Mutex
closed []TicketBinding
released []TicketBinding
heartbeats int
heartbeatErr error
events []SessionEvent
eventErr error
}
func (a *websocketAuthority) ConsumeTicket(_ context.Context, value string, binding TicketBinding) (TicketClaims, error) {
return a.store.Consume(value, binding)
}
func (a *websocketAuthority) CloseDirectStream(_ context.Context, binding TicketBinding) error {
a.mu.Lock()
defer a.mu.Unlock()
a.closed = append(a.closed, binding)
return nil
}
func (a *websocketAuthority) ReleaseDirectWriter(_ context.Context, binding TicketBinding) error {
a.mu.Lock()
defer a.mu.Unlock()
a.released = append(a.released, binding)
return nil
}
func (a *websocketAuthority) HeartbeatDirectStream(_ context.Context, _ TicketBinding) error {
a.mu.Lock()
defer a.mu.Unlock()
a.heartbeats++
return a.heartbeatErr
}
func (a *websocketAuthority) RecordDirectStreamEvent(_ context.Context, _ TicketBinding, event SessionEvent) error {
a.mu.Lock()
defer a.mu.Unlock()
a.events = append(a.events, event)
return a.eventErr
}
func TestWebSocketHeartbeatFailureClosesConnectionAndUpdatesMetrics(t *testing.T) {
factory := &websocketTransportFactory{peers: make(chan net.Conn, 1)}
broker, _ := NewBroker(factory, BrokerOptions{})
defer broker.Close()
authority := &websocketAuthority{
store: NewTicketStore(TicketStoreOptions{}),
heartbeatErr: errors.New("ticket service unavailable"),
}
metrics := &WebSocketMetrics{}
server := httptest.NewServer(NewWebSocketHandler(broker, authority, authority, WebSocketOptions{
PongWait: 200 * time.Millisecond, PingInterval: 20 * time.Millisecond,
WriteTimeout: 100 * time.Millisecond, Metrics: metrics,
}))
defer server.Close()
claims := TicketClaims{SessionID: "direct-CH01-heartbeat", Channel: "CH01", Actor: "admin", Access: AccessViewer}
ticket := issueWebSocketTicket(t, authority.store, claims)
connection := dialDirectWebSocket(t, server.URL, claims, server.URL, true)
defer connection.Close()
authenticateWebSocket(t, connection, ticket)
_ = readStatusFrame(t, connection)
_ = readWelcomeFrame(t, connection)
peer := <-factory.peers
defer peer.Close()
_ = connection.SetReadDeadline(time.Now().Add(time.Second))
if _, _, err := connection.ReadMessage(); err == nil {
t.Fatal("expected failed lifecycle heartbeat to close WebSocket")
}
if !waitFor(time.Second, func() bool { return metrics.Snapshot().HeartbeatFailures == 1 }) {
t.Fatalf("metrics = %#v", metrics.Snapshot())
}
}
func TestWebSocketAuthenticatesTicketAndTransfersWriterBinary(t *testing.T) {
factory := &websocketTransportFactory{peers: make(chan net.Conn, 1)}
broker, err := NewBroker(factory, BrokerOptions{})
if err != nil {
t.Fatal(err)
}
defer broker.Close()
authority := &websocketAuthority{store: NewTicketStore(TicketStoreOptions{})}
server := httptest.NewServer(NewWebSocketHandler(broker, authority, authority, WebSocketOptions{}))
defer server.Close()
claims := TicketClaims{SessionID: "direct-CH01-ws1", Channel: "CH01", Actor: "admin", Access: AccessWriter}
ticket := issueWebSocketTicket(t, authority.store, claims)
connection := dialDirectWebSocket(t, server.URL, claims, server.URL, true)
defer connection.Close()
authenticateWebSocket(t, connection, ticket)
status := readStatusFrame(t, connection)
if status.Type != "session.status" || status.Access != AccessWriter || !status.Writer {
t.Fatalf("status = %#v", status)
}
welcome := readWelcomeFrame(t, connection)
for _, expected := range []string{"Welcome to TERA CORA-X48 PRO K", "Connected from: 127.0.0.1", "Serial channel: CH01", "User: admin", "Access: writer", "Session: direct-CH01-ws1"} {
if !strings.Contains(welcome, expected) {
t.Fatalf("welcome = %q, missing %q", welcome, expected)
}
}
peer := <-factory.peers
defer peer.Close()
tx := []byte{0x00, 0x3a, 0xff, '\n'}
if err := connection.WriteMessage(websocket.BinaryMessage, tx); err != nil {
t.Fatal(err)
}
peerTX := make([]byte, len(tx))
if _, err := io.ReadFull(peer, peerTX); err != nil {
t.Fatal(err)
}
if !reflect.DeepEqual(peerTX, tx) {
t.Fatalf("tx = %v", peerTX)
}
rx := []byte{0xfe, 0x00, 0x7f}
writeDone := make(chan error, 1)
go func() {
_, err := peer.Write(rx)
writeDone <- err
}()
messageType, payload, err := connection.ReadMessage()
if err != nil {
t.Fatal(err)
}
if err := <-writeDone; err != nil {
t.Fatal(err)
}
if messageType != websocket.BinaryMessage || !reflect.DeepEqual(payload, rx) {
t.Fatalf("rx type=%d payload=%v", messageType, payload)
}
}
func TestWebSocketConsumesTicketAndClosesLifecycleThroughAPISocket(t *testing.T) {
factory := &websocketTransportFactory{peers: make(chan net.Conn, 1)}
broker, err := NewBroker(factory, BrokerOptions{})
if err != nil {
t.Fatal(err)
}
defer broker.Close()
authority := &testTicketAuthority{store: NewTicketStore(TicketStoreOptions{})}
ticketSocket, stopTickets := startTicketSocketServer(t, authority)
defer stopTickets()
ticketClient := NewTicketSocketClient(ticketSocket)
server := httptest.NewServer(NewWebSocketHandler(broker, ticketClient, ticketClient, WebSocketOptions{}))
defer server.Close()
claims := TicketClaims{SessionID: "direct-CH01-ipc", Channel: "CH01", Actor: "admin", Access: AccessViewer}
ticket := issueWebSocketTicket(t, authority.store, claims)
connection := dialDirectWebSocket(t, server.URL, claims, server.URL, true)
authenticateWebSocket(t, connection, ticket)
status := readStatusFrame(t, connection)
if status.Access != AccessViewer {
t.Fatalf("status = %#v", status)
}
_ = readWelcomeFrame(t, connection)
peer := <-factory.peers
defer peer.Close()
connection.Close()
deadline := time.Now().Add(time.Second)
for {
authority.mu.Lock()
closed := len(authority.closed)
authority.mu.Unlock()
if closed == 1 {
break
}
if time.Now().After(deadline) {
t.Fatal("ticket socket lifecycle close was not called")
}
time.Sleep(time.Millisecond)
}
authority.mu.Lock()
events := append([]SessionEvent(nil), authority.events...)
authority.mu.Unlock()
if len(events) != 2 || events[0].Type != SessionEventConnected || events[1].Type != SessionEventDisconnected || events[0].Source != "127.0.0.1" {
t.Fatalf("WebSocket session events = %#v", events)
}
}
func TestWebSocketRejectsOriginSubprotocolReplayAndViewerWrites(t *testing.T) {
factory := &websocketTransportFactory{peers: make(chan net.Conn, 2)}
broker, err := NewBroker(factory, BrokerOptions{})
if err != nil {
t.Fatal(err)
}
defer broker.Close()
authority := &websocketAuthority{store: NewTicketStore(TicketStoreOptions{})}
server := httptest.NewServer(NewWebSocketHandler(broker, authority, authority, WebSocketOptions{}))
defer server.Close()
claims := TicketClaims{SessionID: "direct-CH01-ws2", Channel: "CH01", Actor: "viewer", Access: AccessViewer}
ticket := issueWebSocketTicket(t, authority.store, claims)
url := directWebSocketURL(server.URL, claims)
badOriginHeader := http.Header{"Origin": []string{"https://evil.example"}}
badOriginDialer := websocket.Dialer{Subprotocols: []string{Subprotocol}}
if connection, response, err := badOriginDialer.Dial(url, badOriginHeader); err == nil || response == nil || response.StatusCode != http.StatusForbidden {
if connection != nil {
connection.Close()
}
t.Fatalf("bad Origin result response=%v err=%v", response, err)
}
noProtocolHeader := http.Header{"Origin": []string{server.URL}}
if connection, response, err := websocket.DefaultDialer.Dial(url, noProtocolHeader); err == nil || response == nil || response.StatusCode != http.StatusUpgradeRequired {
if connection != nil {
connection.Close()
}
t.Fatalf("missing protocol result response=%v err=%v", response, err)
}
connection := dialDirectWebSocket(t, server.URL, claims, server.URL, true)
authenticateWebSocket(t, connection, ticket)
_ = readStatusFrame(t, connection)
_ = readWelcomeFrame(t, connection)
peer := <-factory.peers
defer peer.Close()
if err := connection.WriteMessage(websocket.BinaryMessage, []byte{0x3a}); err != nil {
t.Fatal(err)
}
errorFrame := readStatusFrame(t, connection)
if errorFrame.Type != "error" || errorFrame.Code != "writer_required" {
t.Fatalf("viewer error = %#v", errorFrame)
}
_ = peer.SetReadDeadline(time.Now().Add(50 * time.Millisecond))
if _, err := peer.Read(make([]byte, 1)); err == nil {
t.Fatal("viewer payload reached serial transport")
}
connection.Close()
replay := dialDirectWebSocket(t, server.URL, claims, server.URL, true)
defer replay.Close()
authenticateWebSocket(t, replay, ticket)
_, _, err = replay.ReadMessage()
if err == nil {
t.Fatal("expected replayed ticket connection to close")
}
}
func TestWebSocketEnforcesFirstFrameDeadlineAndDataFrameLimit(t *testing.T) {
factory := &websocketTransportFactory{peers: make(chan net.Conn, 1)}
broker, err := NewBroker(factory, BrokerOptions{})
if err != nil {
t.Fatal(err)
}
defer broker.Close()
authority := &websocketAuthority{store: NewTicketStore(TicketStoreOptions{})}
handler := NewWebSocketHandler(broker, authority, authority, WebSocketOptions{
FirstFrameTimeout: 50 * time.Millisecond,
})
server := httptest.NewServer(handler)
defer server.Close()
claims := TicketClaims{SessionID: "direct-CH01-ws3", Channel: "CH01", Actor: "admin", Access: AccessWriter}
connection := dialDirectWebSocket(t, server.URL, claims, server.URL, true)
_ = connection.SetReadDeadline(time.Now().Add(time.Second))
if _, _, err := connection.ReadMessage(); err == nil {
t.Fatal("expected unauthenticated socket deadline")
}
connection.Close()
claims.SessionID = "direct-CH01-ws4"
ticket := issueWebSocketTicket(t, authority.store, claims)
connection = dialDirectWebSocket(t, server.URL, claims, server.URL, true)
authenticateWebSocket(t, connection, ticket)
_ = readStatusFrame(t, connection)
_ = readWelcomeFrame(t, connection)
peer := <-factory.peers
defer peer.Close()
oversized := make([]byte, MaxDataFrameBytes+1)
if err := connection.WriteMessage(websocket.BinaryMessage, oversized); err != nil {
t.Fatal(err)
}
_ = connection.SetReadDeadline(time.Now().Add(time.Second))
if _, _, err := connection.ReadMessage(); err == nil {
t.Fatal("expected oversized frame connection to close")
}
}
func TestWebSocketRejectsConnectionWhenMandatorySessionLogFails(t *testing.T) {
factory := &websocketTransportFactory{peers: make(chan net.Conn, 1)}
broker, _ := NewBroker(factory, BrokerOptions{})
defer broker.Close()
authority := &websocketAuthority{
store: NewTicketStore(TicketStoreOptions{}),
eventErr: errors.New("port log full"),
}
metrics := &WebSocketMetrics{}
server := httptest.NewServer(NewWebSocketHandler(broker, authority, authority, WebSocketOptions{Metrics: metrics}))
defer server.Close()
claims := TicketClaims{SessionID: "direct-CH48-logfail", Channel: "CH48", Actor: "admin", Access: AccessViewer}
ticket := issueWebSocketTicket(t, authority.store, claims)
connection := dialDirectWebSocket(t, server.URL, claims, server.URL, true)
defer connection.Close()
authenticateWebSocket(t, connection, ticket)
frame := readStatusFrame(t, connection)
if frame.Type != "error" || frame.Code != "session_log_unavailable" {
t.Fatalf("error frame = %#v", frame)
}
if !waitFor(time.Second, func() bool { return metrics.Snapshot().SessionLogFailures >= 1 }) {
t.Fatalf("metrics = %#v", metrics.Snapshot())
}
}
func TestDirectStreamSourceAndWelcomeCoverForwardedClientAndLastChannel(t *testing.T) {
request := httptest.NewRequest(http.MethodGet, "/", nil)
request.RemoteAddr = "127.0.0.1:42000"
request.Header.Set("X-Forwarded-For", "198.51.100.24")
if source := directStreamSource(request); source != "198.51.100.24" {
t.Fatalf("source = %q", source)
}
request.Header.Set("X-Forwarded-For", "203.0.113.1, 198.51.100.24")
if source := directStreamSource(request); source != "127.0.0.1" {
t.Fatalf("multi-hop forwarded source = %q, want loopback fallback", source)
}
event := SessionEvent{Type: SessionEventConnected, Source: "198.51.100.24", Timestamp: time.Date(2026, 8, 13, 3, 4, 5, 0, time.UTC)}
message := string(welcomeFrame(TicketClaims{SessionID: "direct-CH48-final", Channel: "CH48", Actor: "operator", Access: AccessViewer}, event))
for _, expected := range []string{"Serial channel: CH48", "Connected from: 198.51.100.24", "Connected at: 2026-08-13T03:04:05Z"} {
if !strings.Contains(message, expected) {
t.Fatalf("CH48 welcome = %q, missing %q", message, expected)
}
}
}
func issueWebSocketTicket(t *testing.T, store *TicketStore, claims TicketClaims) string {
t.Helper()
issued, err := store.Issue(claims)
if err != nil {
t.Fatal(err)
}
return issued.Value
}
func dialDirectWebSocket(t *testing.T, serverURL string, claims TicketClaims, origin string, protocol bool) *websocket.Conn {
t.Helper()
dialer := websocket.Dialer{}
if protocol {
dialer.Subprotocols = []string{Subprotocol}
}
header := http.Header{"Origin": []string{origin}}
connection, response, err := dialer.Dial(directWebSocketURL(serverURL, claims), header)
if err != nil {
status := 0
if response != nil {
status = response.StatusCode
}
t.Fatalf("dial websocket status=%d: %v", status, err)
}
return connection
}
func directWebSocketURL(serverURL string, claims TicketClaims) string {
return "ws" + strings.TrimPrefix(serverURL, "http") + "/api/v1/direct/" + claims.Channel + "/sessions/" + claims.SessionID + "/stream"
}
func authenticateWebSocket(t *testing.T, connection *websocket.Conn, ticket string) {
t.Helper()
payload, err := json.Marshal(ControlFrame{Type: ControlAuthenticate, Ticket: ticket})
if err != nil {
t.Fatal(err)
}
if err := connection.WriteMessage(websocket.TextMessage, payload); err != nil {
t.Fatal(err)
}
}
func readStatusFrame(t *testing.T, connection *websocket.Conn) WebSocketStatusFrame {
t.Helper()
_ = connection.SetReadDeadline(time.Now().Add(time.Second))
messageType, payload, err := connection.ReadMessage()
if err != nil {
var closeError *websocket.CloseError
if errors.As(err, &closeError) {
t.Fatalf("websocket closed code=%d text=%s", closeError.Code, closeError.Text)
}
t.Fatal(err)
}
if messageType != websocket.TextMessage {
t.Fatalf("message type = %d, want text", messageType)
}
var frame WebSocketStatusFrame
if err := json.Unmarshal(payload, &frame); err != nil {
t.Fatal(err)
}
return frame
}
func readWelcomeFrame(t *testing.T, connection *websocket.Conn) string {
t.Helper()
_ = connection.SetReadDeadline(time.Now().Add(time.Second))
messageType, payload, err := connection.ReadMessage()
if err != nil {
t.Fatal(err)
}
if messageType != websocket.BinaryMessage {
t.Fatalf("welcome message type = %d, want binary", messageType)
}
return string(payload)
}