Documentation
¶
Overview ¶
Package ws is the WebSocket hub for live scoreboard updates: a connection registry, throttled latest-wins broadcast, ping/pong keepalive, and graceful drain on shutdown. It broadcasts apigen.Scoreboard — the SAME wire type the REST endpoint returns — so a client that reconciles WS pushes against a REST fetch sees byte-identical payloads by construction.
Index ¶
Constants ¶
const ( FrameHello = "hello" // greeting; carries the current frozen flag FrameScoreboard = "scoreboard" // a scoreboard snapshot (apigen.Scoreboard) FrameEventPhase = "event.phase" // an event phase transition )
Frame types emitted on the scoreboard WebSocket. This is the single source of truth for the wire protocol's frame names — the hub references these constants instead of string literals, and frame_types.json is generated from FrameTypes.
The dashboard's ScoreboardSocket must know exactly this set: a frame added here that the client does not branch on is parsed and silently dropped. A contract test on each side asserts its known set against frame_types.json, so adding a backend frame without teaching the dashboard about it fails CI (4a-ii).
Variables ¶
var FrameTypes = []string{FrameEventPhase, FrameHello, FrameScoreboard}
FrameTypes is the authoritative set of frame names, sorted for a stable contract file. Keep frame_types.json in sync (the ws contract test rewrites it with UPDATE_GOLDEN=1).
Functions ¶
This section is empty.
Types ¶
type Hub ¶
type Hub struct {
// contains filtered or unexported fields
}
Hub owns the set of live connections and fans out broadcasts.
func NewHub ¶
NewHub builds a hub with the default connection limits. Call Run in a goroutine to start it; use SetLimits/SetKeyResolver before serving to override the defaults.
func (*Hub) BroadcastPhase ¶
BroadcastPhase notifies clients of an event phase transition (not throttled; phase changes are rare and must arrive promptly).
func (*Hub) BroadcastScoreboard ¶
func (h *Hub) BroadcastScoreboard(snap apigen.Scoreboard)
BroadcastScoreboard queues a scoreboard snapshot for delivery (throttled). It takes apigen.Scoreboard so the "data" it emits is byte-identical to the REST body.
func (*Hub) Handler ¶
func (h *Hub) Handler() http.HandlerFunc
Handler upgrades the request to a WebSocket, registers the connection, and pumps broadcasts until the client disconnects or the server shuts down. The endpoint is public (no per-connection auth), so admission control — connection caps and a handshake rate limit — is what keeps an unauthenticated client from opening connections until the process dies. A rejected handshake returns 429 BEFORE the upgrade, so it never allocates a connection or touches the hub; existing clients are unaffected.
func (*Hub) Run ¶
Run is the hub's single-goroutine event loop. It returns when ctx is cancelled, closing all connections with code 1001 (going away).
func (*Hub) SetKeyResolver ¶
SetKeyResolver installs the admission-key resolver: authenticated connections should key on the user id and anonymous ones on the (proxy-aware) client IP, so a shared NAT of logged-in players is not throttled as one IP. Call before serving. Nil (the default) keys on the socket peer IP.
type Limits ¶
type Limits struct {
MaxConns int // global cap on live connections (0 = unlimited)
MaxConnsPerKey int // cap on live connections per admission key (0 = unlimited)
HandshakeBurst int // max handshake attempts per key within HandshakeWindow (0 = unlimited)
HandshakeWindow time.Duration // sliding window for the handshake rate limit
}
Limits bounds the public, unauthenticated WebSocket endpoint. Without them a single client can open connections until the process dies — each live connection holds two goroutines (read/write pumps) and a send buffer — and CTF participants are exactly the population that will try. A zero field disables that particular limit.
The per-client caps and the handshake rate are keyed by an admission KEY, not raw IP: authenticated connections key on the user id (so a whole campus/venue NAT of logged-in players is not squeezed through one IP budget — GitHub issue #1's shared-IP class), and only anonymous connections fall back to the client IP.
Per-key keying keyed on the user is bypassable: an attacker who registers N accounts gets N × MaxConnsPerKey connections. Registration limits bound N only loosely (and issue #1 wants those raised for shared-IP venues), so the per-key cap is a fairness limit, not the DoS backstop. The GLOBAL cap (MaxConns, clamped to the shared file-descriptor budget by the fd accountant — internal/fdbudget — at startup) is the real backstop against process exhaustion.
func DefaultLimits ¶
func DefaultLimits() Limits
DefaultLimits protects a single-server deployment without squeezing a large event. Generous per-key budgets plus per-user keying mean a NAT full of logged-in players is unaffected; the caps still stop any single key — and the global ceiling any fleet — from exhausting the process. Operators can override every value (OSCTF_WS_*).