322 lines
8.3 KiB
Go
322 lines
8.3 KiB
Go
/******************************************************************************
|
|
*
|
|
* Description :
|
|
*
|
|
* Handler of websocket connections. See also hdl_longpoll.go for long polling
|
|
* and hdl_grpc.go for gRPC.
|
|
*
|
|
*****************************************************************************/
|
|
|
|
package main
|
|
|
|
import (
|
|
"encoding/json"
|
|
"errors"
|
|
"io"
|
|
"net"
|
|
"net/http"
|
|
"sync/atomic"
|
|
"syscall"
|
|
"time"
|
|
|
|
"github.com/gorilla/websocket"
|
|
"github.com/tinode/chat/server/logs"
|
|
immetrics "github.com/tinode/chat/server/metrics"
|
|
"github.com/tinode/chat/server/store/types"
|
|
)
|
|
|
|
const (
|
|
// Time allowed to write a message to the peer.
|
|
writeWait = 10 * time.Second
|
|
// Heartbeat defaults tolerate transient mobile and public-network stalls while
|
|
// still detecting dead connections in a bounded amount of time.
|
|
defaultWSPongWait = 90 * time.Second
|
|
defaultWSPingPeriod = 15 * time.Second
|
|
)
|
|
|
|
var (
|
|
pongWait = defaultWSPongWait
|
|
pingPeriod = defaultWSPingPeriod
|
|
)
|
|
|
|
func configureWSHeartbeat(pingSeconds, pongSeconds int) error {
|
|
ping := defaultWSPingPeriod
|
|
pong := defaultWSPongWait
|
|
if pingSeconds > 0 {
|
|
ping = time.Duration(pingSeconds) * time.Second
|
|
}
|
|
if pongSeconds > 0 {
|
|
pong = time.Duration(pongSeconds) * time.Second
|
|
}
|
|
if ping >= pong {
|
|
return errors.New("websocket ping period must be shorter than pong wait")
|
|
}
|
|
pingPeriod = ping
|
|
pongWait = pong
|
|
immetrics.SetWSHeartbeatConfig(ping, pong)
|
|
return nil
|
|
}
|
|
|
|
func (sess *Session) closeWS() {
|
|
if sess.proto == WEBSOCK {
|
|
sess.ws.Close()
|
|
}
|
|
}
|
|
|
|
func (sess *Session) readLoop() {
|
|
defer func() {
|
|
sess.closeWS()
|
|
sess.cleanUp(false)
|
|
}()
|
|
|
|
sess.ws.SetReadLimit(globals.maxMessageSize)
|
|
refreshReadDeadline := func(event, presenceSource string) {
|
|
atomic.StoreInt64(&sess.lastAction, types.TimeNow().UnixNano())
|
|
immetrics.IncWSHeartbeat(event)
|
|
if presenceSource != "" {
|
|
sess.refreshPresence(presenceSource)
|
|
}
|
|
_ = sess.ws.SetReadDeadline(time.Now().Add(pongWait))
|
|
}
|
|
_ = sess.ws.SetReadDeadline(time.Now().Add(pongWait))
|
|
sess.ws.SetPongHandler(func(string) error {
|
|
refreshReadDeadline("pong_received", "ws.pong")
|
|
return nil
|
|
})
|
|
sess.ws.SetPingHandler(func(appData string) error {
|
|
refreshReadDeadline("ping_received", "")
|
|
return sess.ws.WriteControl(websocket.PongMessage, []byte(appData), time.Now().Add(writeWait))
|
|
})
|
|
|
|
for {
|
|
// Read a ClientComMessage
|
|
_, raw, err := sess.ws.ReadMessage()
|
|
if err != nil {
|
|
reason := classifyWSError(err)
|
|
immetrics.IncWSDisconnect(reason)
|
|
if websocket.IsUnexpectedCloseError(err, websocket.CloseGoingAway, websocket.CloseNormalClosure) {
|
|
logs.Err.Printf("ws: readLoop sid=%s uid=%s reason=%s err=%v", sess.sid, sess.uid.UserId(), reason, err)
|
|
}
|
|
return
|
|
}
|
|
// Any successfully read client frame proves that the connection is alive.
|
|
// This prevents a busy client from timing out merely because its Pong was
|
|
// delayed behind application traffic.
|
|
atomic.StoreInt64(&sess.lastAction, types.TimeNow().UnixNano())
|
|
_ = sess.ws.SetReadDeadline(time.Now().Add(pongWait))
|
|
statsInc("IncomingMessagesWebsockTotal", 1)
|
|
dispatchStarted := time.Now()
|
|
sess.dispatchRaw(raw)
|
|
immetrics.ObserveWSReadDispatch(dispatchStarted)
|
|
}
|
|
}
|
|
|
|
func (sess *Session) sendMessage(msg any) bool {
|
|
if depth := len(sess.send); depth > sendQueueLimit {
|
|
immetrics.ObserveWSWrite("text", "rejected", "queue_limit", time.Now(), 0)
|
|
logs.Err.Printf("ws: outbound queue limit exceeded sid=%s uid=%s depth=%d capacity=%d",
|
|
sess.sid, sess.uid.UserId(), depth, cap(sess.send))
|
|
return false
|
|
}
|
|
|
|
statsInc("OutgoingMessagesWebsockTotal", 1)
|
|
if err := sess.writeWSFrame(websocket.TextMessage, msg); err != nil {
|
|
return false
|
|
}
|
|
return true
|
|
}
|
|
|
|
func (sess *Session) writeLoop() {
|
|
ticker := time.NewTicker(pingPeriod)
|
|
|
|
defer func() {
|
|
ticker.Stop()
|
|
// Break readLoop.
|
|
sess.closeWS()
|
|
}()
|
|
|
|
for {
|
|
select {
|
|
case msg, ok := <-sess.send:
|
|
if !ok {
|
|
// Channel closed.
|
|
return
|
|
}
|
|
sess.observeOutboundDequeue(false)
|
|
switch v := msg.(type) {
|
|
case []*ServerComMessage: // batch of unserialized messages
|
|
for _, msg := range v {
|
|
w := sess.serializeAndUpdateStats(msg)
|
|
if !sess.sendMessage(w) {
|
|
return
|
|
}
|
|
if isAcceptedCtrl(msg) {
|
|
immetrics.ObserveCtrlACKDelivery("write", msg.Timestamp)
|
|
}
|
|
}
|
|
case *ServerComMessage: // single unserialized message
|
|
w := sess.serializeAndUpdateStats(v)
|
|
if !sess.sendMessage(w) {
|
|
return
|
|
}
|
|
if isAcceptedCtrl(v) {
|
|
immetrics.ObserveCtrlACKDelivery("write", v.Timestamp)
|
|
}
|
|
case sharedServerMessage:
|
|
statsAddHistSample("OutgoingMessageSize", float64(len(v)))
|
|
if !sess.sendMessage([]byte(v)) {
|
|
return
|
|
}
|
|
default: // serialized message
|
|
if !sess.sendMessage(v) {
|
|
return
|
|
}
|
|
}
|
|
|
|
case <-sess.bkgTimer.C:
|
|
if sess.background {
|
|
sess.background = false
|
|
sess.onBackgroundTimer()
|
|
}
|
|
|
|
case msg := <-sess.stop:
|
|
// Shutdown requested, don't care if the message is delivered
|
|
if msg != nil {
|
|
sess.writeWSFrame(websocket.TextMessage, msg)
|
|
}
|
|
return
|
|
|
|
case topic := <-sess.detach:
|
|
sess.delSub(topic)
|
|
|
|
case <-ticker.C:
|
|
if err := sess.writeWSFrame(websocket.PingMessage, nil); err != nil {
|
|
return
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
func (sess *Session) writeWSFrame(mt int, msg any) error {
|
|
frame := "text"
|
|
if mt == websocket.PingMessage {
|
|
frame = "ping"
|
|
}
|
|
payloadBytes := 0
|
|
if bits, ok := msg.([]byte); ok {
|
|
payloadBytes = len(bits)
|
|
}
|
|
started := time.Now()
|
|
err := wsWrite(sess.ws, mt, msg)
|
|
if err == nil {
|
|
immetrics.ObserveWSWrite(frame, "ok", "none", started, payloadBytes)
|
|
return nil
|
|
}
|
|
reason := classifyWSError(err)
|
|
immetrics.ObserveWSWrite(frame, "error", reason, started, payloadBytes)
|
|
logs.Err.Printf("ws: writeLoop sid=%s uid=%s frame=%s queue=%d capacity=%d reason=%s err=%v",
|
|
sess.sid, sess.uid.UserId(), frame, len(sess.send), cap(sess.send), reason, err)
|
|
return err
|
|
}
|
|
|
|
func classifyWSError(err error) string {
|
|
if err == nil {
|
|
return "none"
|
|
}
|
|
var closeErr *websocket.CloseError
|
|
if errors.As(err, &closeErr) {
|
|
switch closeErr.Code {
|
|
case websocket.CloseNormalClosure:
|
|
return "close_normal"
|
|
case websocket.CloseGoingAway:
|
|
return "close_going_away"
|
|
case websocket.CloseAbnormalClosure:
|
|
return "close_abnormal"
|
|
default:
|
|
return "close_other"
|
|
}
|
|
}
|
|
var netErr net.Error
|
|
if errors.As(err, &netErr) && netErr.Timeout() {
|
|
return "timeout"
|
|
}
|
|
switch {
|
|
case errors.Is(err, syscall.ECONNRESET):
|
|
return "connection_reset"
|
|
case errors.Is(err, syscall.EPIPE):
|
|
return "broken_pipe"
|
|
case errors.Is(err, net.ErrClosed):
|
|
return "closed"
|
|
case errors.Is(err, io.EOF):
|
|
return "eof"
|
|
default:
|
|
return "other"
|
|
}
|
|
}
|
|
|
|
// Writes a message with the given message type (mt) and payload.
|
|
func wsWrite(ws *websocket.Conn, mt int, msg any) error {
|
|
var bits []byte
|
|
if msg != nil {
|
|
bits = msg.([]byte)
|
|
} else {
|
|
bits = []byte{}
|
|
}
|
|
ws.SetWriteDeadline(time.Now().Add(writeWait))
|
|
return ws.WriteMessage(mt, bits)
|
|
}
|
|
|
|
// Handles websocket requests from peers.
|
|
var upgrader = websocket.Upgrader{
|
|
ReadBufferSize: 1024,
|
|
WriteBufferSize: 1024,
|
|
EnableCompression: globals.wsCompression,
|
|
// Allow connections from any Origin
|
|
CheckOrigin: func(r *http.Request) bool { return true },
|
|
}
|
|
|
|
func serveWebSocket(wrt http.ResponseWriter, req *http.Request) {
|
|
now := types.TimeNow()
|
|
|
|
if isValid, _ := checkAPIKey(getAPIKey(req)); !isValid {
|
|
wrt.WriteHeader(http.StatusForbidden)
|
|
json.NewEncoder(wrt).Encode(ErrAPIKeyRequired(now))
|
|
logs.Err.Println("ws: Missing, invalid or expired API key")
|
|
return
|
|
}
|
|
|
|
if req.Method != http.MethodGet {
|
|
wrt.WriteHeader(http.StatusMethodNotAllowed)
|
|
json.NewEncoder(wrt).Encode(ErrOperationNotAllowed("", "", now))
|
|
logs.Err.Println("ws: Invalid HTTP method", req.Method)
|
|
return
|
|
}
|
|
|
|
ws, err := upgrader.Upgrade(wrt, req, nil)
|
|
if _, ok := err.(websocket.HandshakeError); ok {
|
|
logs.Err.Println("ws: Not a websocket handshake")
|
|
return
|
|
} else if err != nil {
|
|
logs.Err.Println("ws: failed to Upgrade ", err)
|
|
return
|
|
}
|
|
|
|
sess, count := globals.sessionStore.NewSession(ws, "")
|
|
if globals.useXForwardedFor {
|
|
sess.remoteAddr = req.Header.Get("X-Forwarded-For")
|
|
if !isRoutableIP(sess.remoteAddr) {
|
|
sess.remoteAddr = ""
|
|
}
|
|
}
|
|
if sess.remoteAddr == "" {
|
|
sess.remoteAddr = req.RemoteAddr
|
|
}
|
|
|
|
logs.Info.Println("ws: session started", sess.sid, sess.remoteAddr, count)
|
|
|
|
// Do work in goroutines to return from serveWebSocket() to release file pointers.
|
|
// Otherwise "too many open files" will happen.
|
|
go sess.writeLoop()
|
|
go sess.readLoop()
|
|
}
|