A lightweight WebSocket gateway for delivering real-time messages to players, with server-side publishing, offline queueing, rate limiting, and Prometheus metrics.
- WebSocket connections per player (multiple connections supported)
- Token-based authentication (players & servers)
- Offline message queue with TTL
- Per-IP and per-player connection-attempt rate limiting on
/ws - Broadcast and targeted messaging
- Automatic token revalidation
- Prometheus metrics endpoint
- Graceful shutdown
- JSON or plaintext logging with structured
event/reasonfields
- Players connect via WebSocket (
/ws) using player tokens - Servers publish messages via HTTP (
/publish,/broadcast) using server tokens - Tokens are validated against a SQL database
- Messages are delivered immediately or queued if the player is offline
The two endpoints differ in what happens when the target player is not currently connected:
/publishwithplayer_id- queues the message for offline delivery. If the player connects within-offline-ttl, the message is flushed to the new connection./broadcastwithplayer_id- writes to the player's live connections only. If the player is offline, the message is discarded and the response is still200 OK./broadcastwithoutplayer_id- writes to every currently-connected client. There is nothing to queue against, since there is no single target.
If the intent is "deliver this to player X, now or later," use /publish.
If the intent is "deliver this to player X if they happen to be connected,"
use /broadcast with player_id.
Queued delivery is best-effort and not ordered relative to live traffic.
A player reconnecting is registered as live before their queue is flushed,
so a /publish call that lands in that window is delivered immediately,
ahead of older messages still waiting in their queue. If your application
needs a guaranteed order, put a sequence number or timestamp in the
payload and let the client sort, don't rely on arrival order.
GET /ws?token=PLAYER_TOKEN
POST /publish- Header:
Authorization: Bearer SERVER_TOKEN - Body:
{
"player_id": "1",
"event": "event_name",
"payload": { "any": "json" }
}POST /broadcast- Header:
Authorization: Bearer SERVER_TOKEN - Body (all players):
{
"event": "event_name",
"payload": { "any": "json" }
}- Body (single player):
{
"player_id": "1",
"event": "event_name",
"payload": { "any": "json" }
}GET /metrics(Prometheus format)
-addr- Server address (default:8080)-db- Database driver (defaultsqlite)-dsn- Database DSN (defaultfile:ws_tokens.db?cache=shared)-origins- Comma-separated allowed WS origins-log-file- Path to log file-log-level- Log level (defaultinfo)-log-format- Log format:json(default) ortext-pid-file- Path to PID file-max-conns- Max WS connections per player (default10)-max-queued- Max offline queued messages per player (default100)-offline-ttl- Offline message TTL (default10s)-rate-limit-ip- WS connection attempts per second per client IP (default20, disabled unless this or-rate-burst-ipis set)-rate-burst-ip- WS connection attempt burst per client IP (default60, disabled unless this or-rate-limit-ipis set)-rate-limit-player- WS connection attempts per second per player (default3, disabled unless this or-rate-burst-playeris set)-rate-burst-player- WS connection attempt burst per player (default10, disabled unless this or-rate-limit-playeris set)-trust-xff- TrustX-Forwarded-Forfor the client IP. Only enable when the server is reachable exclusively through a trusted proxy that overwrites the header (defaultfalse)-max-header-value- Max bytes for theCookie,Origin, orX-Forwarded-Forheader on/ws(default8192); oversized values are rejected with431before any other work, including the rate limiter-revalidate-period- Token revalidation interval (default1m)-daemon- Run process as a daemon
Rate limiting is opt-in. It is disabled unless at least one of the four rate
flags is passed on the command line. Setting any of the four to 0 (or a
negative value) is a startup error; to disable the limiter, omit the flags.
Unlike rate limiting, -max-header-value is always on; there's no
legitimate reason for these three headers to be large, so it isn't gated
behind an opt-in flag the way the rate limiter is. Setting it to 0 or
negative is a startup error, since that would reject every /ws connection.
http.Server.MaxHeaderBytes is additionally set to 16KB server-wide (all
headers combined, all endpoints), well under Go's 1MB default, as a cheap
first-line check before a request even reaches a handler.
NOTE: If MaxOpenConns(1) ever shows up as a bottleneck, the alternative is
WAL mode in the DSN, which allows one writer plus concurrent readers:
-dsn "file:ws_tokens.db?_pragma=journal_mode(WAL)&_pragma=busy_timeout(5000)"
Required table:
CREATE TABLE IF NOT EXISTS ws_token (
token VARBINARY(32) NOT NULL PRIMARY KEY,
player_id INT(10) UNSIGNED DEFAULT NULL,
subject_id VARBINARY(32) NOT NULL,
is_server TINYINT(1) NOT NULL DEFAULT 0,
expires_at DATETIME NOT NULL
);
CREATE INDEX IF NOT EXISTS idx_ws_token_player_id ON ws_token (player_id);
CREATE INDEX IF NOT EXISTS idx_ws_token_server_exp ON ws_token (is_server, expires_at);player_idis used for player tokenssubject_idis used for server tokens
ws-server does not check expires_at itself; validateToken() only checks that a row with the
given token and is_server value exists. Expiration is enforced out-of-band by a DB event:
CREATE EVENT ev_ws_token_expiration
ON SCHEDULE EVERY 10 MINUTE ON COMPLETION PRESERVE ENABLE
DO
BEGIN
DELETE FROM ws_token WHERE is_server=0 AND expires_at<=NOW();
END;- Only
is_server=0(player) rows are swept. A player token can stay usable for up to ~10 minutes past its ownexpires_at, until the next event run. is_server=1(server) rows are never touched by this event and are not deleted byws-servereither; server token lifecycle is managed wherever tokens are issued.
ws_active_connectionsws_messages_published_totalws_messages_delivered_total
All output is JSON by default, one object per line, written to -log-file or
stdout. Set -log-format text for a human-readable format (colors disabled
when writing to a file).
Lines are namespaced by event (family) and, where a family has more than one
cause, reason (specific case). Log level is a property of the reason, not
the event.
See LOGGING.md for the event/reason/level reference, the fields on each line, and the nginx configuration required for client IPs to appear correctly.
go build
./ws-server \
-db sqlite \
-dsn file:ws_tokens.db?cache=shared
# or
./ws-server \
-db mysql \
-dsn "root@/echoCTF"- Designed to be stateless except for in-memory queues
- Offline messages are best-effort (not persisted) and not ordered relative to live traffic, see "Broadcast vs. Publish: offline behavior" above
- Suitable for game backends, notification systems, and real-time apps