mirror of
https://github.com/tiennm99/noitu.git
synced 2026-10-05 02:13:50 +00:00
A held seat now remembers which connection it waits for, so a token from a kicked player is refused instead of landing in whoever took the seat. A room answers every input left in its inbox when it exits, disconnect notices no longer share the lossy inbox, quick-match no longer leaves autoStart armed after a pairing that never started, only lobby changes restart the idle window, and draining refuses new queue entries. Sockets that never send Hello close after ten seconds and the room budget is charged per address, so one client cannot hold the global caps. IPv6 limiter keys use the /64. Corpus log lines get a process-wide rate limit with a suppressed counter. Responses carry nosniff, frame-ancestors and referrer headers. Unicode spaces in a word become spaces rather than vanishing, and blank-rendering letters are dropped from names and chat. A resume into a lobby whose game ended during the absence is replayed that seat's GameOver. Unknown payloads get unknown_message.
491 lines
16 KiB
Go
491 lines
16 KiB
Go
package wsapi
|
|
|
|
import (
|
|
"context"
|
|
"crypto/rand"
|
|
"encoding/base64"
|
|
"errors"
|
|
"log/slog"
|
|
"sync"
|
|
"time"
|
|
|
|
"github.com/coder/websocket"
|
|
noituv1 "github.com/tiennm99dev/noitu/server/gen/noitu/v1"
|
|
"github.com/tiennm99dev/noitu/server/internal/game"
|
|
)
|
|
|
|
// The socket half of a connection: reading and writing frames, the
|
|
// keepalive that detects a dead peer, and the identity — nickname, room,
|
|
// seat — the protocol half below reads and writes through the same mutex.
|
|
|
|
const (
|
|
// outboxCap buffers writes. A client that cannot keep up with this many
|
|
// pending frames is not going to catch up, so the session is closed rather
|
|
// than grown without bound.
|
|
outboxCap = 32
|
|
|
|
// pingEvery / pingTimeout are the liveness check. Reads carry no deadline
|
|
// of their own: a player idling in the lobby between games is healthy and
|
|
// silent, and a read timeout cannot tell that apart from a dead socket.
|
|
// A ping can.
|
|
pingEvery = 20 * time.Second
|
|
pingTimeout = 10 * time.Second
|
|
pingMisses = 2
|
|
|
|
// writeTimeout bounds a single frame write, and drainTimeout the final
|
|
// flush of whatever is still queued when the session ends.
|
|
writeTimeout = 10 * time.Second
|
|
drainTimeout = 2 * time.Second
|
|
|
|
// submitsPerSecond bounds word submissions. Each one is a dictionary
|
|
// lookup and a possible engine mutation; a human types far below this.
|
|
submitsPerSecond = 5
|
|
submitBurst = 10
|
|
|
|
// Chat gets its own budget so talking never costs a move. It can afford to
|
|
// be humane about a burst — two people typing at each other is normal —
|
|
// because the danger a limiter would otherwise be holding down is handled
|
|
// where it actually lives: chat is delivered with trySend, so a recipient
|
|
// who cannot keep up drops a line rather than losing their session.
|
|
chatsPerSecond = 2.0
|
|
chatBurst = 5
|
|
|
|
// joinsPerSecond and joinBurst bound how many rooms one address may join
|
|
// or attempt to join. The limiter exists to slow a brute-force walk of the
|
|
// room-code space (31 characters over 6 places, ~8.9e8 codes) to
|
|
// centuries even at this rate — it is not meant to ration ordinary play.
|
|
// A single NAT/CGNAT egress (a café, a school, a mobile carrier) can be
|
|
// many real players sharing one address, so the budget has to be generous
|
|
// enough for a whole one of those, not just one person.
|
|
joinsPerSecond = 5
|
|
joinBurst = 20
|
|
|
|
// maxWordReportsPerSession bounds how many distinct words one session may
|
|
// file with ReportWord. A duplicate report of a word already filed does
|
|
// not count against it — it costs nothing new to acknowledge again — but
|
|
// an unbounded stream of distinct ones would turn the corpus feedback loop
|
|
// into a log-filling vector.
|
|
maxWordReportsPerSession = 20
|
|
|
|
// Room creation is far more expensive than a join: each one is a
|
|
// goroutine, an engine and a registry entry held until the game ends.
|
|
roomsPerSecond = 0.2
|
|
roomBurst = 5
|
|
limiterIdleFor = 5 * time.Minute
|
|
|
|
// addressRoomsPerSecond and addressRoomBurst are the same budget kept per
|
|
// client address, because the per-connection one above resets with every
|
|
// reconnect and so bounds nothing about who is opening rooms.
|
|
//
|
|
// Sized like the join budget, for the same reason: without a trusted proxy
|
|
// that unmasks the real client — production today runs without
|
|
// NOITU_TRUSTED_PROXIES — every player shares one address, and a NAT or
|
|
// CGNAT egress is many real people at once. Thirty rooms up front and one
|
|
// every two seconds after is far above what any crowd creates by hand, yet
|
|
// it keeps one address from holding more than about a third of the default
|
|
// room ceiling for a whole idle window (0.5/s over ten minutes is 300).
|
|
addressRoomsPerSecond = 0.5
|
|
addressRoomBurst = 30
|
|
|
|
// helloTimeout is how long a socket may stay open without saying Hello.
|
|
// A connection that has not greeted is not in the hub, is in no room and
|
|
// has no resume token, yet it counts against the global connection cap;
|
|
// an honest client sends Hello the instant the socket opens, so a silent
|
|
// one is only ever holding a slot. The keepalive cannot catch it, since
|
|
// every WebSocket stack answers pings on its own.
|
|
helloTimeout = 10 * time.Second
|
|
|
|
// framesPerSecond bounds every frame a connection sends, before it is
|
|
// routed. The per-action limiters above only meter the actions they know
|
|
// about; a Ping, or a ClientMessage with no payload set, matched none of
|
|
// them and cost the reader a decode at line rate. A client past this is
|
|
// not a player typing, so the connection is closed rather than throttled.
|
|
framesPerSecond = 20
|
|
frameBurst = 40
|
|
)
|
|
|
|
var (
|
|
errHandshake = errors.New("wsapi: first message must be Hello")
|
|
errFlood = errors.New("wsapi: frame rate exceeded")
|
|
)
|
|
|
|
// session is one WebSocket connection.
|
|
//
|
|
// Exactly one reader goroutine and one writer goroutine touch the socket. The
|
|
// mutex below guards only the small mutable identity — nickname, room, seat —
|
|
// that the reader sets and the room goroutine reads.
|
|
type session struct {
|
|
id string
|
|
resumeToken string
|
|
conn *websocket.Conn
|
|
hub *hub
|
|
|
|
// remoteIP keys the join limiter. A session id is minted per connection,
|
|
// so keying on it would let anyone brute-force room codes by reconnecting
|
|
// between attempts — the limiter has to outlive the socket.
|
|
remoteIP string
|
|
|
|
// ctx is the teardown signal for everything except the read.
|
|
ctx context.Context
|
|
cancel context.CancelFunc
|
|
|
|
// readCtx is cancelled only after the writer has finished flushing.
|
|
//
|
|
// coder/websocket arms a context.AfterFunc on the context passed to Read
|
|
// that hard-closes the underlying socket when it fires, so cancelling the
|
|
// read context is the same as destroying the connection. Every frame
|
|
// queued at teardown — the shutdown notice above all — would be written
|
|
// into a socket that is already gone.
|
|
//
|
|
// It is rooted at Background rather than at the server context on purpose.
|
|
// A child of the server context would be cancelled by Shutdown at the same
|
|
// instant as ctx, which is precisely the ordering this exists to prevent.
|
|
// Nothing leaks: run always signals ctx, and the goroutine watching it
|
|
// always cancels this one.
|
|
readCtx context.Context
|
|
cancelRead context.CancelFunc
|
|
|
|
out chan []byte
|
|
|
|
// flushed closes when the writer has drained, so teardown can wait for the
|
|
// last frames to leave before tearing the socket down.
|
|
flushed chan struct{}
|
|
|
|
mu sync.Mutex
|
|
nick string
|
|
room *room
|
|
playerID game.PlayerID
|
|
|
|
submitLimiter *bucket
|
|
roomLimiter *bucket
|
|
chatLimiter *bucket
|
|
frameLimiter *bucket
|
|
|
|
// reportedWords is every distinct word this session has filed with
|
|
// ReportWord, capped at maxWordReportsPerSession. Touched only from
|
|
// dispatch, which is the sole reader of this connection's frames, so it
|
|
// needs no lock of its own — unlike nick/room/playerID above, nothing else
|
|
// ever reads or writes it.
|
|
reportedWords map[string]struct{}
|
|
|
|
// greeted marks the handshake done. It is a one-shot transition: a second
|
|
// Hello would re-register the session and rewrite its nickname mid-game.
|
|
// Touched only from dispatch, like reportedWords, so it needs no lock.
|
|
greeted bool
|
|
|
|
// helloTimer closes the connection if the handshake never arrives. Armed
|
|
// by run before the first read and stopped by handleHello, both on the
|
|
// reader goroutine, so it needs no lock either. Nil for a session that
|
|
// was never run.
|
|
helloTimer *time.Timer
|
|
}
|
|
|
|
func newSession(ctx context.Context, conn *websocket.Conn, h *hub, remoteIP string) *session {
|
|
readCtx, cancelRead := context.WithCancel(context.Background())
|
|
ctx, cancel := context.WithCancel(ctx)
|
|
return &session{
|
|
readCtx: readCtx,
|
|
cancelRead: cancelRead,
|
|
flushed: make(chan struct{}),
|
|
id: randomToken(),
|
|
resumeToken: randomToken(),
|
|
remoteIP: remoteIP,
|
|
conn: conn,
|
|
hub: h,
|
|
ctx: ctx,
|
|
cancel: cancel,
|
|
out: make(chan []byte, outboxCap),
|
|
submitLimiter: newBucket(submitsPerSecond, submitBurst, time.Now()),
|
|
roomLimiter: newBucket(roomsPerSecond, roomBurst, time.Now()),
|
|
chatLimiter: newBucket(chatsPerSecond, chatBurst, time.Now()),
|
|
frameLimiter: newBucket(framesPerSecond, frameBurst, time.Now()),
|
|
reportedWords: make(map[string]struct{}),
|
|
}
|
|
}
|
|
|
|
func (s *session) nickname() string {
|
|
s.mu.Lock()
|
|
defer s.mu.Unlock()
|
|
return s.nick
|
|
}
|
|
|
|
func (s *session) setNickname(n string) {
|
|
s.mu.Lock()
|
|
defer s.mu.Unlock()
|
|
s.nick = n
|
|
}
|
|
|
|
// attach binds this connection to a room seat.
|
|
func (s *session) attach(r *room, id game.PlayerID) {
|
|
s.mu.Lock()
|
|
previous, previousID := s.room, s.playerID
|
|
s.room = r
|
|
s.playerID = id
|
|
s.mu.Unlock()
|
|
|
|
// Releasing the old room is not tidiness. Nothing else tells it this
|
|
// connection has gone: leaveRoom only ever notifies the current room, so an
|
|
// unreleased room parks in select forever, holding a goroutine and a room
|
|
// code for the life of the process. One connection asking for several rooms
|
|
// is all it takes.
|
|
//
|
|
// Delivered reliably, and from its own goroutine: this runs on the goroutine
|
|
// of the room being entered, and a room waiting on another room's full
|
|
// inbox from there could wait on each other. A dropped notice would leave
|
|
// the old seat bound to this connection for good.
|
|
if previous != nil && previous != r {
|
|
go previous.sendReliably(disconnectInput{player: previousID, sess: s})
|
|
}
|
|
}
|
|
|
|
// release forgets a room this connection is no longer seated in, because it
|
|
// left or was kicked. The connection itself stays open.
|
|
//
|
|
// Guarded by identity: a release from a room the connection has already moved
|
|
// on from must not detach it from the one it is sitting in now.
|
|
func (s *session) release(r *room) {
|
|
s.mu.Lock()
|
|
defer s.mu.Unlock()
|
|
if s.room == r {
|
|
s.room = nil
|
|
s.playerID = ""
|
|
}
|
|
}
|
|
|
|
func (s *session) currentRoom() (*room, game.PlayerID) {
|
|
s.mu.Lock()
|
|
defer s.mu.Unlock()
|
|
return s.room, s.playerID
|
|
}
|
|
|
|
// send queues a message for the writer goroutine.
|
|
//
|
|
// Never blocks: the room goroutine calls this, and one unresponsive client
|
|
// must not be able to stall the game its opponent is still playing. A full
|
|
// outbox closes the session instead.
|
|
func (s *session) send(m *noituv1.ServerMessage) {
|
|
if s.enqueue(m) == errOutboxFull {
|
|
slog.Warn("outbox full, closing session", "session", s.id)
|
|
s.close()
|
|
}
|
|
}
|
|
|
|
// trySend queues a message and reports whether it fit.
|
|
//
|
|
// The difference from send is what a full outbox means: send closes the
|
|
// session, on the grounds that a client this far behind will not catch up.
|
|
// That is right for a game frame and wrong for a chat line, because it hands
|
|
// one player a way to disconnect the other into losing by abandonment.
|
|
//
|
|
// A line is droppable because the next replay carries it. A ChatHistory is
|
|
// not — it is the frame that corrects a whole panel, and there is nothing
|
|
// behind it — so that one still goes through send. This is for ChatMessage.
|
|
func (s *session) trySend(m *noituv1.ServerMessage) bool {
|
|
err := s.enqueue(m)
|
|
if err == errOutboxFull {
|
|
slog.Warn("outbox full, dropping chat", "session", s.id)
|
|
}
|
|
return err == nil
|
|
}
|
|
|
|
// errOutboxFull and errSessionClosed are why enqueue did not queue a frame.
|
|
// Only the first is the caller's to act on: a closed session is already on
|
|
// its way out, and a frame for it is simply not needed any more.
|
|
var (
|
|
errOutboxFull = errors.New("wsapi: outbox full")
|
|
errSessionClosed = errors.New("wsapi: session closed")
|
|
)
|
|
|
|
// enqueue encodes m and offers it to the outbox without blocking.
|
|
func (s *session) enqueue(m *noituv1.ServerMessage) error {
|
|
raw, err := Encode(m)
|
|
if err != nil {
|
|
slog.Error("encode failed", "session", s.id, "err", err)
|
|
return err
|
|
}
|
|
|
|
select {
|
|
case s.out <- raw:
|
|
return nil
|
|
case <-s.ctx.Done():
|
|
return errSessionClosed
|
|
default:
|
|
return errOutboxFull
|
|
}
|
|
}
|
|
|
|
// close signals teardown. It does not cancel the read context: that is done by
|
|
// run once the writer has flushed, so a client is told why it is being
|
|
// disconnected before the socket goes.
|
|
func (s *session) close() { s.cancel() }
|
|
|
|
// run drives the connection until it closes.
|
|
func (s *session) run() {
|
|
defer s.close()
|
|
defer s.leaveRoom()
|
|
// A connection that ends while queued must not leave a ghost in line: the
|
|
// next two strangers to ask are paired with each other, not with a socket
|
|
// that is already gone.
|
|
defer s.hub.cancelQuickMatch(s)
|
|
|
|
s.conn.SetReadLimit(maxFrameBytes)
|
|
|
|
timeout := helloTimeout
|
|
if s.hub != nil && s.hub.helloTimeout > 0 {
|
|
timeout = s.hub.helloTimeout
|
|
}
|
|
s.helloTimer = time.AfterFunc(timeout, func() {
|
|
s.send(errorMsg(codeHandshakeRequired))
|
|
s.close()
|
|
})
|
|
defer s.helloTimer.Stop()
|
|
|
|
var wg sync.WaitGroup
|
|
wg.Add(3)
|
|
go func() { defer wg.Done(); s.writeLoop() }()
|
|
go func() { defer wg.Done(); s.keepalive() }()
|
|
|
|
// Teardown ordering lives in its own goroutine because readLoop below is
|
|
// blocked in Read and cannot run it. Whoever signals the close — a
|
|
// protocol error here, a dead peer in keepalive, or Shutdown cancelling
|
|
// the server context — gets the same sequence: flush, then drop the
|
|
// socket.
|
|
go func() {
|
|
defer wg.Done()
|
|
<-s.ctx.Done()
|
|
select {
|
|
case <-s.flushed:
|
|
case <-time.After(drainTimeout):
|
|
}
|
|
s.cancelRead()
|
|
}()
|
|
|
|
err := s.readLoop()
|
|
s.close()
|
|
wg.Wait()
|
|
|
|
// readLoop only ever returns an error; a cancelled context is the one
|
|
// that means the server chose to close.
|
|
status, reason := websocket.StatusNormalClosure, ""
|
|
if !errors.Is(err, context.Canceled) {
|
|
status, reason = websocket.StatusPolicyViolation, "protocol error"
|
|
}
|
|
_ = s.conn.Close(status, reason)
|
|
}
|
|
|
|
// readLoop is the only reader of the socket.
|
|
func (s *session) readLoop() error {
|
|
for {
|
|
typ, raw, err := s.conn.Read(s.readCtx)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
if !s.frameLimiter.allow(time.Now()) {
|
|
s.send(errorMsg(codeTooFast))
|
|
return errFlood
|
|
}
|
|
|
|
msg, err := Decode(typ, raw)
|
|
if err != nil {
|
|
s.send(errorMsg(codeBadFrame))
|
|
return err
|
|
}
|
|
if err := s.dispatch(msg); err != nil {
|
|
return err
|
|
}
|
|
}
|
|
}
|
|
|
|
// writeLoop is the only writer of the socket. A single owner keeps frame order
|
|
// deterministic even though coder/websocket tolerates concurrent writes.
|
|
func (s *session) writeLoop() {
|
|
defer close(s.flushed)
|
|
|
|
for {
|
|
select {
|
|
case <-s.ctx.Done():
|
|
s.drain()
|
|
return
|
|
case raw := <-s.out:
|
|
if !s.write(raw) {
|
|
return
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
// write sends one frame.
|
|
//
|
|
// The deadline is its own, not derived from the session context. A frame that
|
|
// has already been dequeued must still reach the peer even when the session is
|
|
// ending — refusals are sent immediately before a close, and inheriting the
|
|
// cancelled context would fail every one of them. The timeout is what protects
|
|
// against a peer that has stopped reading.
|
|
func (s *session) write(raw []byte) bool {
|
|
ctx, cancel := context.WithTimeout(context.Background(), writeTimeout)
|
|
defer cancel()
|
|
|
|
if err := s.conn.Write(ctx, websocket.MessageBinary, raw); err != nil {
|
|
s.close()
|
|
return false
|
|
}
|
|
return true
|
|
}
|
|
|
|
// drain flushes what is already queued after the session is cancelled.
|
|
//
|
|
// Refusals are the reason this exists: the server answers a bad handshake with
|
|
// a UI key and then closes, and without this the close wins the race and the
|
|
// client is left to guess why it was dropped. The context is fresh because
|
|
// s.ctx is by definition already cancelled here, and bounded because a peer
|
|
// that is not reading must not delay teardown.
|
|
func (s *session) drain() {
|
|
for {
|
|
select {
|
|
case raw := <-s.out:
|
|
ctx, cancel := context.WithTimeout(context.Background(), drainTimeout)
|
|
err := s.conn.Write(ctx, websocket.MessageBinary, raw)
|
|
cancel()
|
|
if err != nil {
|
|
return
|
|
}
|
|
default:
|
|
return
|
|
}
|
|
}
|
|
}
|
|
|
|
// keepalive is what actually detects a dead peer, since reads have no deadline.
|
|
func (s *session) keepalive() {
|
|
ticker := time.NewTicker(pingEvery)
|
|
defer ticker.Stop()
|
|
|
|
misses := 0
|
|
for {
|
|
select {
|
|
case <-s.ctx.Done():
|
|
return
|
|
case <-ticker.C:
|
|
ctx, cancel := context.WithTimeout(s.ctx, pingTimeout)
|
|
err := s.conn.Ping(ctx)
|
|
cancel()
|
|
|
|
if err == nil {
|
|
misses = 0
|
|
continue
|
|
}
|
|
if misses++; misses >= pingMisses {
|
|
slog.Info("peer unresponsive, closing", "session", s.id)
|
|
s.close()
|
|
return
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
func randomToken() string {
|
|
raw := make([]byte, 16)
|
|
_, _ = rand.Read(raw)
|
|
return base64.RawURLEncoding.EncodeToString(raw)
|
|
}
|