snapshot of https://github.com/cortezaproject/corteza.git (2024.9.x, 2026-09-18; blobs > 131072B omitted)
Some checks failed
SonarQube / SonarQube (push) Failing after 5m28s
E2e test on main branch push / server and client test (map[name:admin port:8080]) (push) Failing after 3m33s
Server and lib tests / Library test (js) (push) Failing after 1m36s
Server and lib tests / Library test (vue) (push) Successful in 2m24s
Server and lib tests / Web client test (admin) (push) Successful in 2m52s
Server and lib tests / Web client test (compose) (push) Successful in 3m23s
Server and lib tests / Web client test (discovery) (push) Successful in 2m49s
Server and lib tests / Web client test (one) (push) Successful in 2m52s
Server and lib tests / Web client test (privacy) (push) Successful in 2m44s
Server and lib tests / Web client test (reporter) (push) Successful in 2m56s
Server and lib tests / Web client test (workflow) (push) Successful in 3m0s
Server and lib tests / Server tests (push) Failing after 4m55s
E2e test on main branch push / notify-failure (push) Failing after 9s
Server and lib tests / Send matrix testing notification (push) Successful in 2s
Mark stale issues and pull requests / stale (push) Successful in 40s
Some checks failed
SonarQube / SonarQube (push) Failing after 5m28s
E2e test on main branch push / server and client test (map[name:admin port:8080]) (push) Failing after 3m33s
Server and lib tests / Library test (js) (push) Failing after 1m36s
Server and lib tests / Library test (vue) (push) Successful in 2m24s
Server and lib tests / Web client test (admin) (push) Successful in 2m52s
Server and lib tests / Web client test (compose) (push) Successful in 3m23s
Server and lib tests / Web client test (discovery) (push) Successful in 2m49s
Server and lib tests / Web client test (one) (push) Successful in 2m52s
Server and lib tests / Web client test (privacy) (push) Successful in 2m44s
Server and lib tests / Web client test (reporter) (push) Successful in 2m56s
Server and lib tests / Web client test (workflow) (push) Successful in 3m0s
Server and lib tests / Server tests (push) Failing after 4m55s
E2e test on main branch push / notify-failure (push) Failing after 9s
Server and lib tests / Send matrix testing notification (push) Successful in 2s
Mark stale issues and pull requests / stale (push) Successful in 40s
This commit is contained in:
44
server/pkg/websocket/payload.go
Normal file
44
server/pkg/websocket/payload.go
Normal file
@@ -0,0 +1,44 @@
|
||||
package websocket
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
)
|
||||
|
||||
type (
|
||||
// Auth is JWT token provided by client as first message,
|
||||
// and will be passed whenever it changes
|
||||
payloadAuth struct {
|
||||
AccessToken string `json:"accessToken"`
|
||||
}
|
||||
|
||||
payloadWrap struct {
|
||||
Type string `json:"@type"`
|
||||
Value json.RawMessage `json:"@value"`
|
||||
}
|
||||
)
|
||||
|
||||
const (
|
||||
payloadTypeCredentials = "credentials"
|
||||
)
|
||||
|
||||
var (
|
||||
closingUnidentifiedConn, _ = MarshalPayload("error", "closing unidentified connection")
|
||||
ok, _ = MarshalPayload("message", "authenticated")
|
||||
)
|
||||
|
||||
func (p payloadWrap) UnmarshalValue(m interface{}) error {
|
||||
return json.Unmarshal(p.Value, m)
|
||||
}
|
||||
|
||||
func MarshalPayload(t string, m interface{}) ([]byte, error) {
|
||||
var (
|
||||
err error
|
||||
w = payloadWrap{Type: t}
|
||||
)
|
||||
|
||||
if w.Value, err = json.Marshal(m); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return json.Marshal(w)
|
||||
}
|
||||
14
server/pkg/websocket/router.go
Normal file
14
server/pkg/websocket/router.go
Normal file
@@ -0,0 +1,14 @@
|
||||
package websocket
|
||||
|
||||
import (
|
||||
"github.com/go-chi/chi/v5"
|
||||
)
|
||||
|
||||
// MountRoutes initialize route for websocket
|
||||
// No middleware used, since anyone can open connection and
|
||||
// send first message with valid JWT token,
|
||||
// If it's valid then we keep the connection open or close it
|
||||
func (ws *server) MountRoutes(r chi.Router) {
|
||||
// Initialize handlers & controllers.
|
||||
r.Get("/", ws.Open)
|
||||
}
|
||||
137
server/pkg/websocket/server.go
Normal file
137
server/pkg/websocket/server.go
Normal file
@@ -0,0 +1,137 @@
|
||||
package websocket
|
||||
|
||||
import (
|
||||
"context"
|
||||
"io"
|
||||
"net/http"
|
||||
"sync"
|
||||
|
||||
"github.com/cortezaproject/corteza/server/pkg/auth"
|
||||
"github.com/cortezaproject/corteza/server/pkg/errors"
|
||||
"github.com/cortezaproject/corteza/server/pkg/options"
|
||||
"github.com/cortezaproject/corteza/server/pkg/slice"
|
||||
"github.com/gorilla/websocket"
|
||||
"go.uber.org/zap"
|
||||
)
|
||||
|
||||
type (
|
||||
tokenValidator func(context.Context, string) (auth.Identifiable, error)
|
||||
|
||||
server struct {
|
||||
config options.WebsocketOpt
|
||||
logger *zap.Logger
|
||||
|
||||
// user id => session id => session
|
||||
sessions map[uint64]map[uint64]io.Writer
|
||||
|
||||
// keep lock on session map changes
|
||||
l sync.RWMutex
|
||||
|
||||
tokenValidator tokenValidator
|
||||
}
|
||||
)
|
||||
|
||||
var (
|
||||
// upgrader handles websocket requests from peers
|
||||
upgrader = websocket.Upgrader{
|
||||
ReadBufferSize: 1024,
|
||||
WriteBufferSize: 1024,
|
||||
|
||||
// Allow connections from any Origin
|
||||
CheckOrigin: func(r *http.Request) bool { return true },
|
||||
}
|
||||
)
|
||||
|
||||
func Server(logger *zap.Logger, config options.WebsocketOpt, tv tokenValidator) *server {
|
||||
if !config.LogEnabled {
|
||||
logger = zap.NewNop()
|
||||
}
|
||||
|
||||
return &server{
|
||||
config: config,
|
||||
logger: logger.Named("websocket"),
|
||||
sessions: make(map[uint64]map[uint64]io.Writer),
|
||||
tokenValidator: tv,
|
||||
}
|
||||
}
|
||||
|
||||
func (ws *server) Open(w http.ResponseWriter, r *http.Request) {
|
||||
ctx := r.Context()
|
||||
|
||||
conn, err := upgrader.Upgrade(w, r, nil)
|
||||
if _, ok := err.(websocket.HandshakeError); ok {
|
||||
errors.ProperlyServeHTTP(w, r, errors.Internal("need a websocket handshake"), false)
|
||||
return
|
||||
} else if err != nil {
|
||||
errors.ProperlyServeHTTP(w, r, errors.Internal("failed to upgrade connection").Wrap(err), false)
|
||||
return
|
||||
}
|
||||
|
||||
// init new session
|
||||
//
|
||||
// session will add itself back to server's session map when
|
||||
// ready (if user authenticates itself)
|
||||
ses := Session(ctx, ws, conn)
|
||||
|
||||
if err = ses.Handle(); err != nil {
|
||||
ws.logger.Warn("websocket session handler error", zap.Error(err))
|
||||
}
|
||||
}
|
||||
|
||||
// Send delivers payload to one, more or all users
|
||||
//
|
||||
// Omit userIDs to deliver to ALL users
|
||||
func (ws *server) Send(t string, payload interface{}, userIDs ...uint64) error {
|
||||
pb, err := MarshalPayload(t, payload)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
var (
|
||||
sendToAll = len(userIDs) == 0
|
||||
uMap = slice.ToUint64BoolMap(userIDs)
|
||||
)
|
||||
|
||||
ws.l.RLock()
|
||||
defer ws.l.RUnlock()
|
||||
|
||||
for uid := range ws.sessions {
|
||||
if sendToAll || (!sendToAll && uMap[uid]) {
|
||||
for _, s := range ws.sessions[uid] {
|
||||
_, err = s.Write(pb)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (ws *server) StoreSession(s *session) {
|
||||
ws.l.Lock()
|
||||
defer ws.l.Unlock()
|
||||
if i := s.Identity(); i != nil {
|
||||
ws.storeSession(s, i.Identity(), s.id)
|
||||
}
|
||||
}
|
||||
|
||||
func (ws *server) storeSession(w io.Writer, uid, sid uint64) {
|
||||
if ws.sessions[uid] == nil {
|
||||
ws.sessions[uid] = make(map[uint64]io.Writer)
|
||||
|
||||
}
|
||||
|
||||
ws.sessions[uid][sid] = w
|
||||
}
|
||||
|
||||
func (ws *server) RemoveSession(s *session) {
|
||||
ws.l.Lock()
|
||||
defer ws.l.Unlock()
|
||||
if i := s.Identity(); i != nil {
|
||||
uid := i.Identity()
|
||||
delete(ws.sessions[uid], s.id)
|
||||
|
||||
if len(ws.sessions[uid]) == 0 {
|
||||
delete(ws.sessions, uid)
|
||||
}
|
||||
}
|
||||
}
|
||||
64
server/pkg/websocket/server_test.go
Normal file
64
server/pkg/websocket/server_test.go
Normal file
@@ -0,0 +1,64 @@
|
||||
package websocket
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"testing"
|
||||
|
||||
"github.com/cortezaproject/corteza/server/pkg/options"
|
||||
"github.com/stretchr/testify/require"
|
||||
"go.uber.org/zap"
|
||||
)
|
||||
|
||||
func TestWebsocketSend_NoSessions(t *testing.T) {
|
||||
var (
|
||||
req = require.New(t)
|
||||
ws = Server(zap.NewNop(), options.WebsocketOpt{}, nil)
|
||||
)
|
||||
|
||||
req.NoError(ws.Send("msg", "msg"))
|
||||
req.NoError(ws.Send("msg", "msg", 1))
|
||||
req.NoError(ws.Send("msg", "msg", 1, 2))
|
||||
req.NoError(ws.Send("msg", "msg", 1, 2, 3))
|
||||
}
|
||||
|
||||
func TestWebsocketSend_ExistingSessions(t *testing.T) {
|
||||
var (
|
||||
req = require.New(t)
|
||||
ws = Server(zap.NewNop(), options.WebsocketOpt{}, nil)
|
||||
|
||||
s1User uint64 = 100
|
||||
s1ID uint64 = 101
|
||||
|
||||
s2User uint64 = 200
|
||||
s2ID uint64 = 201
|
||||
|
||||
s1 = &bytes.Buffer{}
|
||||
s2 = &bytes.Buffer{}
|
||||
)
|
||||
|
||||
ws.storeSession(s1, s1User, s1ID)
|
||||
ws.storeSession(s2, s2User, s2ID)
|
||||
|
||||
req.Empty(s1)
|
||||
req.Empty(s2)
|
||||
|
||||
req.NoError(ws.Send("msg", "msg", 0))
|
||||
req.Empty(s1)
|
||||
req.Empty(s2)
|
||||
|
||||
req.NoError(ws.Send("msg", "msg1", s1User))
|
||||
req.Equal(s1.String(), `{"@type":"msg","@value":"msg1"}`)
|
||||
req.Equal(s2.String(), "")
|
||||
|
||||
req.NoError(ws.Send("msg", "msg2", s2User))
|
||||
req.Equal(s1.String(), `{"@type":"msg","@value":"msg1"}`)
|
||||
req.Equal(s2.String(), `{"@type":"msg","@value":"msg2"}`)
|
||||
|
||||
req.NoError(ws.Send("both", "msg3", s1User, s2User))
|
||||
req.Equal(s1.String(), `{"@type":"msg","@value":"msg1"}{"@type":"both","@value":"msg3"}`)
|
||||
req.Equal(s2.String(), `{"@type":"msg","@value":"msg2"}{"@type":"both","@value":"msg3"}`)
|
||||
|
||||
req.NoError(ws.Send("all", "msg4"))
|
||||
req.Equal(s1.String(), `{"@type":"msg","@value":"msg1"}{"@type":"both","@value":"msg3"}{"@type":"all","@value":"msg4"}`)
|
||||
req.Equal(s2.String(), `{"@type":"msg","@value":"msg2"}{"@type":"both","@value":"msg3"}{"@type":"all","@value":"msg4"}`)
|
||||
}
|
||||
395
server/pkg/websocket/session.go
Normal file
395
server/pkg/websocket/session.go
Normal file
@@ -0,0 +1,395 @@
|
||||
package websocket
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"net"
|
||||
"sync"
|
||||
"sync/atomic"
|
||||
"time"
|
||||
|
||||
"github.com/cortezaproject/corteza/server/pkg/auth"
|
||||
"github.com/cortezaproject/corteza/server/pkg/errors"
|
||||
"github.com/cortezaproject/corteza/server/pkg/id"
|
||||
"github.com/cortezaproject/corteza/server/pkg/logger"
|
||||
"github.com/cortezaproject/corteza/server/pkg/options"
|
||||
"github.com/gorilla/websocket"
|
||||
"go.uber.org/zap"
|
||||
)
|
||||
|
||||
// active sessions of users
|
||||
var (
|
||||
// wrapper around nextID that will aid service testing
|
||||
nextID = func() uint64 {
|
||||
return id.Next()
|
||||
}
|
||||
)
|
||||
|
||||
type (
|
||||
conection interface {
|
||||
Close() error
|
||||
RemoteAddr() net.Addr
|
||||
WriteMessage(messageType int, data []byte) error
|
||||
SetWriteDeadline(t time.Time) error
|
||||
ReadMessage() (messageType int, p []byte, err error)
|
||||
SetReadDeadline(t time.Time) error
|
||||
SetPongHandler(h func(appData string) error)
|
||||
}
|
||||
|
||||
session struct {
|
||||
l sync.RWMutex
|
||||
|
||||
id uint64
|
||||
once sync.Once
|
||||
conn conection
|
||||
closed atomic.Bool
|
||||
|
||||
ctx context.Context
|
||||
ctxCancel context.CancelFunc
|
||||
|
||||
logger *zap.Logger
|
||||
|
||||
send chan []byte
|
||||
stop chan []byte
|
||||
|
||||
remoteAddr string
|
||||
|
||||
config options.WebsocketOpt
|
||||
|
||||
identity auth.Identifiable
|
||||
|
||||
server *server
|
||||
}
|
||||
)
|
||||
|
||||
func Session(ctx context.Context, ws *server, conn *websocket.Conn) *session {
|
||||
s := &session{
|
||||
id: nextID(),
|
||||
conn: conn,
|
||||
config: ws.config,
|
||||
send: make(chan []byte, 512),
|
||||
stop: make(chan []byte, 1),
|
||||
server: ws,
|
||||
}
|
||||
|
||||
s.ctx, s.ctxCancel = context.WithCancel(ctx)
|
||||
|
||||
s.logger = ws.logger.
|
||||
Named("session").
|
||||
With(
|
||||
logger.Uint64("id", s.id),
|
||||
)
|
||||
|
||||
return s
|
||||
}
|
||||
|
||||
func (s *session) Identity() auth.Identifiable {
|
||||
s.l.RLock()
|
||||
defer s.l.RUnlock()
|
||||
return s.identity
|
||||
}
|
||||
|
||||
func (s *session) disconnect() {
|
||||
s.l.Lock()
|
||||
defer s.l.Unlock()
|
||||
|
||||
// Mark session as closed before tearing it down
|
||||
s.closed.Store(true)
|
||||
|
||||
// Cancel context; this is what stops the write loop and unblocks
|
||||
// anyone waiting to queue a message
|
||||
s.ctxCancel()
|
||||
|
||||
s.logger.Info("disconnected")
|
||||
|
||||
// Close connection
|
||||
_ = s.conn.Close()
|
||||
|
||||
// send & stop are deliberately left open. Closing them races with
|
||||
// Write() and panics on "send on closed channel"; they are garbage
|
||||
// collected with the session instead.
|
||||
s.conn = nil
|
||||
}
|
||||
|
||||
func (s *session) Handle() error {
|
||||
go func() {
|
||||
// Close unidentified connections in 5sec
|
||||
<-time.NewTimer(time.Second * 5).C
|
||||
if s.Identity() == nil {
|
||||
_, _ = s.Write(closingUnidentifiedConn)
|
||||
s.logger.Info("closing unidentified connection")
|
||||
s.Close()
|
||||
}
|
||||
}()
|
||||
|
||||
go func() {
|
||||
if err := s.readLoop(); err != nil {
|
||||
if errors.Is(err, net.ErrClosed) {
|
||||
// read will return net.ErrClosed when
|
||||
// recovering from panic
|
||||
return
|
||||
}
|
||||
|
||||
s.logger.Error("read failure", zap.Error(err))
|
||||
}
|
||||
s.Close()
|
||||
}()
|
||||
|
||||
if err := s.writeLoop(); err != nil {
|
||||
if errors.Is(err, net.ErrClosed) {
|
||||
// write will return net.ErrClosed when
|
||||
// recovering from panic
|
||||
return nil
|
||||
}
|
||||
|
||||
s.logger.Error("write failure", zap.Error(err))
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *session) Close() {
|
||||
s.once.Do(func() {
|
||||
s.disconnect()
|
||||
s.server.RemoveSession(s)
|
||||
})
|
||||
}
|
||||
|
||||
func (s *session) readLoop() (err error) {
|
||||
if err = s.conn.SetReadDeadline(time.Now().Add(s.config.PingTimeout)); err != nil {
|
||||
return
|
||||
}
|
||||
|
||||
s.conn.SetPongHandler(func(string) error {
|
||||
return s.conn.SetReadDeadline(time.Now().Add(s.config.PingTimeout))
|
||||
})
|
||||
|
||||
s.remoteAddr = s.conn.RemoteAddr().String()
|
||||
|
||||
var (
|
||||
raw []byte
|
||||
)
|
||||
|
||||
for {
|
||||
if raw, err = s.read(); err != nil {
|
||||
return
|
||||
}
|
||||
|
||||
if raw == nil {
|
||||
continue
|
||||
}
|
||||
|
||||
if err = s.procRawMessage(raw); err != nil {
|
||||
return
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func (s *session) read() (raw []byte, err error) {
|
||||
defer func() {
|
||||
if recovered := recover(); recovered != nil {
|
||||
s.logger.Debug("recovering from websocket read panic", zap.Any("recovered-error", recovered))
|
||||
err = net.ErrClosed
|
||||
}
|
||||
}()
|
||||
|
||||
s.l.RLock()
|
||||
defer s.l.RUnlock()
|
||||
|
||||
// Check if connection was closed by disconnect()
|
||||
if s.conn == nil {
|
||||
return nil, net.ErrClosed
|
||||
}
|
||||
|
||||
if _, raw, err = s.conn.ReadMessage(); err != nil {
|
||||
return nil, errHandler("websocket read failed", err)
|
||||
}
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
func (s *session) procRawMessage(raw []byte) (err error) {
|
||||
pw := payloadWrap{}
|
||||
if err = json.Unmarshal(raw, &pw); err != nil {
|
||||
return fmt.Errorf("could not unmarshal session message: %w", err)
|
||||
}
|
||||
|
||||
if pw.Type == payloadTypeCredentials {
|
||||
authPayload := &payloadAuth{}
|
||||
if err = pw.UnmarshalValue(authPayload); err != nil {
|
||||
return fmt.Errorf("could not unmarshal session payload: %w", err)
|
||||
}
|
||||
|
||||
if err = s.authenticate(authPayload); err != nil {
|
||||
return fmt.Errorf("unauthorized: %w", err)
|
||||
}
|
||||
|
||||
i := s.Identity()
|
||||
s.logger.Debug(
|
||||
"authenticated",
|
||||
logger.Uint64("userID", i.Identity()),
|
||||
logger.Uint64s("roles", i.Roles()),
|
||||
)
|
||||
|
||||
s.server.StoreSession(s)
|
||||
|
||||
// not expecting anything else
|
||||
return
|
||||
}
|
||||
|
||||
if s.Identity() == nil {
|
||||
return fmt.Errorf("unauthenticated session")
|
||||
}
|
||||
|
||||
// at the moment we do not support any other kinds of message types
|
||||
return fmt.Errorf("unknown message type '%s'", pw.Type)
|
||||
}
|
||||
|
||||
// reads send & stop channels and sends received messages to websocket connection via write fn()
|
||||
func (s *session) writeLoop() error {
|
||||
ticker := time.NewTicker(s.config.PingPeriod)
|
||||
|
||||
defer ticker.Stop()
|
||||
defer s.Close() // break readLoop
|
||||
|
||||
for {
|
||||
select {
|
||||
case <-s.ctx.Done():
|
||||
// session disconnected
|
||||
return nil
|
||||
|
||||
case msg, ok := <-s.send:
|
||||
if !ok {
|
||||
// channel closed
|
||||
return nil
|
||||
}
|
||||
|
||||
if err := s.write(websocket.TextMessage, msg); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// continue with wait & write/ping loop
|
||||
|
||||
case msg, ok := <-s.stop:
|
||||
if !ok {
|
||||
// channel closed
|
||||
return nil
|
||||
}
|
||||
|
||||
// Shutdown requested, don't care if the message is delivered
|
||||
if err := s.write(websocket.TextMessage, msg); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// stopping, break the loop.
|
||||
return nil
|
||||
|
||||
case <-ticker.C:
|
||||
if err := s.write(websocket.PingMessage, nil); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// continue with wait & write/ping loop
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// writes messages to websocket connection
|
||||
func (s *session) write(t int, msg []byte) (err error) {
|
||||
s.l.RLock()
|
||||
defer s.l.RUnlock()
|
||||
|
||||
defer func() {
|
||||
if recovered := recover(); recovered != nil {
|
||||
s.logger.Debug("recovering from websocket write panic", zap.Any("recovered-error", recovered))
|
||||
err = net.ErrClosed
|
||||
}
|
||||
}()
|
||||
|
||||
// Check if connection was closed by disconnect()
|
||||
if s.conn == nil {
|
||||
return net.ErrClosed
|
||||
}
|
||||
|
||||
if err = s.conn.SetWriteDeadline(time.Now().Add(s.config.Timeout)); err != nil {
|
||||
return fmt.Errorf("deadline error: %w", err)
|
||||
}
|
||||
|
||||
return errHandler("websocket write failed", s.conn.WriteMessage(t, msg))
|
||||
}
|
||||
|
||||
func (s *session) authenticate(p *payloadAuth) error {
|
||||
identity, err := s.server.tokenValidator(s.ctx, p.AccessToken)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if i := s.Identity(); i != nil {
|
||||
if i.Identity() != identity.Identity() {
|
||||
return fmt.Errorf("identity does not match")
|
||||
}
|
||||
}
|
||||
|
||||
if !identity.Valid() {
|
||||
return fmt.Errorf("invalid identity")
|
||||
}
|
||||
|
||||
s.l.Lock()
|
||||
defer s.l.Unlock()
|
||||
|
||||
s.identity = identity
|
||||
_, _ = s.Write(ok)
|
||||
return nil
|
||||
}
|
||||
|
||||
// sendBytes sends byte to channel or timeout
|
||||
func (s *session) Write(p []byte) (int, error) {
|
||||
// Check if session is closed before attempting to send
|
||||
if s.closed.Load() {
|
||||
return 0, net.ErrClosed
|
||||
}
|
||||
|
||||
defer func() {
|
||||
if recovered := recover(); recovered != nil {
|
||||
s.logger.Debug("recovering from websocket write panic", zap.Any("recovered-error", recovered))
|
||||
}
|
||||
}()
|
||||
|
||||
select {
|
||||
case <-s.ctx.Done():
|
||||
// Session is disconnecting, channel may be closed
|
||||
return 0, net.ErrClosed
|
||||
case s.send <- p:
|
||||
return len(p), nil
|
||||
case <-time.After(2 * time.Millisecond):
|
||||
return 0, fmt.Errorf("write timedout")
|
||||
}
|
||||
}
|
||||
|
||||
func errHandler(prefix string, err error) error {
|
||||
if err == nil {
|
||||
return nil
|
||||
}
|
||||
|
||||
// Handle websocket close errors - these are expected during disconnection
|
||||
if websocket.IsCloseError(err,
|
||||
websocket.CloseNormalClosure,
|
||||
websocket.CloseGoingAway,
|
||||
websocket.CloseAbnormalClosure,
|
||||
websocket.CloseNoStatusReceived,
|
||||
) {
|
||||
return net.ErrClosed
|
||||
}
|
||||
|
||||
if errors.Is(err, net.ErrClosed) {
|
||||
return net.ErrClosed
|
||||
}
|
||||
|
||||
// close sent occurs when writing to a connection that's closing
|
||||
if errors.Is(err, websocket.ErrCloseSent) {
|
||||
return net.ErrClosed
|
||||
}
|
||||
|
||||
return fmt.Errorf(prefix+": %w", err)
|
||||
}
|
||||
203
server/pkg/websocket/session_test.go
Normal file
203
server/pkg/websocket/session_test.go
Normal file
@@ -0,0 +1,203 @@
|
||||
package websocket
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"net"
|
||||
"sync"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/cortezaproject/corteza/server/pkg/auth"
|
||||
"github.com/cortezaproject/corteza/server/pkg/logger"
|
||||
"github.com/cortezaproject/corteza/server/pkg/options"
|
||||
"github.com/gorilla/websocket"
|
||||
"github.com/stretchr/testify/require"
|
||||
"go.uber.org/zap"
|
||||
"go.uber.org/zap/zaptest/observer"
|
||||
)
|
||||
|
||||
type (
|
||||
mockConn struct {
|
||||
close func() error
|
||||
remoteAddr func() net.Addr
|
||||
writeMessage func(messageType int, data []byte) error
|
||||
setWriteDeadline func(t time.Time) error
|
||||
readMessage func() (messageType int, p []byte, err error)
|
||||
setReadDeadline func(t time.Time) error
|
||||
setPongHandler func(h func(appData string) error)
|
||||
}
|
||||
)
|
||||
|
||||
func MockConn() *mockConn {
|
||||
return &mockConn{
|
||||
close: func() (err error) { return },
|
||||
remoteAddr: func() (addr net.Addr) { return &net.IPAddr{IP: net.IPv4(0, 0, 0, 0)} },
|
||||
writeMessage: func(messageType int, data []byte) (err error) { return },
|
||||
setWriteDeadline: func(t time.Time) (err error) { return },
|
||||
readMessage: func() (messageType int, p []byte, err error) { return },
|
||||
setReadDeadline: func(t time.Time) (err error) { return },
|
||||
setPongHandler: func(h func(appData string) error) {},
|
||||
}
|
||||
}
|
||||
|
||||
func (c *mockConn) Close() error { return c.close() }
|
||||
func (c *mockConn) RemoteAddr() net.Addr { return c.remoteAddr() }
|
||||
func (c *mockConn) WriteMessage(messageType int, data []byte) error {
|
||||
return c.writeMessage(messageType, data)
|
||||
}
|
||||
func (c *mockConn) SetWriteDeadline(t time.Time) error { return c.setWriteDeadline(t) }
|
||||
func (c *mockConn) ReadMessage() (messageType int, p []byte, err error) { return c.readMessage() }
|
||||
func (c *mockConn) SetReadDeadline(t time.Time) error { return c.setReadDeadline(t) }
|
||||
func (c *mockConn) SetPongHandler(h func(appData string) error) { c.setPongHandler(h) }
|
||||
|
||||
func TestSession_procRawMessage(t *testing.T) {
|
||||
var (
|
||||
req = require.New(t)
|
||||
|
||||
identity1 = auth.Authenticated(123, 456, 789)
|
||||
identity2 = auth.Authenticated(321, 456, 789)
|
||||
|
||||
s = session{
|
||||
server: Server(
|
||||
nil,
|
||||
options.WebsocketOpt{},
|
||||
func(ctx context.Context, accessToken string) (auth.Identifiable, error) {
|
||||
switch accessToken {
|
||||
case "one":
|
||||
return identity1, nil
|
||||
case "two":
|
||||
return identity2, nil
|
||||
case "":
|
||||
return nil, fmt.Errorf("failed to parse token: EOF")
|
||||
}
|
||||
|
||||
return nil, fmt.Errorf("something else went wrong")
|
||||
}),
|
||||
}
|
||||
|
||||
token []byte
|
||||
|
||||
mockResponse = func(token []byte) (out []byte) {
|
||||
out = []byte(`{"@type": "credentials", "@value": {"accessToken": "`)
|
||||
out = append(out, token...)
|
||||
out = append(out, []byte(`"}}`)...)
|
||||
return
|
||||
}
|
||||
)
|
||||
|
||||
if testing.Verbose() {
|
||||
s.logger = logger.MakeDebugLogger()
|
||||
} else {
|
||||
s.logger = zap.NewNop()
|
||||
}
|
||||
|
||||
req.EqualError(s.procRawMessage([]byte("{}")), "unauthenticated session")
|
||||
req.Nil(s.Identity())
|
||||
|
||||
req.EqualError(s.procRawMessage(mockResponse(nil)), "unauthorized: failed to parse token: EOF")
|
||||
req.Nil(s.Identity())
|
||||
|
||||
token = []byte("one")
|
||||
req.NoError(s.procRawMessage(mockResponse(token)))
|
||||
req.NotNil(s.Identity())
|
||||
req.Equal(identity1.Identity(), s.Identity().Identity())
|
||||
|
||||
req.EqualError(s.procRawMessage([]byte("{}")), "unknown message type ''")
|
||||
req.Equal(identity1.Identity(), s.Identity().Identity())
|
||||
|
||||
token = []byte("one")
|
||||
req.NoError(s.procRawMessage(mockResponse(token)))
|
||||
req.NotNil(s.Identity())
|
||||
req.Equal(identity1.Identity(), s.Identity().Identity())
|
||||
|
||||
token = []byte("two")
|
||||
req.EqualError(s.procRawMessage(mockResponse(token)), "unauthorized: identity does not match")
|
||||
}
|
||||
|
||||
func TestSession_writeDuringDisconnect(t *testing.T) {
|
||||
var (
|
||||
req = require.New(t)
|
||||
|
||||
core, logs = observer.New(zap.DebugLevel)
|
||||
|
||||
s = session{
|
||||
conn: MockConn(),
|
||||
logger: zap.New(core),
|
||||
config: options.WebsocketOpt{},
|
||||
send: make(chan []byte, 512),
|
||||
stop: make(chan []byte, 1),
|
||||
}
|
||||
|
||||
wg sync.WaitGroup
|
||||
halt = make(chan struct{})
|
||||
)
|
||||
|
||||
s.ctx, s.ctxCancel = context.WithCancel(context.Background())
|
||||
|
||||
// hammer Write() from several goroutines while the session is torn
|
||||
// down underneath them
|
||||
for i := 0; i < 8; i++ {
|
||||
wg.Add(1)
|
||||
go func() {
|
||||
defer wg.Done()
|
||||
for {
|
||||
select {
|
||||
case <-halt:
|
||||
return
|
||||
default:
|
||||
_, _ = s.Write([]byte("foo"))
|
||||
}
|
||||
}
|
||||
}()
|
||||
}
|
||||
|
||||
s.disconnect()
|
||||
close(halt)
|
||||
wg.Wait()
|
||||
|
||||
// disconnect must leave the channels open — closing them races with
|
||||
// Write() and panics with "send on closed channel". stop is never
|
||||
// written to, so a receive here only succeeds if it was closed.
|
||||
select {
|
||||
case _, ok := <-s.stop:
|
||||
req.True(ok, "stop channel must not be closed by disconnect")
|
||||
default:
|
||||
}
|
||||
|
||||
req.Zero(logs.FilterMessageSnippet("recovering from websocket").Len(), "expected no recovered panics")
|
||||
}
|
||||
|
||||
func TestSession_disconnected(t *testing.T) {
|
||||
var (
|
||||
req = require.New(t)
|
||||
|
||||
core, logs = observer.New(zap.DebugLevel)
|
||||
|
||||
s = session{
|
||||
conn: MockConn(),
|
||||
logger: zap.New(core),
|
||||
config: options.WebsocketOpt{},
|
||||
send: make(chan []byte, 1),
|
||||
stop: make(chan []byte, 1),
|
||||
}
|
||||
)
|
||||
|
||||
s.ctx, s.ctxCancel = context.WithCancel(context.Background())
|
||||
s.disconnect()
|
||||
|
||||
// reading, writing and queueing on a disconnected session must report a
|
||||
// closed connection instead of panicking on a nil connection or on a
|
||||
// closed send channel
|
||||
raw, err := s.read()
|
||||
req.Nil(raw)
|
||||
req.ErrorIs(err, net.ErrClosed)
|
||||
|
||||
req.ErrorIs(s.write(websocket.TextMessage, []byte("foo")), net.ErrClosed)
|
||||
|
||||
n, err := s.Write([]byte("foo"))
|
||||
req.Zero(n)
|
||||
req.ErrorIs(err, net.ErrClosed)
|
||||
|
||||
req.Zero(logs.FilterMessageSnippet("recovering from websocket").Len(), "expected no recovered panics")
|
||||
}
|
||||
Reference in New Issue
Block a user