docs: document the per-IP cap and record two open decisions

NOITU_MAX_CONNECTIONS_PER_IP is now in both env tables, with the
warning that it must stay off behind a proxy unless
NOITU_TRUSTED_PROXIES names it, since every player otherwise shares
one address.

Also records two decisions the review flagged as undocumented rather
than broken: a second tab presenting a live resume token takes the
seat on purpose, and /debug/vars carries the process's argv and heap
stats because expvar always publishes them, which is why it lives on
its own address.
This commit is contained in:
tiennm99 committed 2026-09-21 16:18:31 +07:00
1 parent 2fece2fcea
commit 8008bf6318
2 files changed
+30 -5

No files matched your search

+1
View File
@@ -207,6 +207,7 @@ Configuration is environment-only; every variable has a working default.
| `NOITU_TRUSTED_PROXIES` | *(unset)* | Comma-separated proxy addresses or CIDRs whose `X-Forwarded-For` is believed. Unset keys limiters on the socket peer |
| `NOITU_MAX_ROOMS` | `1000` | Ceiling on live rooms across the process; a creator past it is told `server_full` |
| `NOITU_MAX_CONNECTIONS` | `2000` | Ceiling on open WebSockets; the next upgrade gets HTTP 503 |
| `NOITU_MAX_CONNECTIONS_PER_IP` | `0` (off) | Ceiling on open WebSockets from one address; the next upgrade from it gets HTTP 503. Leave this at `0` behind a reverse proxy unless `NOITU_TRUSTED_PROXIES` is set — otherwise every player behind it shares one address, and turning this on caps them all at whichever gets there first |
| `NOITU_DEBUG_ADDR` | *(unset)* | A separate listen address for `GET /debug/vars` (expvar). Unset means the operational counters are not exposed anywhere |
| `NOITU_DRAIN_TIMEOUT` | `0s` | How long a shutdown waits for live *games* (not lobbies) to finish before ending them anyway. `0s` is today's behaviour: end them immediately |
+29 -5
View File
@@ -21,6 +21,7 @@ so the image runs with nothing set.
| `NOITU_TRUSTED_PROXIES` | *(unset)* | Comma-separated proxy addresses or CIDRs whose `X-Forwarded-For` is believed. Unset keys limiters on the socket peer |
| `NOITU_MAX_ROOMS` | `1000` | Ceiling on live rooms across the process; a creator past it is told `server_full` |
| `NOITU_MAX_CONNECTIONS` | `2000` | Ceiling on open WebSockets; the next upgrade gets HTTP 503 |
| `NOITU_MAX_CONNECTIONS_PER_IP` | `0` (off) | Ceiling on open WebSockets from one address; the next upgrade from it gets HTTP 503 |
| `NOITU_DEBUG_ADDR` | *(unset)* | Separate listen address for `GET /debug/vars` (expvar counters). Unset means the counters exist in the process but nothing serves them |
| `NOITU_DRAIN_TIMEOUT` | `0s` | How long a shutdown waits for live games to finish before ending them anyway; see "Draining on deploy" below |
@@ -168,14 +169,25 @@ client past it is disconnected rather than throttled. The defaults are
generous for one binary on a small host; lower them if memory is tight,
because a room is a goroutine and an engine held for up to its idle window.
A third ceiling, `NOITU_MAX_CONNECTIONS_PER_IP`, bounds how many of those
sockets one address may hold at once, and it is off by default. Turning it on
is safe only once the client's own address (above) is the real one: behind a
reverse proxy with `NOITU_TRUSTED_PROXIES` unset, every player shares the
proxy's own address, and the cap would seat one of them and refuse the rest.
## Observability
Set `NOITU_DEBUG_ADDR` to a second listen address — one that is not the one
players reach — to expose `GET /debug/vars` there: standard-library
[`expvar`](https://pkg.go.dev/expvar), zero extra dependencies, a JSON object
of process counters refreshed on every write. It is never mounted on the
public address, unset or not, so leaving `NOITU_DEBUG_ADDR` unset is the same
as not having it. The counters, all prefixed `noitu_`: connections open and
players reach, and never a public interface — to expose `GET /debug/vars`
there: standard-library [`expvar`](https://pkg.go.dev/expvar), zero extra
dependencies, a JSON object of process counters refreshed on every write. It
is never mounted on the public address, unset or not, so leaving
`NOITU_DEBUG_ADDR` unset is the same as not having it. Besides the counters
below, expvar always publishes the process's full command line and its
runtime memory statistics; that is the standard library's own doing, not
something this server adds, and it is the whole reason `/debug/vars` lives on
a separate address rather than a route on the public mux one config change
could expose. The counters, all prefixed `noitu_`: connections open and
total; rooms live and total, each split `bot`/`pvp`; games started and
finished the same way; words submitted, accepted, and rejected by reason;
eliminations by reason; chat lines; join attempts refused, by whether it was
@@ -258,6 +270,18 @@ turn clock already bounds how long any one game can take, so a timeout a
little over `NOITU_TURN_LIMIT` covers the common case of a handful of games
mid-turn.
## Resuming from a second tab
A resume token is a bearer credential: whoever presents a live one takes the
seat, and the connection that held it before is closed. Opening the same
game in a second tab, or reloading with the token still in `localStorage`, is
therefore a takeover, not a copy — the newest connection to present the token
wins the seat, on purpose. There is no liveness check on the connection being
replaced beyond that; nothing here treats a second tab as an attack, because
the token already proves it came from the same player. A future version that
wants two tabs to share a seat, rather than fight over it, would need a
different design — this one intentionally does not.
## What a restart costs
A restart with `NOITU_DRAIN_TIMEOUT` unset, or a signal harder than `SIGTERM`,