Files
ruby-sea-battle/game-server/internal/ws/handler.go
T

200 lines
5.6 KiB
Go

// Package ws implements the websocket endpoint of the game server:
// connection upgrade, JOIN handling and the per-connection read loop.
package ws
import (
"context"
"log/slog"
"net/http"
"sync"
"time"
"github.com/coder/websocket"
"github.com/ruby-sea-battle/game-server/internal/auth"
"github.com/ruby-sea-battle/game-server/internal/game"
"github.com/ruby-sea-battle/game-server/internal/protocol"
)
const (
readLimit = 16 * 1024 // game messages are tiny
joinReadTimeout = 10 * time.Second
writeTimeout = 5 * time.Second
pingInterval = 25 * time.Second
pingTimeout = 10 * time.Second
)
// Conn adapts a websocket connection to the game.Sender interface.
type Conn struct {
ws *websocket.Conn
wmu sync.Mutex // serializes writes
}
func (c *Conn) Send(_ context.Context, data []byte) error {
ctx, cancel := context.WithTimeout(context.Background(), writeTimeout)
defer cancel()
c.wmu.Lock()
defer c.wmu.Unlock()
return c.ws.Write(ctx, websocket.MessageText, data)
}
func (c *Conn) Close(code int, reason string) error {
return c.ws.Close(websocket.StatusCode(code), reason)
}
// Handler is the http.Handler of the websocket endpoint.
type Handler struct {
Manager *game.Manager
JWTSecret []byte
PublicAddr string // must match the "game_server" claim strictly
acceptOpts *websocket.AcceptOptions
log *slog.Logger
}
func NewHandler(manager *game.Manager, jwtSecret []byte, publicAddr string, allowedOrigins []string, log *slog.Logger) *Handler {
opts := &websocket.AcceptOptions{}
if len(allowedOrigins) == 1 && allowedOrigins[0] == "*" {
opts.InsecureSkipVerify = true
} else {
opts.OriginPatterns = allowedOrigins
}
return &Handler{
Manager: manager,
JWTSecret: jwtSecret,
PublicAddr: publicAddr,
acceptOpts: opts,
log: log,
}
}
func (h *Handler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
wsc, err := websocket.Accept(w, r, h.acceptOpts)
if err != nil {
h.log.Debug("websocket accept failed", "err", err)
return
}
wsc.SetReadLimit(readLimit)
ctx, cancel := context.WithCancel(r.Context())
defer cancel()
defer wsc.Close(websocket.StatusInternalError, "")
// The first message on a fresh connection must be JOIN.
readCtx, cancelRead := context.WithTimeout(ctx, joinReadTimeout)
_, data, err := wsc.Read(readCtx)
cancelRead()
if err != nil {
wsc.Close(protocol.CloseCodeExpectedJoin, "join timeout")
return
}
opcode, args, err := protocol.ParseMessage(data)
if err != nil {
wsc.Close(protocol.CloseCodeBadMessage, "malformed message")
return
}
if opcode != protocol.OpJoin {
wsc.Close(protocol.CloseCodeExpectedJoin, "expected JOIN")
return
}
gameToken, userToken, err := protocol.ParseJoin(args)
if err != nil {
wsc.Close(protocol.CloseCodeBadMessage, "malformed JOIN")
return
}
// Validate the game token and make sure the token was issued for this
// particular server instance (strict comparison with the public address).
gc, err := auth.ParseGameToken(h.JWTSecret, gameToken)
if err != nil {
h.log.Debug("bad game token", "err", err)
wsc.Close(protocol.CloseCodeBadGameToken, "invalid game token")
return
}
if gc.GameServer != h.PublicAddr {
h.log.Debug("game token issued for another server", "want", h.PublicAddr, "got", gc.GameServer)
wsc.Close(protocol.CloseCodeBadGameToken, "game server mismatch")
return
}
if gc.Player1ID == gc.Player2ID {
wsc.Close(protocol.CloseCodeBadGameToken, "invalid match")
return
}
// Validate the user token and make sure the user is a participant.
uc, err := auth.ParseUserToken(h.JWTSecret, userToken)
if err != nil {
h.log.Debug("bad user token", "err", err)
wsc.Close(protocol.CloseCodeBadUserToken, "invalid user token")
return
}
if uc.UserID != gc.Player1ID && uc.UserID != gc.Player2ID {
wsc.Close(protocol.CloseCodeBadUserToken, "not a participant of this match")
return
}
conn := &Conn{ws: wsc}
match, slotIdx, err := h.Manager.Join(ctx, conn, gc, uc.UserID)
if err != nil {
// ErrMatchFinished: the END message has already been replayed and
// the connection closed by the manager.
if err != game.ErrMatchFinished {
h.log.Debug("join failed", "err", err)
}
return
}
h.log.Info("player joined", "match", match.ID, "user", uc.UserID, "slot", slotIdx)
go h.pingLoop(ctx, wsc)
// Read loop: process messages until the connection dies.
for {
_, data, err := wsc.Read(ctx)
if err != nil {
break
}
opcode, args, err := protocol.ParseMessage(data)
if err != nil {
h.log.Debug("malformed message", "match", match.ID, "user", uc.UserID, "err", err)
continue
}
switch opcode {
case protocol.OpAck:
seq, err := protocol.ParseAck(args)
if err != nil {
h.log.Debug("malformed ACK", "match", match.ID, "user", uc.UserID, "err", err)
continue
}
h.Manager.Ack(match, slotIdx, seq)
default:
// Unknown opcodes are ignored for forward compatibility.
h.log.Debug("unknown opcode", "match", match.ID, "user", uc.UserID, "opcode", opcode)
}
}
h.Manager.Leave(conn, match, slotIdx)
h.log.Info("player disconnected", "match", match.ID, "user", uc.UserID, "slot", slotIdx)
}
// pingLoop keeps the connection alive and detects dead peers. When the ping
// fails the connection is closed, which terminates the read loop.
func (h *Handler) pingLoop(ctx context.Context, wsc *websocket.Conn) {
ticker := time.NewTicker(pingInterval)
defer ticker.Stop()
for {
select {
case <-ctx.Done():
return
case <-ticker.C:
pingCtx, cancel := context.WithTimeout(ctx, pingTimeout)
err := wsc.Ping(pingCtx)
cancel()
if err != nil {
wsc.Close(websocket.StatusGoingAway, "ping failed")
return
}
}
}
}