mirror of
https://github.com/coder/coder.git
synced 2025-07-03 16:13:58 +00:00
chore: add support for one-way websockets to backend (#16853)
Closes https://github.com/coder/coder/issues/16775 ## Changes made - Added `OneWayWebSocket` function that establishes WebSocket connections that don't allow client-to-server communication - Added tests for the new function - Updated API endpoints to make new WS-based endpoints, and mark previous SSE-based endpoints as deprecated - Updated existing SSE handlers to use the same core logic as the new WS handlers ## Notes - Frontend changes handled via #16855
This commit is contained in:
@ -16,6 +16,9 @@ import (
|
||||
"github.com/go-playground/validator/v10"
|
||||
"golang.org/x/xerrors"
|
||||
|
||||
"github.com/coder/websocket"
|
||||
"github.com/coder/websocket/wsjson"
|
||||
|
||||
"github.com/coder/coder/v2/coderd/httpapi/httpapiconstraints"
|
||||
"github.com/coder/coder/v2/coderd/tracing"
|
||||
"github.com/coder/coder/v2/codersdk"
|
||||
@ -282,7 +285,25 @@ func WebsocketCloseSprintf(format string, vars ...any) string {
|
||||
return msg
|
||||
}
|
||||
|
||||
func ServerSentEventSender(rw http.ResponseWriter, r *http.Request) (sendEvent func(ctx context.Context, sse codersdk.ServerSentEvent) error, closed chan struct{}, err error) {
|
||||
type EventSender func(rw http.ResponseWriter, r *http.Request) (
|
||||
sendEvent func(sse codersdk.ServerSentEvent) error,
|
||||
done <-chan struct{},
|
||||
err error,
|
||||
)
|
||||
|
||||
// ServerSentEventSender establishes a Server-Sent Event connection and allows
|
||||
// the consumer to send messages to the client.
|
||||
//
|
||||
// The function returned allows you to send a single message to the client,
|
||||
// while the channel lets you listen for when the connection closes.
|
||||
//
|
||||
// As much as possible, this function should be avoided in favor of using the
|
||||
// OneWayWebSocket function. See OneWayWebSocket for more context.
|
||||
func ServerSentEventSender(rw http.ResponseWriter, r *http.Request) (
|
||||
func(sse codersdk.ServerSentEvent) error,
|
||||
<-chan struct{},
|
||||
error,
|
||||
) {
|
||||
h := rw.Header()
|
||||
h.Set("Content-Type", "text/event-stream")
|
||||
h.Set("Cache-Control", "no-cache")
|
||||
@ -294,7 +315,8 @@ func ServerSentEventSender(rw http.ResponseWriter, r *http.Request) (sendEvent f
|
||||
panic("http.ResponseWriter is not http.Flusher")
|
||||
}
|
||||
|
||||
closed = make(chan struct{})
|
||||
ctx := r.Context()
|
||||
closed := make(chan struct{})
|
||||
type sseEvent struct {
|
||||
payload []byte
|
||||
errC chan error
|
||||
@ -304,16 +326,13 @@ func ServerSentEventSender(rw http.ResponseWriter, r *http.Request) (sendEvent f
|
||||
// Synchronized handling of events (no guarantee of order).
|
||||
go func() {
|
||||
defer close(closed)
|
||||
|
||||
// Send a heartbeat every 15 seconds to avoid the connection being killed.
|
||||
ticker := time.NewTicker(time.Second * 15)
|
||||
ticker := time.NewTicker(HeartbeatInterval)
|
||||
defer ticker.Stop()
|
||||
|
||||
for {
|
||||
var event sseEvent
|
||||
|
||||
select {
|
||||
case <-r.Context().Done():
|
||||
case <-ctx.Done():
|
||||
return
|
||||
case event = <-eventC:
|
||||
case <-ticker.C:
|
||||
@ -333,21 +352,21 @@ func ServerSentEventSender(rw http.ResponseWriter, r *http.Request) (sendEvent f
|
||||
}
|
||||
}()
|
||||
|
||||
sendEvent = func(ctx context.Context, sse codersdk.ServerSentEvent) error {
|
||||
sendEvent := func(newEvent codersdk.ServerSentEvent) error {
|
||||
buf := &bytes.Buffer{}
|
||||
enc := json.NewEncoder(buf)
|
||||
|
||||
_, err := buf.WriteString(fmt.Sprintf("event: %s\n", sse.Type))
|
||||
_, err := buf.WriteString(fmt.Sprintf("event: %s\n", newEvent.Type))
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if sse.Data != nil {
|
||||
if newEvent.Data != nil {
|
||||
_, err = buf.WriteString("data: ")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
err = enc.Encode(sse.Data)
|
||||
|
||||
enc := json.NewEncoder(buf)
|
||||
err = enc.Encode(newEvent.Data)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
@ -364,8 +383,6 @@ func ServerSentEventSender(rw http.ResponseWriter, r *http.Request) (sendEvent f
|
||||
}
|
||||
|
||||
select {
|
||||
case <-r.Context().Done():
|
||||
return r.Context().Err()
|
||||
case <-ctx.Done():
|
||||
return ctx.Err()
|
||||
case <-closed:
|
||||
@ -375,8 +392,6 @@ func ServerSentEventSender(rw http.ResponseWriter, r *http.Request) (sendEvent f
|
||||
// for early exit. We don't check closed here because it
|
||||
// can't happen while processing the event.
|
||||
select {
|
||||
case <-r.Context().Done():
|
||||
return r.Context().Err()
|
||||
case <-ctx.Done():
|
||||
return ctx.Err()
|
||||
case err := <-event.errC:
|
||||
@ -387,3 +402,90 @@ func ServerSentEventSender(rw http.ResponseWriter, r *http.Request) (sendEvent f
|
||||
|
||||
return sendEvent, closed, nil
|
||||
}
|
||||
|
||||
// OneWayWebSocketEventSender establishes a new WebSocket connection that
|
||||
// enforces one-way communication from the server to the client.
|
||||
//
|
||||
// The function returned allows you to send a single message to the client,
|
||||
// while the channel lets you listen for when the connection closes.
|
||||
//
|
||||
// We must use an approach like this instead of Server-Sent Events for the
|
||||
// browser, because on HTTP/1.1 connections, browsers are locked to no more than
|
||||
// six HTTP connections for a domain total, across all tabs. If a user were to
|
||||
// open a workspace in multiple tabs, the entire UI can start to lock up.
|
||||
// WebSockets have no such limitation, no matter what HTTP protocol was used to
|
||||
// establish the connection.
|
||||
func OneWayWebSocketEventSender(rw http.ResponseWriter, r *http.Request) (
|
||||
func(event codersdk.ServerSentEvent) error,
|
||||
<-chan struct{},
|
||||
error,
|
||||
) {
|
||||
ctx, cancel := context.WithCancel(r.Context())
|
||||
r = r.WithContext(ctx)
|
||||
socket, err := websocket.Accept(rw, r, nil)
|
||||
if err != nil {
|
||||
cancel()
|
||||
return nil, nil, xerrors.Errorf("cannot establish connection: %w", err)
|
||||
}
|
||||
go Heartbeat(ctx, socket)
|
||||
|
||||
eventC := make(chan codersdk.ServerSentEvent)
|
||||
socketErrC := make(chan websocket.CloseError, 1)
|
||||
closed := make(chan struct{})
|
||||
go func() {
|
||||
defer cancel()
|
||||
defer close(closed)
|
||||
|
||||
for {
|
||||
select {
|
||||
case event := <-eventC:
|
||||
writeCtx, cancel := context.WithTimeout(ctx, 10*time.Second)
|
||||
err := wsjson.Write(writeCtx, socket, event)
|
||||
cancel()
|
||||
if err == nil {
|
||||
continue
|
||||
}
|
||||
_ = socket.Close(websocket.StatusInternalError, "Unable to send newest message")
|
||||
case err := <-socketErrC:
|
||||
_ = socket.Close(err.Code, err.Reason)
|
||||
case <-ctx.Done():
|
||||
_ = socket.Close(websocket.StatusNormalClosure, "Connection closed")
|
||||
}
|
||||
return
|
||||
}
|
||||
}()
|
||||
|
||||
// We have some tools in the UI code to help enforce one-way WebSocket
|
||||
// connections, but there's still the possibility that the client could send
|
||||
// a message when it's not supposed to. If that happens, the client likely
|
||||
// forgot to use those tools, and communication probably can't be trusted.
|
||||
// Better to just close the socket and force the UI to fix its mess
|
||||
go func() {
|
||||
_, _, err := socket.Read(ctx)
|
||||
if errors.Is(err, context.Canceled) {
|
||||
return
|
||||
}
|
||||
if err != nil {
|
||||
socketErrC <- websocket.CloseError{
|
||||
Code: websocket.StatusInternalError,
|
||||
Reason: "Unable to process invalid message from client",
|
||||
}
|
||||
return
|
||||
}
|
||||
socketErrC <- websocket.CloseError{
|
||||
Code: websocket.StatusProtocolError,
|
||||
Reason: "Clients cannot send messages for one-way WebSockets",
|
||||
}
|
||||
}()
|
||||
|
||||
sendEvent := func(event codersdk.ServerSentEvent) error {
|
||||
select {
|
||||
case eventC <- event:
|
||||
case <-ctx.Done():
|
||||
return ctx.Err()
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
return sendEvent, closed, nil
|
||||
}
|
||||
|
Reference in New Issue
Block a user