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.
485 lines
15 KiB
485 lines
15 KiB
package directstream
|
|
|
|
import (
|
|
"context"
|
|
"errors"
|
|
"net"
|
|
"net/http"
|
|
"net/url"
|
|
"regexp"
|
|
"strings"
|
|
"sync"
|
|
"sync/atomic"
|
|
"time"
|
|
|
|
"github.com/gorilla/websocket"
|
|
)
|
|
|
|
const (
|
|
defaultFirstFrameTimeout = 5 * time.Second
|
|
defaultWebSocketPongWait = 30 * time.Second
|
|
defaultWebSocketPing = 10 * time.Second
|
|
defaultWebSocketWrite = 5 * time.Second
|
|
)
|
|
|
|
var directSessionIDPattern = regexp.MustCompile(`^[A-Za-z0-9][A-Za-z0-9._:-]{0,127}$`)
|
|
|
|
type WebSocketOptions struct {
|
|
AllowedOrigins []string
|
|
FirstFrameTimeout time.Duration
|
|
PongWait time.Duration
|
|
PingInterval time.Duration
|
|
WriteTimeout time.Duration
|
|
Metrics *WebSocketMetrics
|
|
}
|
|
|
|
type WebSocketSnapshot struct {
|
|
ActiveConnections int64 `json:"active_connections"`
|
|
UpgradedConnections uint64 `json:"upgraded_connections"`
|
|
AcceptedConnections uint64 `json:"accepted_connections"`
|
|
ClosedConnections uint64 `json:"closed_connections"`
|
|
OriginRejections uint64 `json:"origin_rejections"`
|
|
ProtocolRejections uint64 `json:"protocol_rejections"`
|
|
AuthenticationFailures uint64 `json:"authentication_failures"`
|
|
AttachmentFailures uint64 `json:"attachment_failures"`
|
|
LifecycleFailures uint64 `json:"lifecycle_failures"`
|
|
HeartbeatFailures uint64 `json:"heartbeat_failures"`
|
|
OutboundFailures uint64 `json:"outbound_failures"`
|
|
WelcomeFrames uint64 `json:"welcome_frames"`
|
|
SessionLogFailures uint64 `json:"session_log_failures"`
|
|
}
|
|
|
|
type WebSocketMetrics struct {
|
|
activeConnections atomic.Int64
|
|
upgradedConnections atomic.Uint64
|
|
acceptedConnections atomic.Uint64
|
|
closedConnections atomic.Uint64
|
|
originRejections atomic.Uint64
|
|
protocolRejections atomic.Uint64
|
|
authenticationFailures atomic.Uint64
|
|
attachmentFailures atomic.Uint64
|
|
lifecycleFailures atomic.Uint64
|
|
heartbeatFailures atomic.Uint64
|
|
outboundFailures atomic.Uint64
|
|
welcomeFrames atomic.Uint64
|
|
sessionLogFailures atomic.Uint64
|
|
}
|
|
|
|
func (m *WebSocketMetrics) Snapshot() WebSocketSnapshot {
|
|
if m == nil {
|
|
return WebSocketSnapshot{}
|
|
}
|
|
return WebSocketSnapshot{
|
|
ActiveConnections: m.activeConnections.Load(),
|
|
UpgradedConnections: m.upgradedConnections.Load(),
|
|
AcceptedConnections: m.acceptedConnections.Load(),
|
|
ClosedConnections: m.closedConnections.Load(),
|
|
OriginRejections: m.originRejections.Load(), ProtocolRejections: m.protocolRejections.Load(),
|
|
AuthenticationFailures: m.authenticationFailures.Load(), AttachmentFailures: m.attachmentFailures.Load(),
|
|
LifecycleFailures: m.lifecycleFailures.Load(), HeartbeatFailures: m.heartbeatFailures.Load(),
|
|
OutboundFailures: m.outboundFailures.Load(), WelcomeFrames: m.welcomeFrames.Load(),
|
|
SessionLogFailures: m.sessionLogFailures.Load(),
|
|
}
|
|
}
|
|
|
|
type WebSocketStatusFrame struct {
|
|
Type string `json:"type"`
|
|
Code string `json:"code,omitempty"`
|
|
Message string `json:"message,omitempty"`
|
|
SessionID string `json:"session_id,omitempty"`
|
|
Channel string `json:"channel,omitempty"`
|
|
Access string `json:"access,omitempty"`
|
|
Writer bool `json:"writer,omitempty"`
|
|
QueuePosition int `json:"queue_position,omitempty"`
|
|
}
|
|
|
|
type webSocketHandler struct {
|
|
broker *Broker
|
|
tickets TicketConsumer
|
|
lifecycle TicketLifecycle
|
|
options WebSocketOptions
|
|
origins map[string]struct{}
|
|
upgrader websocket.Upgrader
|
|
metrics *WebSocketMetrics
|
|
}
|
|
|
|
func NewWebSocketHandler(broker *Broker, tickets TicketConsumer, lifecycle TicketLifecycle, options WebSocketOptions) http.Handler {
|
|
if options.FirstFrameTimeout <= 0 {
|
|
options.FirstFrameTimeout = defaultFirstFrameTimeout
|
|
}
|
|
if options.PongWait <= 0 {
|
|
options.PongWait = defaultWebSocketPongWait
|
|
}
|
|
if options.PingInterval <= 0 || options.PingInterval >= options.PongWait {
|
|
options.PingInterval = defaultWebSocketPing
|
|
}
|
|
if options.WriteTimeout <= 0 {
|
|
options.WriteTimeout = defaultWebSocketWrite
|
|
}
|
|
origins := make(map[string]struct{}, len(options.AllowedOrigins))
|
|
for _, origin := range options.AllowedOrigins {
|
|
origin = strings.TrimSpace(origin)
|
|
if origin != "" {
|
|
origins[origin] = struct{}{}
|
|
}
|
|
}
|
|
handler := &webSocketHandler{
|
|
broker: broker, tickets: tickets, lifecycle: lifecycle,
|
|
options: options, origins: origins, metrics: options.Metrics,
|
|
}
|
|
if handler.metrics == nil {
|
|
handler.metrics = &WebSocketMetrics{}
|
|
}
|
|
handler.upgrader = websocket.Upgrader{
|
|
ReadBufferSize: 4096,
|
|
WriteBufferSize: 4096,
|
|
Subprotocols: []string{Subprotocol},
|
|
CheckOrigin: func(*http.Request) bool { return true },
|
|
}
|
|
return handler
|
|
}
|
|
|
|
func (h *webSocketHandler) ServeHTTP(w http.ResponseWriter, request *http.Request) {
|
|
if h.broker == nil || h.tickets == nil || h.lifecycle == nil {
|
|
http.Error(w, "Direct Mode streaming is unavailable", http.StatusServiceUnavailable)
|
|
return
|
|
}
|
|
if request.Method != http.MethodGet {
|
|
w.Header().Set("Allow", http.MethodGet)
|
|
http.Error(w, "Method not allowed", http.StatusMethodNotAllowed)
|
|
return
|
|
}
|
|
channel, sessionID, ok := parseDirectStreamPath(request.URL.Path)
|
|
if !ok {
|
|
http.NotFound(w, request)
|
|
return
|
|
}
|
|
if !h.originAllowed(request) {
|
|
h.metrics.originRejections.Add(1)
|
|
http.Error(w, "WebSocket Origin is not allowed", http.StatusForbidden)
|
|
return
|
|
}
|
|
if !containsString(websocket.Subprotocols(request), Subprotocol) {
|
|
h.metrics.protocolRejections.Add(1)
|
|
w.Header().Set("Sec-WebSocket-Protocol", Subprotocol)
|
|
http.Error(w, "WebSocket subprotocol is required", http.StatusUpgradeRequired)
|
|
return
|
|
}
|
|
connection, err := h.upgrader.Upgrade(w, request, nil)
|
|
if err != nil {
|
|
return
|
|
}
|
|
h.metrics.upgradedConnections.Add(1)
|
|
h.serveConnection(connection, TicketBinding{SessionID: sessionID, Channel: channel}, directStreamSource(request))
|
|
}
|
|
|
|
func (h *webSocketHandler) serveConnection(connection *websocket.Conn, binding TicketBinding, source string) {
|
|
defer func() {
|
|
h.metrics.closedConnections.Add(1)
|
|
_ = connection.Close()
|
|
}()
|
|
connection.SetReadLimit(MaxDataFrameBytes)
|
|
_ = connection.SetReadDeadline(time.Now().Add(h.options.FirstFrameTimeout))
|
|
messageType, payload, err := connection.ReadMessage()
|
|
if err != nil || messageType != websocket.TextMessage || len(payload) > MaxControlFrameBytes {
|
|
h.metrics.authenticationFailures.Add(1)
|
|
h.closeConnection(connection, websocket.ClosePolicyViolation, "authentication required")
|
|
return
|
|
}
|
|
frame, err := DecodeControlFrame(payload)
|
|
if err != nil || frame.Type != ControlAuthenticate {
|
|
h.metrics.authenticationFailures.Add(1)
|
|
h.closeConnection(connection, websocket.ClosePolicyViolation, "invalid authentication frame")
|
|
return
|
|
}
|
|
ctx, cancel := context.WithTimeout(context.Background(), h.options.FirstFrameTimeout)
|
|
claims, err := h.tickets.ConsumeTicket(ctx, frame.Ticket, binding)
|
|
cancel()
|
|
if err != nil {
|
|
h.metrics.authenticationFailures.Add(1)
|
|
h.closeConnection(connection, websocket.ClosePolicyViolation, "invalid stream ticket")
|
|
return
|
|
}
|
|
attachment, err := h.broker.Attach(context.Background(), AttachRequest{
|
|
SessionID: claims.SessionID, Channel: claims.Channel, Actor: claims.Actor, Access: claims.Access,
|
|
SharedWrite: claims.Access == AccessWriter, MaxAttachments: claims.MaxAttachments,
|
|
})
|
|
if err != nil {
|
|
h.metrics.attachmentFailures.Add(1)
|
|
if h.closeLifecycle(binding) != nil {
|
|
h.metrics.lifecycleFailures.Add(1)
|
|
}
|
|
reason := "stream attachment unavailable"
|
|
if errors.Is(err, ErrAttachmentLimit) {
|
|
reason = "channel session limit reached"
|
|
}
|
|
h.closeConnection(connection, websocket.CloseTryAgainLater, reason)
|
|
return
|
|
}
|
|
h.metrics.acceptedConnections.Add(1)
|
|
h.metrics.activeConnections.Add(1)
|
|
defer h.metrics.activeConnections.Add(-1)
|
|
defer attachment.Close()
|
|
connectedLogged := false
|
|
defer func() {
|
|
if connectedLogged {
|
|
event := SessionEvent{Type: SessionEventDisconnected, Source: source, Timestamp: time.Now().UTC()}
|
|
if h.recordSessionEvent(binding, event) != nil {
|
|
h.metrics.sessionLogFailures.Add(1)
|
|
}
|
|
}
|
|
if h.closeLifecycle(binding) != nil {
|
|
h.metrics.lifecycleFailures.Add(1)
|
|
}
|
|
}()
|
|
|
|
_ = connection.SetReadDeadline(time.Now().Add(h.options.PongWait))
|
|
connection.SetPongHandler(func(string) error {
|
|
return connection.SetReadDeadline(time.Now().Add(h.options.PongWait))
|
|
})
|
|
var writeMu sync.Mutex
|
|
writeJSON := func(value WebSocketStatusFrame) error {
|
|
writeMu.Lock()
|
|
defer writeMu.Unlock()
|
|
_ = connection.SetWriteDeadline(time.Now().Add(h.options.WriteTimeout))
|
|
return connection.WriteJSON(value)
|
|
}
|
|
writeBinary := func(value []byte) error {
|
|
writeMu.Lock()
|
|
defer writeMu.Unlock()
|
|
_ = connection.SetWriteDeadline(time.Now().Add(h.options.WriteTimeout))
|
|
return connection.WriteMessage(websocket.BinaryMessage, value)
|
|
}
|
|
writePing := func() error {
|
|
writeMu.Lock()
|
|
defer writeMu.Unlock()
|
|
return connection.WriteControl(websocket.PingMessage, nil, time.Now().Add(h.options.WriteTimeout))
|
|
}
|
|
connectedEvent := SessionEvent{Type: SessionEventConnected, Source: source, Timestamp: time.Now().UTC()}
|
|
if err := h.recordSessionEvent(binding, connectedEvent); err != nil {
|
|
h.metrics.sessionLogFailures.Add(1)
|
|
_ = writeJSON(errorStatus("session_log_unavailable", "Direct Mode session log is unavailable"))
|
|
h.closeConnection(connection, websocket.CloseInternalServerErr, "session log unavailable")
|
|
return
|
|
}
|
|
connectedLogged = true
|
|
if err := writeJSON(h.statusFrame(claims.SessionID, claims.Channel, claims.Access)); err != nil {
|
|
return
|
|
}
|
|
if err := writeBinary(welcomeFrame(claims, connectedEvent)); err != nil {
|
|
h.metrics.outboundFailures.Add(1)
|
|
return
|
|
}
|
|
h.metrics.welcomeFrames.Add(1)
|
|
|
|
done := make(chan struct{})
|
|
var stopOnce sync.Once
|
|
stop := func() {
|
|
stopOnce.Do(func() {
|
|
close(done)
|
|
_ = connection.Close()
|
|
})
|
|
}
|
|
defer stop()
|
|
go func() {
|
|
for {
|
|
data, err := attachment.Receive(context.Background())
|
|
if err != nil {
|
|
h.metrics.outboundFailures.Add(1)
|
|
stop()
|
|
return
|
|
}
|
|
if writeBinary(data) != nil {
|
|
h.metrics.outboundFailures.Add(1)
|
|
stop()
|
|
return
|
|
}
|
|
}
|
|
}()
|
|
go func() {
|
|
ticker := time.NewTicker(h.options.PingInterval)
|
|
defer ticker.Stop()
|
|
for {
|
|
select {
|
|
case <-ticker.C:
|
|
if writePing() != nil {
|
|
h.metrics.outboundFailures.Add(1)
|
|
stop()
|
|
return
|
|
}
|
|
ctx, cancel := context.WithTimeout(context.Background(), h.options.WriteTimeout)
|
|
err := h.lifecycle.HeartbeatDirectStream(ctx, binding)
|
|
cancel()
|
|
if err != nil {
|
|
h.metrics.heartbeatFailures.Add(1)
|
|
h.metrics.lifecycleFailures.Add(1)
|
|
stop()
|
|
return
|
|
}
|
|
case <-done:
|
|
return
|
|
}
|
|
}
|
|
}()
|
|
|
|
access := claims.Access
|
|
for {
|
|
messageType, payload, err = connection.ReadMessage()
|
|
if err != nil {
|
|
return
|
|
}
|
|
switch messageType {
|
|
case websocket.BinaryMessage:
|
|
if err := ValidateClientData(access, true, payload); err != nil {
|
|
if writeJSON(errorStatus("writer_required", err.Error())) != nil {
|
|
return
|
|
}
|
|
continue
|
|
}
|
|
if err := attachment.Write(context.Background(), payload); err != nil {
|
|
code := "stream_write_failed"
|
|
if errors.Is(err, ErrWriterRequired) {
|
|
code = "writer_required"
|
|
}
|
|
if writeJSON(errorStatus(code, err.Error())) != nil {
|
|
return
|
|
}
|
|
}
|
|
case websocket.TextMessage:
|
|
if len(payload) > MaxControlFrameBytes {
|
|
h.closeConnection(connection, websocket.CloseMessageTooBig, "control frame too large")
|
|
return
|
|
}
|
|
control, err := DecodeControlFrame(payload)
|
|
if err != nil || control.Type == ControlAuthenticate {
|
|
h.closeConnection(connection, websocket.ClosePolicyViolation, "invalid control frame")
|
|
return
|
|
}
|
|
switch control.Type {
|
|
case ControlWriterRequest:
|
|
if access != AccessWriter {
|
|
if writeJSON(errorStatus("writer_required", "writer authorization requires a new REST claim")) != nil {
|
|
return
|
|
}
|
|
continue
|
|
}
|
|
if _, err := attachment.RequestWriter(); err != nil {
|
|
if writeJSON(errorStatus("writer_request_failed", err.Error())) != nil {
|
|
return
|
|
}
|
|
continue
|
|
}
|
|
case ControlWriterRelease:
|
|
if err := attachment.ReleaseWriter(); err != nil {
|
|
if writeJSON(errorStatus("writer_release_failed", err.Error())) != nil {
|
|
return
|
|
}
|
|
continue
|
|
}
|
|
if err := h.lifecycle.ReleaseDirectWriter(context.Background(), binding); err != nil {
|
|
h.metrics.lifecycleFailures.Add(1)
|
|
if writeJSON(errorStatus("writer_release_failed", err.Error())) != nil {
|
|
return
|
|
}
|
|
continue
|
|
}
|
|
access = AccessViewer
|
|
}
|
|
if writeJSON(h.statusFrame(claims.SessionID, claims.Channel, access)) != nil {
|
|
return
|
|
}
|
|
default:
|
|
h.closeConnection(connection, websocket.CloseUnsupportedData, "unsupported frame type")
|
|
return
|
|
}
|
|
}
|
|
}
|
|
|
|
func (h *webSocketHandler) statusFrame(sessionID, channel, access string) WebSocketStatusFrame {
|
|
snapshot, _ := h.broker.Session(sessionID)
|
|
return WebSocketStatusFrame{
|
|
Type: "session.status", SessionID: sessionID, Channel: channel,
|
|
Access: access, Writer: snapshot.Writer, QueuePosition: snapshot.QueuePosition,
|
|
}
|
|
}
|
|
|
|
func (h *webSocketHandler) closeConnection(connection *websocket.Conn, code int, reason string) {
|
|
deadline := time.Now().Add(h.options.WriteTimeout)
|
|
_ = connection.WriteControl(websocket.CloseMessage, websocket.FormatCloseMessage(code, reason), deadline)
|
|
}
|
|
|
|
func (h *webSocketHandler) closeLifecycle(binding TicketBinding) error {
|
|
ctx, cancel := context.WithTimeout(context.Background(), h.options.WriteTimeout)
|
|
defer cancel()
|
|
return h.lifecycle.CloseDirectStream(ctx, binding)
|
|
}
|
|
|
|
func (h *webSocketHandler) recordSessionEvent(binding TicketBinding, event SessionEvent) error {
|
|
ctx, cancel := context.WithTimeout(context.Background(), h.options.WriteTimeout)
|
|
defer cancel()
|
|
return h.lifecycle.RecordDirectStreamEvent(ctx, binding, event)
|
|
}
|
|
|
|
func directStreamSource(request *http.Request) string {
|
|
host, _, err := net.SplitHostPort(strings.TrimSpace(request.RemoteAddr))
|
|
if err != nil {
|
|
host = strings.Trim(strings.TrimSpace(request.RemoteAddr), "[]")
|
|
}
|
|
remoteIP := net.ParseIP(host)
|
|
if remoteIP == nil {
|
|
return host
|
|
}
|
|
if remoteIP.IsLoopback() {
|
|
forwarded := strings.Split(request.Header.Get("X-Forwarded-For"), ",")
|
|
if len(forwarded) == 1 {
|
|
if forwardedIP := net.ParseIP(strings.TrimSpace(forwarded[0])); forwardedIP != nil {
|
|
return forwardedIP.String()
|
|
}
|
|
}
|
|
}
|
|
return remoteIP.String()
|
|
}
|
|
|
|
func (h *webSocketHandler) originAllowed(request *http.Request) bool {
|
|
origin := request.Header.Get("Origin")
|
|
if origin == "" {
|
|
return false
|
|
}
|
|
if len(h.origins) != 0 {
|
|
_, ok := h.origins[origin]
|
|
return ok
|
|
}
|
|
parsed, err := url.Parse(origin)
|
|
if err != nil || parsed.Host == "" || parsed.Host != request.Host {
|
|
return false
|
|
}
|
|
expectedScheme := "http"
|
|
if request.TLS != nil || strings.EqualFold(request.Header.Get("X-Forwarded-Proto"), "https") {
|
|
expectedScheme = "https"
|
|
}
|
|
return parsed.Scheme == expectedScheme
|
|
}
|
|
|
|
func parseDirectStreamPath(path string) (string, string, bool) {
|
|
parts := strings.Split(strings.Trim(path, "/"), "/")
|
|
if len(parts) != 7 || parts[0] != "api" || parts[1] != "v1" || parts[2] != "direct" || parts[4] != "sessions" || parts[6] != "stream" {
|
|
return "", "", false
|
|
}
|
|
if ValidateChannel(parts[3]) != nil || !directSessionIDPattern.MatchString(parts[5]) {
|
|
return "", "", false
|
|
}
|
|
return parts[3], parts[5], true
|
|
}
|
|
|
|
func containsString(values []string, expected string) bool {
|
|
for _, value := range values {
|
|
if value == expected {
|
|
return true
|
|
}
|
|
}
|
|
return false
|
|
}
|
|
|
|
func errorStatus(code, message string) WebSocketStatusFrame {
|
|
return WebSocketStatusFrame{Type: "error", Code: code, Message: message}
|
|
}
|
|
|