Files
noitu/server/internal/wsapi/server.go
T
tiennm99 442ced32cc fix(server): name the player on every played word, and bound the process
PlayedWord.player_id was declared and read by the client but never set by
the server, so a room of three or four never showed who played each word.
The chain byline now comes from the room, with a producer-side test.

The process also gains the ceilings it was missing: a cap on live rooms and
on open sockets, a per-connection frame-rate limit so a payload-less frame
is no longer free, and an opt-in trusted-proxy list so the join limiter can
tell players apart behind the documented reverse proxy instead of putting
them in one bucket. The typed word is sanitized before the engine stores it,
since every seat is shown it; a room exiting on its idle clock releases the
sessions still bound to it; the dictionary builder escapes its SQLite path
like the store does and renames over the old database instead of deleting
it first.
2026-09-21 00:38:09 +07:00

279 lines
8.4 KiB
Go

package wsapi
import (
"context"
"log/slog"
"net"
"net/http"
"net/netip"
"os"
"path/filepath"
"strings"
"sync/atomic"
"time"
"github.com/coder/websocket"
)
// Config is everything the transport layer needs to run.
type Config struct {
// TurnLimit is the same for bot and PvP games: one constant, one code
// path, no mode-specific timing to reason about.
TurnLimit time.Duration
// GraceFor is how long a disconnected seat is held open.
GraceFor time.Duration
// IdleFor is how long a room sits in its lobby with no game started before
// it closes. Zero falls back to a built-in default.
IdleFor time.Duration
// AllowedOrigins is matched by coder/websocket against the Origin header.
// Empty means same-origin only, which is the right default for a binary
// that also serves the frontend.
AllowedOrigins []string
// WebDir is the built frontend. Empty, or missing on disk, serves the API
// alone, which is how the server runs before the frontend has been built.
WebDir string
// TrustedProxies lists the addresses, or CIDR ranges, of reverse proxies
// whose X-Forwarded-For header is believed. Empty means the header is
// ignored and every limiter keys on the socket's own peer address.
TrustedProxies []string
// MaxRooms caps live rooms across the process; zero means a built-in
// default. MaxConnections caps open sockets the same way.
MaxRooms int
MaxConnections int
}
// defaultMaxConnections bounds open WebSockets when nothing else is set. Each
// one is three goroutines and an outbox; the number is generous for one
// binary and small next to what the host can hold.
const defaultMaxConnections = 2000
// Server wires the hub to an HTTP mux.
type Server struct {
hub *hub
mux *http.ServeMux
cancel context.CancelFunc
cfg Config
proxies []netip.Prefix
maxConns int64
conns atomic.Int64
}
// NewServer builds the handler tree.
func NewServer(ctx context.Context, dict Dictionary, cfg Config) *Server {
ctx, cancel := context.WithCancel(ctx)
s := &Server{
hub: newHub(ctx, dict, cfg.TurnLimit, cfg.GraceFor, cfg.IdleFor, cfg.MaxRooms),
mux: http.NewServeMux(),
cancel: cancel,
cfg: cfg,
proxies: parsePrefixes(cfg.TrustedProxies),
maxConns: int64(cfg.MaxConnections),
}
if s.maxConns <= 0 {
s.maxConns = defaultMaxConnections
}
s.mux.HandleFunc("GET /ws", s.handleWS)
s.mux.HandleFunc("GET /healthz", func(w http.ResponseWriter, _ *http.Request) {
w.WriteHeader(http.StatusOK)
_, _ = w.Write([]byte("ok"))
})
s.mountStatic()
go s.sweepLimiters(ctx)
return s
}
func (s *Server) ServeHTTP(w http.ResponseWriter, r *http.Request) { s.mux.ServeHTTP(w, r) }
// Shutdown tells live games why they are ending, then stops the hub.
func (s *Server) Shutdown() {
s.hub.shutdown()
s.cancel()
}
func (s *Server) handleWS(w http.ResponseWriter, r *http.Request) {
// Refused before the upgrade, so a client that is over the line is told
// so in HTTP terms it can read, and never costs a socket.
if s.conns.Add(1) > s.maxConns {
s.conns.Add(-1)
http.Error(w, "server full", http.StatusServiceUnavailable)
return
}
defer s.conns.Add(-1)
conn, err := websocket.Accept(w, r, &websocket.AcceptOptions{
OriginPatterns: s.cfg.AllowedOrigins,
})
if err != nil {
// Accept has already written the rejection, including the origin
// refusal, so there is nothing to add to the response here.
slog.Debug("websocket accept rejected", "err", err, "origin", r.Header.Get("Origin"))
return
}
sess := newSession(s.hub.ctx, conn, s.hub, s.clientIP(r))
sess.run()
// The token has to outlive the socket by exactly the grace window: that is
// what a reconnect presents to reclaim its seat. Dropping it here, as the
// connection ends, would make every resume fail to find its game.
s.hub.expireToken(sess.resumeToken, s.cfg.GraceFor)
}
// immutablePrefix is where SvelteKit's adapter puts content-hashed assets.
const immutablePrefix = "/_app/immutable/"
// mountStatic serves the built frontend so one binary is the whole deployment.
//
// Unknown paths fall back to index.html because the frontend is a single-page
// app: a deep link is a client route, not a server 404.
func (s *Server) mountStatic() {
if s.cfg.WebDir == "" {
return
}
index := filepath.Join(s.cfg.WebDir, "index.html")
if _, err := os.Stat(index); err != nil {
slog.Warn("no frontend to serve", "dir", s.cfg.WebDir)
return
}
root := filepath.Clean(s.cfg.WebDir)
files := http.FileServer(http.Dir(root))
s.mux.HandleFunc("/", func(w http.ResponseWriter, r *http.Request) {
clean := filepath.Join(root, filepath.Clean(r.URL.Path))
// A path boundary, not a string prefix: with a root of /srv/web, a
// prefix test would also accept /srv/webhooks. http.Dir re-anchors
// anyway, but the SPA fallback below stats paths directly, so this is
// the check that keeps it from being used to probe outside the bundle.
if !underRoot(root, clean) {
http.NotFound(w, r)
return
}
if info, err := os.Stat(clean); err == nil && !info.IsDir() {
// Everything under immutablePrefix carries a content hash in its
// name, so a changed file is a changed URL and the old one can be
// cached forever.
if strings.HasPrefix(r.URL.Path, immutablePrefix) {
w.Header().Set("Cache-Control", "public, max-age=31536000, immutable")
}
files.ServeHTTP(w, r)
return
}
// The shell names those hashed assets, so a cached copy outlives the
// deploy that renamed them and the app loads into a blank page.
w.Header().Set("Cache-Control", "no-cache")
http.ServeFile(w, r, index)
})
}
// underRoot reports whether path is root itself or lies beneath it.
func underRoot(root, path string) bool {
rel, err := filepath.Rel(root, path)
if err != nil {
return false
}
return rel == "." || (!strings.HasPrefix(rel, "..") && !filepath.IsAbs(rel))
}
// clientIP is the key the join limiter counts against.
//
// RemoteAddr is the default and the only source when no proxy is trusted:
// X-Forwarded-For is attacker-controlled unless the proxy is known to append
// to it, and trusting it unconditionally would let one client spend everyone
// else's budget by forging the header. When the peer is a configured proxy,
// the header is walked from the right and the first address that is not
// itself a trusted proxy is the client — the entries a client could have
// forged all sit to the left of the one the proxy appended.
func (s *Server) clientIP(r *http.Request) string {
peer := remoteHost(r.RemoteAddr)
if len(s.proxies) == 0 || !s.trusted(peer) {
return peer
}
var hops []string
for _, v := range r.Header.Values("X-Forwarded-For") {
hops = append(hops, strings.Split(v, ",")...)
}
for i := len(hops) - 1; i >= 0; i-- {
hop := strings.TrimSpace(hops[i])
if hop == "" || s.trusted(hop) {
continue
}
if _, err := netip.ParseAddr(hop); err != nil {
// A malformed hop is a header somebody wrote by hand; fall back
// to the proxy's address rather than key a limiter on garbage.
return peer
}
return hop
}
return peer
}
// trusted reports whether host is one of the configured proxies.
func (s *Server) trusted(host string) bool {
addr, err := netip.ParseAddr(host)
if err != nil {
return false
}
addr = addr.Unmap()
for _, p := range s.proxies {
if p.Contains(addr) {
return true
}
}
return false
}
// remoteHost strips the port from a RemoteAddr.
func remoteHost(remoteAddr string) string {
host, _, err := net.SplitHostPort(remoteAddr)
if err != nil {
return remoteAddr
}
return host
}
// parsePrefixes reads proxy addresses as CIDR ranges, accepting a bare
// address as a range of one. An entry that parses as neither is logged and
// skipped rather than silently trusting nothing or everything.
func parsePrefixes(raw []string) []netip.Prefix {
var out []netip.Prefix
for _, entry := range raw {
entry = strings.TrimSpace(entry)
if entry == "" {
continue
}
if p, err := netip.ParsePrefix(entry); err == nil {
out = append(out, p.Masked())
continue
}
if a, err := netip.ParseAddr(entry); err == nil {
a = a.Unmap()
out = append(out, netip.PrefixFrom(a, a.BitLen()))
continue
}
slog.Warn("ignoring unparseable trusted proxy", "entry", entry)
}
return out
}
// sweepLimiters keeps the per-key rate limiter from growing without bound.
func (s *Server) sweepLimiters(ctx context.Context) {
ticker := time.NewTicker(limiterIdleFor)
defer ticker.Stop()
for {
select {
case <-ctx.Done():
return
case now := <-ticker.C:
s.hub.joinLimiter.sweep(now)
}
}
}