Files
mostlymatter/app/web_conn.go
Agniva De Sarker 2743089b54 MM-34000: Use non-epoll mode for TLS connections (#17172)
* MM-34000: Use non-epoll mode for TLS connections

A *crypto/tls.Conn does not expose the underlying TCP connection
or even a File method to get the underlying file descriptor
like the way a *net/TCPConn does. Therefore the netpoll code would
fail to get the file descriptor.

Relevant issue here: https://github.com/mailru/easygo/issues/3

It is indeed possible to use reflect black magic to get the unexported
member, but I have found unexpected errors during writing to the websocket
by getting the file descriptor this way. I do not want to spend time investigating
this especially since this is already released.

Once this is out, we can decide on the right way to fix this, most probably
by proposing to expose the File method or some other way.

https://mattermost.atlassian.net/browse/MM-34000

```release-note
Fix an issue where websockets wouldn't work with TLS connections.
In that case, we just fall back to the way it works for Windows machines,
which is to use a separate goroutine for reader connection.
```

* Ignore logging errors on non-epoll

On non-epoll systems, we needed to return an error
to break from the loop. But in that case, there is no
need to log the error
2021-03-18 22:27:53 +05:30

565 строки
16 KiB
Go

// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
package app
import (
"bytes"
"encoding/json"
"fmt"
"net"
"net/http"
"runtime"
"strings"
"sync"
"sync/atomic"
"time"
"github.com/gobwas/ws"
"github.com/gobwas/ws/wsutil"
"github.com/mailru/easygo/netpoll"
"github.com/pkg/errors"
"github.com/mattermost/mattermost-server/v5/model"
"github.com/mattermost/mattermost-server/v5/shared/i18n"
"github.com/mattermost/mattermost-server/v5/shared/mlog"
)
const (
sendQueueSize = 256
sendSlowWarn = (sendQueueSize * 50) / 100
sendFullWarn = (sendQueueSize * 95) / 100
writeWaitTime = 30 * time.Second
pongWaitTime = 100 * time.Second
pingInterval = (pongWaitTime * 6) / 10
authCheckInterval = 5 * time.Second
webConnMemberCacheTime = 1000 * 60 * 30 // 30 minutes
)
var errNonEpollConnClose = errors.New("connection closed")
// WebConn represents a single websocket connection to a user.
// It contains all the necessary state to manage sending/receiving data to/from
// a websocket.
type WebConn struct {
sessionExpiresAt int64 // This should stay at the top for 64-bit alignment of 64-bit words accessed atomically
App *App
WebSocket net.Conn
T i18n.TranslateFunc
Locale string
Sequence int64
UserId string
readMut sync.Mutex
allChannelMembers map[string]string
lastAllChannelMembersTime int64
lastUserActivityAt int64
send chan model.WebSocketMessage
sessionToken atomic.Value
session atomic.Value
hasEpoll bool
endWritePump chan struct{}
pumpFinished chan struct{}
closeOnce sync.Once
}
// NewWebConn returns a new WebConn instance.
func (a *App) NewWebConn(ws net.Conn, session model.Session, t i18n.TranslateFunc, locale string) *WebConn {
if session.UserId != "" {
a.Srv().Go(func() {
a.SetStatusOnline(session.UserId, false)
a.UpdateLastActivityAtIfNeeded(session)
})
}
wc := &WebConn{
App: a,
send: make(chan model.WebSocketMessage, sendQueueSize),
WebSocket: ws,
lastUserActivityAt: model.GetMillis(),
UserId: session.UserId,
T: t,
Locale: locale,
hasEpoll: *a.Config().ServiceSettings.ConnectionSecurity == "" && runtime.GOOS != "windows",
endWritePump: make(chan struct{}),
pumpFinished: make(chan struct{}),
}
wc.SetSession(&session)
wc.SetSessionToken(session.Token)
wc.SetSessionExpiresAt(session.ExpiresAt)
if wc.hasEpoll {
wc.startPoller()
}
return wc
}
// Close closes the WebConn.
// It is made idempotent in nature by using a sync.Once
// to avoid a race condition that happens when an EventReadHup event
// and a connection close event happens at the same time.
func (wc *WebConn) Close() {
wc.closeOnce.Do(func() {
wc.WebSocket.Close()
if wc.hasEpoll {
// This triggers the pump exit.
// If the pump has already exited, this just becomes a noop.
close(wc.endWritePump)
}
// We wait for the pump to fully exit.
<-wc.pumpFinished
})
}
// GetSessionExpiresAt returns the time at which the session expires.
func (wc *WebConn) GetSessionExpiresAt() int64 {
return atomic.LoadInt64(&wc.sessionExpiresAt)
}
// SetSessionExpiresAt sets the time at which the session expires.
func (wc *WebConn) SetSessionExpiresAt(v int64) {
atomic.StoreInt64(&wc.sessionExpiresAt, v)
}
// GetSessionToken returns the session token of the connection.
func (wc *WebConn) GetSessionToken() string {
return wc.sessionToken.Load().(string)
}
// SetSessionToken sets the session token of the connection.
func (wc *WebConn) SetSessionToken(v string) {
wc.sessionToken.Store(v)
}
// GetSession returns the session of the connection.
func (wc *WebConn) GetSession() *model.Session {
return wc.session.Load().(*model.Session)
}
// SetSession sets the session of the connection.
func (wc *WebConn) SetSession(v *model.Session) {
if v != nil {
v = v.DeepCopy()
}
wc.session.Store(v)
}
// Epoll returns whether the websocket is eligible to use epoll or not.
func (wc *WebConn) Epoll() bool {
return wc.hasEpoll
}
// Pump starts the WebConn instance. After this, the websocket
// is ready to send messages.
// This is only used by *nix platforms.
func (wc *WebConn) Pump() {
// writePump is blocking in nature.
wc.writePump()
// Once it exits, we close everything.
wc.App.HubUnregister(wc)
close(wc.pumpFinished)
}
// BlockingPump is the non-epoll alternative of Pump.
// It creates two goroutines - one for reading, another
// for writing.
func (wc *WebConn) BlockingPump() {
var wg sync.WaitGroup
wg.Add(1)
go func() {
defer wg.Done()
wc.writePump()
}()
wc.readPump()
close(wc.endWritePump)
wg.Wait()
wc.App.HubUnregister(wc)
close(wc.pumpFinished)
defer ReturnSessionToPool(wc.GetSession())
}
// startPoller adds the file descriptor of the connection
// to the global epoll instance and registers a callback.
func (wc *WebConn) startPoller() {
desc := netpoll.Must(netpoll.HandleRead(wc.WebSocket))
wc.App.Srv().Poller().Start(desc, func(wsEv netpoll.Event) {
if wsEv&(netpoll.EventReadHup|netpoll.EventHup) != 0 {
wc.App.Srv().Poller().Stop(desc)
wc.Close()
return
}
// Block until we have a token.
wc.App.Srv().GetWebConnToken()
// Read from conn.
go func() {
defer wc.App.Srv().ReleaseWebConnToken()
err := wc.ReadMsg()
if err != nil {
mlog.Debug("Error while reading message from websocket", mlog.Err(err))
wc.App.Srv().Poller().Stop(desc)
// net.ErrClosed is not available until Go 1.16.
// https://github.com/golang/go/issues/4373
//
// Sometimes, the netpoller generates a data event and a HUP event
// close to each other. In that case, we don't want to double-close
// the connection.
if !strings.Contains(err.Error(), "use of closed network connection") {
wc.Close()
}
}
}()
})
}
// GetWebConnToken creates backpressure by using
// a counting semaphore to limit the number of concurrent goroutines.
func (s *Server) GetWebConnToken() {
s.webConnSemaWg.Add(1)
s.webConnSema <- struct{}{}
}
// ReleaseWebConnToken releases a token
// got from the semaphore
func (s *Server) ReleaseWebConnToken() {
<-s.webConnSema
s.webConnSemaWg.Done()
}
// ReadMsg will read a single message from the websocket connection.
func (wc *WebConn) ReadMsg() error {
r := wsutil.NewReader(wc.WebSocket, ws.StateServerSide)
r.MaxFrameSize = model.SOCKET_MAX_MESSAGE_SIZE_KB
decoder := json.NewDecoder(r)
// The reader's methods are not goroutine safe.
// We restrict only one reader goroutine per-connection.
wc.readMut.Lock()
defer wc.readMut.Unlock()
var req model.WebSocketRequest
hdr, err := r.NextFrame()
if err != nil {
return errors.Wrap(err, "error while getting the next websocket frame")
}
switch hdr.OpCode {
case ws.OpClose:
// Return if closed.
// We need to return an error for non-epoll systems to let the reader exit.
if !wc.hasEpoll {
return errNonEpollConnClose
}
return nil
case ws.OpPong:
wc.WebSocket.SetReadDeadline(time.Now().Add(pongWaitTime))
// Handle pongs
if wc.IsAuthenticated() {
wc.App.Srv().Go(func() {
wc.App.SetStatusAwayIfNeeded(wc.UserId, false)
})
}
default:
// Default case of data message.
if err := decoder.Decode(&req); err != nil {
// We discard any remaining data left in the socket.
r.Discard()
return errors.Wrap(err, "error during decoding websocket message")
}
wc.App.Srv().WebSocketRouter.ServeWebSocket(wc, &req)
}
return nil
}
func (wc *WebConn) readPump() {
defer wc.WebSocket.Close()
wc.WebSocket.SetReadDeadline(time.Now().Add(pongWaitTime))
for {
if err := wc.ReadMsg(); err != nil {
if err != errNonEpollConnClose {
wc.logSocketErr("websocket.read", err)
}
return
}
}
}
func (wc *WebConn) writePump() {
ticker := time.NewTicker(pingInterval)
authTicker := time.NewTicker(authCheckInterval)
defer func() {
ticker.Stop()
authTicker.Stop()
wc.WebSocket.Close()
}()
var buf bytes.Buffer
// 2k is seen to be a good heuristic under which 98.5% of message sizes remain.
buf.Grow(1024 * 2)
enc := json.NewEncoder(&buf)
for {
select {
case msg, ok := <-wc.send:
if !ok {
wc.WebSocket.SetWriteDeadline(time.Now().Add(writeWaitTime))
wsutil.WriteServerMessage(wc.WebSocket, ws.OpClose, []byte{})
return
}
evt, evtOk := msg.(*model.WebSocketEvent)
skipSend := false
if len(wc.send) >= sendSlowWarn {
// When the pump starts to get slow we'll drop non-critical messages
switch msg.EventType() {
case model.WEBSOCKET_EVENT_TYPING,
model.WEBSOCKET_EVENT_STATUS_CHANGE,
model.WEBSOCKET_EVENT_CHANNEL_VIEWED:
mlog.Warn(
"websocket.slow: dropping message",
mlog.String("user_id", wc.UserId),
mlog.String("type", msg.EventType()),
mlog.String("channel_id", evt.GetBroadcast().ChannelId),
)
skipSend = true
}
}
if skipSend {
continue
}
buf.Reset()
var err error
if evtOk {
cpyEvt := evt.SetSequence(wc.Sequence)
err = cpyEvt.Encode(enc)
wc.Sequence++
} else {
err = enc.Encode(msg)
}
if err != nil {
mlog.Warn("Error in encoding websocket message", mlog.Err(err))
continue
}
if len(wc.send) >= sendFullWarn {
logData := []mlog.Field{
mlog.String("user_id", wc.UserId),
mlog.String("type", msg.EventType()),
mlog.Int("size", buf.Len()),
}
if evtOk {
logData = append(logData, mlog.String("channel_id", evt.GetBroadcast().ChannelId))
}
mlog.Warn("websocket.full", logData...)
}
wc.WebSocket.SetWriteDeadline(time.Now().Add(writeWaitTime))
if err := wsutil.WriteServerMessage(wc.WebSocket, ws.OpText, buf.Bytes()); err != nil {
wc.logSocketErr("websocket.send", err)
return
}
if wc.App.Metrics() != nil {
wc.App.Metrics().IncrementWebSocketBroadcast(msg.EventType())
}
case <-ticker.C:
wc.WebSocket.SetWriteDeadline(time.Now().Add(writeWaitTime))
if err := wsutil.WriteServerMessage(wc.WebSocket, ws.OpPing, []byte{}); err != nil {
wc.logSocketErr("websocket.ticker", err)
return
}
case <-wc.endWritePump:
return
case <-authTicker.C:
if wc.GetSessionToken() == "" {
mlog.Debug("websocket.authTicker: did not authenticate", mlog.Any("ip_address", wc.WebSocket.RemoteAddr()))
return
}
authTicker.Stop()
}
}
}
// InvalidateCache resets all internal data of the WebConn.
func (wc *WebConn) InvalidateCache() {
wc.allChannelMembers = nil
wc.lastAllChannelMembersTime = 0
wc.SetSession(nil)
wc.SetSessionExpiresAt(0)
}
// IsAuthenticated returns whether the given WebConn is authenticated or not.
func (wc *WebConn) IsAuthenticated() bool {
// Check the expiry to see if we need to check for a new session
if wc.GetSessionExpiresAt() < model.GetMillis() {
if wc.GetSessionToken() == "" {
return false
}
session, err := wc.App.GetSession(wc.GetSessionToken())
if err != nil {
if err.StatusCode >= http.StatusBadRequest && err.StatusCode < http.StatusInternalServerError {
mlog.Debug("Invalid session.", mlog.Err(err))
} else {
mlog.Error("Could not get session", mlog.String("session_token", wc.GetSessionToken()), mlog.Err(err))
}
wc.SetSessionToken("")
wc.SetSession(nil)
wc.SetSessionExpiresAt(0)
return false
}
wc.SetSession(session)
wc.SetSessionExpiresAt(session.ExpiresAt)
}
return true
}
func (wc *WebConn) createHelloMessage() *model.WebSocketEvent {
msg := model.NewWebSocketEvent(model.WEBSOCKET_EVENT_HELLO, "", "", wc.UserId, nil)
msg.Add("server_version", fmt.Sprintf("%v.%v.%v.%v", model.CurrentVersion, model.BuildNumber, wc.App.ClientConfigHash(), wc.App.Srv().License() != nil))
return msg
}
func (wc *WebConn) shouldSendEventToGuest(msg *model.WebSocketEvent) bool {
var userID string
var canSee bool
switch msg.EventType() {
case model.WEBSOCKET_EVENT_USER_UPDATED:
user, ok := msg.GetData()["user"].(*model.User)
if !ok {
mlog.Debug("webhub.shouldSendEvent: user not found in message", mlog.Any("user", msg.GetData()["user"]))
return false
}
userID = user.Id
case model.WEBSOCKET_EVENT_NEW_USER:
userID = msg.GetData()["user_id"].(string)
default:
return true
}
canSee, err := wc.App.UserCanSeeOtherUser(wc.UserId, userID)
if err != nil {
mlog.Error("webhub.shouldSendEvent.", mlog.Err(err))
return false
}
return canSee
}
// shouldSendEvent returns whether the message should be sent or not.
func (wc *WebConn) shouldSendEvent(msg *model.WebSocketEvent) bool {
// IMPORTANT: Do not send event if WebConn does not have a session
if !wc.IsAuthenticated() {
return false
}
// If the event contains sanitized data, only send to users that don't have permission to
// see sensitive data. Prevents admin clients from receiving events with bad data
var hasReadPrivateDataPermission *bool
if msg.GetBroadcast().ContainsSanitizedData {
hasReadPrivateDataPermission = model.NewBool(wc.App.RolesGrantPermission(wc.GetSession().GetUserRoles(), model.PERMISSION_MANAGE_SYSTEM.Id))
if *hasReadPrivateDataPermission {
return false
}
}
// If the event contains sensitive data, only send to users with permission to see it
if msg.GetBroadcast().ContainsSensitiveData {
if hasReadPrivateDataPermission == nil {
hasReadPrivateDataPermission = model.NewBool(wc.App.RolesGrantPermission(wc.GetSession().GetUserRoles(), model.PERMISSION_MANAGE_SYSTEM.Id))
}
if !*hasReadPrivateDataPermission {
return false
}
}
// If the event is destined to a specific user
if msg.GetBroadcast().UserId != "" {
return wc.UserId == msg.GetBroadcast().UserId
}
// if the user is omitted don't send the message
if len(msg.GetBroadcast().OmitUsers) > 0 {
if _, ok := msg.GetBroadcast().OmitUsers[wc.UserId]; ok {
return false
}
}
// Only report events to users who are in the channel for the event
if msg.GetBroadcast().ChannelId != "" {
if model.GetMillis()-wc.lastAllChannelMembersTime > webConnMemberCacheTime {
wc.allChannelMembers = nil
wc.lastAllChannelMembersTime = 0
}
if wc.allChannelMembers == nil {
result, err := wc.App.Srv().Store.Channel().GetAllChannelMembersForUser(wc.UserId, true, false)
if err != nil {
mlog.Error("webhub.shouldSendEvent.", mlog.Err(err))
return false
}
wc.allChannelMembers = result
wc.lastAllChannelMembersTime = model.GetMillis()
}
if _, ok := wc.allChannelMembers[msg.GetBroadcast().ChannelId]; ok {
return true
}
return false
}
// Only report events to users who are in the team for the event
if msg.GetBroadcast().TeamId != "" {
return wc.isMemberOfTeam(msg.GetBroadcast().TeamId)
}
if wc.GetSession().Props[model.SESSION_PROP_IS_GUEST] == "true" {
return wc.shouldSendEventToGuest(msg)
}
return true
}
// IsMemberOfTeam returns whether the user of the WebConn
// is a member of the given teamID or not.
func (wc *WebConn) isMemberOfTeam(teamID string) bool {
currentSession := wc.GetSession()
if currentSession == nil || currentSession.Token == "" {
session, err := wc.App.GetSession(wc.GetSessionToken())
if err != nil {
if err.StatusCode >= http.StatusBadRequest && err.StatusCode < http.StatusInternalServerError {
mlog.Debug("Invalid session.", mlog.Err(err))
} else {
mlog.Error("Could not get session", mlog.String("session_token", wc.GetSessionToken()), mlog.Err(err))
}
return false
}
wc.SetSession(session)
currentSession = session
}
return currentSession.GetTeamByTeamId(teamID) != nil
}
func (wc *WebConn) logSocketErr(source string, err error) {
mlog.Debug(source+": error during writing to websocket", mlog.String("user_id", wc.UserId), mlog.Err(err))
}