create a websocket game server
This commit is contained in:
@@ -0,0 +1,199 @@
|
||||
// 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
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,192 @@
|
||||
package ws_test
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"log/slog"
|
||||
"net/http/httptest"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/coder/websocket"
|
||||
"github.com/golang-jwt/jwt/v5"
|
||||
|
||||
"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/ws"
|
||||
)
|
||||
|
||||
var secret = []byte("integration-secret")
|
||||
|
||||
const publicAddr = "ws://game.example.com/ws"
|
||||
|
||||
func gameToken(t *testing.T, matchID string, p1, p2 int64, gameServer string, exp time.Time) string {
|
||||
t.Helper()
|
||||
token, err := jwt.NewWithClaims(jwt.SigningMethodHS256, &auth.GameClaims{
|
||||
MatchID: matchID,
|
||||
Player1ID: p1,
|
||||
Player2ID: p2,
|
||||
GameServer: gameServer,
|
||||
RegisteredClaims: jwt.RegisteredClaims{
|
||||
ExpiresAt: jwt.NewNumericDate(exp),
|
||||
},
|
||||
}).SignedString(secret)
|
||||
if err != nil {
|
||||
t.Fatalf("sign game token: %v", err)
|
||||
}
|
||||
return token
|
||||
}
|
||||
|
||||
func userToken(t *testing.T, userID int64) string {
|
||||
t.Helper()
|
||||
token, err := jwt.NewWithClaims(jwt.SigningMethodHS256, &auth.UserClaims{
|
||||
UserID: userID,
|
||||
RegisteredClaims: jwt.RegisteredClaims{
|
||||
ExpiresAt: jwt.NewNumericDate(time.Now().Add(time.Hour)),
|
||||
},
|
||||
}).SignedString(secret)
|
||||
if err != nil {
|
||||
t.Fatalf("sign user token: %v", err)
|
||||
}
|
||||
return token
|
||||
}
|
||||
|
||||
func startServer(t *testing.T, joinTimeout time.Duration) (url string) {
|
||||
t.Helper()
|
||||
manager := game.NewManager(joinTimeout)
|
||||
log := slog.New(slog.NewTextHandler(testWriter{t}, nil))
|
||||
handler := ws.NewHandler(manager, secret, publicAddr, []string{"*"}, log)
|
||||
srv := httptest.NewServer(handler)
|
||||
t.Cleanup(srv.Close)
|
||||
return "ws" + strings.TrimPrefix(srv.URL, "http")
|
||||
}
|
||||
|
||||
type testWriter struct{ t *testing.T }
|
||||
|
||||
func (w testWriter) Write(p []byte) (int, error) {
|
||||
w.t.Log(strings.TrimSpace(string(p)))
|
||||
return len(p), nil
|
||||
}
|
||||
|
||||
func dial(t *testing.T, url string) *websocket.Conn {
|
||||
t.Helper()
|
||||
c, _, err := websocket.Dial(context.Background(), url, nil)
|
||||
if err != nil {
|
||||
t.Fatalf("dial: %v", err)
|
||||
}
|
||||
t.Cleanup(func() { c.Close(websocket.StatusNormalClosure, "") })
|
||||
return c
|
||||
}
|
||||
|
||||
func join(t *testing.T, c *websocket.Conn, gt, ut string) {
|
||||
t.Helper()
|
||||
msg, _ := json.Marshal([]any{1, gt, ut})
|
||||
if err := c.Write(context.Background(), websocket.MessageText, msg); err != nil {
|
||||
t.Fatalf("write JOIN: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func readMsg(t *testing.T, c *websocket.Conn) []any {
|
||||
t.Helper()
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
|
||||
defer cancel()
|
||||
_, data, err := c.Read(ctx)
|
||||
if err != nil {
|
||||
t.Fatalf("read: %v", err)
|
||||
}
|
||||
var v []any
|
||||
if err := json.Unmarshal(data, &v); err != nil {
|
||||
t.Fatalf("unmarshal %s: %v", data, err)
|
||||
}
|
||||
return v
|
||||
}
|
||||
|
||||
func TestTwoPlayersStartMatch(t *testing.T) {
|
||||
url := startServer(t, time.Minute)
|
||||
exp := time.Now().Add(time.Hour)
|
||||
gt := gameToken(t, "m-1", 1, 2, publicAddr, exp)
|
||||
|
||||
c1, c2 := dial(t, url), dial(t, url)
|
||||
join(t, c1, gt, userToken(t, 1))
|
||||
join(t, c2, gt, userToken(t, 2))
|
||||
|
||||
for i, c := range []*websocket.Conn{c1, c2} {
|
||||
v := readMsg(t, c)
|
||||
if v[0] != float64(3) || v[1] != float64(1) || v[2] != float64(1) {
|
||||
t.Fatalf("player %d: unexpected message %v", i, v)
|
||||
}
|
||||
// ACK the state.
|
||||
msg, _ := json.Marshal([]any{4, 1})
|
||||
if err := c.Write(context.Background(), websocket.MessageText, msg); err != nil {
|
||||
t.Fatalf("write ACK: %v", err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestWrongServerAddressRejected(t *testing.T) {
|
||||
url := startServer(t, time.Minute)
|
||||
gt := gameToken(t, "m-2", 1, 2, "ws://other.example.com/ws", time.Now().Add(time.Hour))
|
||||
|
||||
c := dial(t, url)
|
||||
join(t, c, gt, userToken(t, 1))
|
||||
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
|
||||
defer cancel()
|
||||
_, _, err := c.Read(ctx)
|
||||
if err == nil {
|
||||
t.Fatal("expected connection to be closed")
|
||||
}
|
||||
if code := websocket.CloseStatus(err); code != 4003 {
|
||||
t.Fatalf("close code = %d, want 4003", code)
|
||||
}
|
||||
}
|
||||
|
||||
func TestJoinTimeoutEndsMatchAndReplay(t *testing.T) {
|
||||
url := startServer(t, 200*time.Millisecond)
|
||||
exp := time.Now().Add(time.Hour)
|
||||
gt := gameToken(t, "m-3", 1, 2, publicAddr, exp)
|
||||
|
||||
c1 := dial(t, url)
|
||||
join(t, c1, gt, userToken(t, 1))
|
||||
|
||||
v := readMsg(t, c1) // END
|
||||
if v[0] != float64(2) || v[1] != float64(1) || v[2] != float64(-1) || v[3] != "join timeout exceeded" {
|
||||
t.Fatalf("unexpected END: %v", v)
|
||||
}
|
||||
|
||||
// Late join with the same token replays END and closes.
|
||||
c2 := dial(t, url)
|
||||
join(t, c2, gt, userToken(t, 2))
|
||||
v = readMsg(t, c2)
|
||||
if v[0] != float64(2) || v[2] != float64(-1) {
|
||||
t.Fatalf("unexpected replayed END: %v", v)
|
||||
}
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
|
||||
defer cancel()
|
||||
if _, _, err := c2.Read(ctx); err == nil {
|
||||
t.Fatal("expected connection to be closed after END replay")
|
||||
}
|
||||
}
|
||||
|
||||
func TestReconnectDuringGame(t *testing.T) {
|
||||
url := startServer(t, time.Minute)
|
||||
exp := time.Now().Add(time.Hour)
|
||||
gt := gameToken(t, "m-4", 1, 2, publicAddr, exp)
|
||||
|
||||
c1, c2 := dial(t, url), dial(t, url)
|
||||
join(t, c1, gt, userToken(t, 1))
|
||||
join(t, c2, gt, userToken(t, 2))
|
||||
readMsg(t, c1) // STATE, not acknowledged
|
||||
|
||||
// Simulate a dropped connection.
|
||||
c1.Close(websocket.StatusAbnormalClosure, "network gone")
|
||||
|
||||
// Reconnect with the same tokens: unacknowledged STATE is replayed.
|
||||
c1b := dial(t, url)
|
||||
join(t, c1b, gt, userToken(t, 1))
|
||||
v := readMsg(t, c1b)
|
||||
if v[0] != float64(3) || v[1] != float64(1) || v[2] != float64(1) {
|
||||
t.Fatalf("unexpected replayed message: %v", v)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user